First commit
This commit is contained in:
commit
243c04c51b
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*.pyc
|
||||||
|
__pycache__
|
339
LICENSE
Normal file
339
LICENSE
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc., <http://fsf.org/>
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
convey the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
{description}
|
||||||
|
Copyright (C) {year} {fullname}
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along
|
||||||
|
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program is interactive, make it output a short notice like this
|
||||||
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
|
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, the commands you use may
|
||||||
|
be called something other than `show w' and `show c'; they could even be
|
||||||
|
mouse-clicks or menu items--whatever suits your program.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
|
|
||||||
|
{signature of Ty Coon}, 1 April 1989
|
||||||
|
Ty Coon, President of Vice
|
||||||
|
|
||||||
|
This General Public License does not permit incorporating your program into
|
||||||
|
proprietary programs. If your program is a subroutine library, you may
|
||||||
|
consider it more useful to permit linking proprietary applications with the
|
||||||
|
library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License.
|
180
README.rst
Normal file
180
README.rst
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
|PythonPIP|_ |PythonSupport|_ |License|_ |Codacy|_ |Coverage|_ |RTFD|_ |Travis|_
|
||||||
|
|
||||||
|
pymailq - Simple Postfix queue management
|
||||||
|
=========================================
|
||||||
|
|
||||||
|
| **Contact:** Denis 'jawa' Pompilio <denis.pompilio@gmail.com>
|
||||||
|
| **Sources:** https://github.com/outini/pymailq/
|
||||||
|
|
|
||||||
|
| A full content documentation, is online at https://pymailq.readthedocs.io/en/latest/
|
||||||
|
|
|
||||||
|
| The pymailq module makes it easy to view and control Postfix mails queue. It
|
||||||
|
| provide several classes to store, view and interact with mail queue using
|
||||||
|
| Postfix command line tools. This module is provided for automation and
|
||||||
|
| monitoring developments.
|
||||||
|
|
|
||||||
|
| This project also provides a shell-like to interact with Postfix mails queue.
|
||||||
|
| It provide simple means to view the queue content, filter mails on criterias
|
||||||
|
| like Sender or delivery errors and lead administrative operations.
|
||||||
|
|
||||||
|
Installation
|
||||||
|
------------
|
||||||
|
|
||||||
|
Install pymailq module from https://pypi.python.org::
|
||||||
|
|
||||||
|
pip install pymailq
|
||||||
|
|
||||||
|
Install pymailq module from sources::
|
||||||
|
|
||||||
|
python setup.py install
|
||||||
|
|
||||||
|
A SPEC file is also provided for RPM builds (currently tested only on Fedora),
|
||||||
|
thanks to Nils Ratusznik (https://github.com/ahpnils). Debian binary packages
|
||||||
|
are also available.
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
------------
|
||||||
|
|
||||||
|
This module actually support the following Python versions:
|
||||||
|
|
||||||
|
* *Python 2.7*
|
||||||
|
* *Python 3+*
|
||||||
|
|
||||||
|
A shell is provided for interactive administration. Based on Python *cmd*
|
||||||
|
module, using Python compiled with *readline* support is highly recommended
|
||||||
|
to access shell's full features.
|
||||||
|
|
||||||
|
Using the shell
|
||||||
|
---------------
|
||||||
|
|
||||||
|
Mails queue summary::
|
||||||
|
|
||||||
|
~$ pqshell --summary
|
||||||
|
|
||||||
|
====================== Mail queue summary ========================
|
||||||
|
Total mails in queue: 1773
|
||||||
|
Total queue size: 40.2 MB
|
||||||
|
|
||||||
|
Mails by accepted date:
|
||||||
|
last 24h: 939
|
||||||
|
1 to 4 days ago: 326
|
||||||
|
older than 4 days: 173
|
||||||
|
|
||||||
|
----- Mails by status ---------- ----- Mails by size ----------
|
||||||
|
Active 2 Average size 23.239 KB
|
||||||
|
Hold 896 Maximum size 1305.029 KB
|
||||||
|
Deferred 875 Minimum size 0.517 KB
|
||||||
|
|
||||||
|
----- Unique senders ----------- ----- Unique recipients ------
|
||||||
|
Senders 156 Recipients 1003
|
||||||
|
Domains 141 Domains 240
|
||||||
|
|
||||||
|
----- Top senders ------------------------------------------------
|
||||||
|
228 sender-3@domain-1.tld
|
||||||
|
195 sender-1@domain-4.tld
|
||||||
|
116 MAILER-DAEMON
|
||||||
|
105 sender-2@domain-2.tld
|
||||||
|
61 sender-7@domain-3.tld
|
||||||
|
|
||||||
|
----- Top sender domains -----------------------------------------
|
||||||
|
228 domain-1.tld
|
||||||
|
195 domain-4.tld
|
||||||
|
105 domain-2.tld
|
||||||
|
75 domain-0.tld
|
||||||
|
61 domain-3.tld
|
||||||
|
|
||||||
|
----- Top recipients ---------------------------------------------
|
||||||
|
29 user-1@domain-5.tld
|
||||||
|
28 user-5@domain-9.tld
|
||||||
|
23 user-2@domain-8.tld
|
||||||
|
20 user-3@domain-6.tld
|
||||||
|
20 user-4@domain-7.tld
|
||||||
|
|
||||||
|
----- Top recipient domains --------------------------------------
|
||||||
|
697 domain-7.tld
|
||||||
|
455 domain-5.tld
|
||||||
|
37 domain-6.tld
|
||||||
|
37 domain-9.tld
|
||||||
|
34 domain-8.tld
|
||||||
|
|
||||||
|
Using the shell in interactive mode::
|
||||||
|
|
||||||
|
~$ pqshell
|
||||||
|
Welcome to PyMailq shell.
|
||||||
|
PyMailq (sel:0)> store load
|
||||||
|
500 mails loaded from queue
|
||||||
|
PyMailq (sel:500)> show selected limit 5
|
||||||
|
2017-09-02 17:54:34 B04C91183774 [deferred] sender-6@test-domain.tld (425B)
|
||||||
|
2017-09-02 17:54:34 B21D71183681 [deferred] sender-2@test-domain.tld (435B)
|
||||||
|
2017-09-02 17:54:34 B422D11836AB [deferred] sender-7@test-domain.tld (2416B)
|
||||||
|
2017-09-02 17:54:34 B21631183753 [deferred] sender-6@test-domain.tld (425B)
|
||||||
|
2017-09-02 17:54:34 F2A7E1183789 [deferred] sender-2@test-domain.tld (2416B)
|
||||||
|
...Preview of first 5 (495 more)...
|
||||||
|
PyMailq (sel:500)> show selected limit 5 long
|
||||||
|
2017-09-02 17:54:34 B04C91183774 [deferred] sender-6@test-domain.tld (425B)
|
||||||
|
Rcpt: user-3@test-domain.tld
|
||||||
|
Err: Test error message
|
||||||
|
2017-09-02 17:54:34 B21D71183681 [deferred] sender-2@test-domain.tld (435B)
|
||||||
|
Rcpt: user-3@test-domain.tld
|
||||||
|
Err: Test error message
|
||||||
|
2017-09-02 17:54:34 B422D11836AB [deferred] sender-7@test-domain.tld (2416B)
|
||||||
|
Rcpt: user-2@test-domain.tld
|
||||||
|
Err: mail transport unavailable
|
||||||
|
2017-09-02 17:54:34 B21631183753 [deferred] sender-6@test-domain.tld (425B)
|
||||||
|
Rcpt: user-3@test-domain.tld
|
||||||
|
Err: mail transport unavailable
|
||||||
|
2017-09-02 17:54:34 F2A7E1183789 [deferred] sender-2@test-domain.tld (2416B)
|
||||||
|
Rcpt: user-1@test-domain.tld
|
||||||
|
Err: mail transport unavailable
|
||||||
|
...Preview of first 5 (495 more)...
|
||||||
|
PyMailq (sel:500)> select error "Test error message"
|
||||||
|
PyMailq (sel:16)> show selected rankby sender
|
||||||
|
sender count
|
||||||
|
================================================
|
||||||
|
sender-2@test-domain.tld 7
|
||||||
|
sender-4@test-domain.tld 3
|
||||||
|
sender-6@test-domain.tld 2
|
||||||
|
sender-5@test-domain.tld 1
|
||||||
|
sender-8@test-domain.tld 1
|
||||||
|
sender-3@test-domain.tld 1
|
||||||
|
sender-1@test-domain.tld 1
|
||||||
|
PyMailq (sel:16)> select sender sender-2@test-domain.tld
|
||||||
|
PyMailq (sel:7)> super hold
|
||||||
|
postsuper: Placed on hold: 7 messages
|
||||||
|
PyMailq (sel:7)> select reset
|
||||||
|
Selector resetted with store content (500 mails)
|
||||||
|
PyMailq (sel:500)> show selected rankby status
|
||||||
|
status count
|
||||||
|
================================================
|
||||||
|
deferred 493
|
||||||
|
hold 7
|
||||||
|
PyMailq (sel:500)> exit
|
||||||
|
Exiting shell... Bye.
|
||||||
|
|
||||||
|
Packaging
|
||||||
|
---------
|
||||||
|
|
||||||
|
Binary packages for some linux distribution are available. See the *packaging*
|
||||||
|
directory for more information.
|
||||||
|
|
||||||
|
License
|
||||||
|
-------
|
||||||
|
|
||||||
|
"GNU GENERAL PUBLIC LICENSE" (Version 2) *(see LICENSE file)*
|
||||||
|
|
||||||
|
|
||||||
|
.. |PythonPIP| image:: https://img.shields.io/pypi/v/pymailq.svg
|
||||||
|
.. _PythonPIP: https://pypi.python.org/pypi/pymailq/
|
||||||
|
.. |PythonSupport| image:: https://img.shields.io/badge/python-2.7,%203.4,%203.5,%203.6-blue.svg
|
||||||
|
.. _PythonSupport: https://github.com/outini/pymailq/
|
||||||
|
.. |License| image:: https://img.shields.io/badge/license-GPLv2-blue.svg
|
||||||
|
.. _License: https://github.com/outini/pymailq/
|
||||||
|
.. |Codacy| image:: https://api.codacy.com/project/badge/Grade/8444a0f124fe463d86a91d80a2a52e7c
|
||||||
|
.. _Codacy: https://www.codacy.com/app/outini/pymailq
|
||||||
|
.. |Coverage| image:: https://api.codacy.com/project/badge/Coverage/8444a0f124fe463d86a91d80a2a52e7c
|
||||||
|
.. _Coverage: https://www.codacy.com/app/outini/pymailq
|
||||||
|
.. |RTFD| image:: https://readthedocs.org/projects/pymailq/badge/?version=latest
|
||||||
|
.. _RTFD: http://pymailq.readthedocs.io/en/latest/?badge=latest
|
||||||
|
.. |Travis| image:: https://travis-ci.org/outini/pymailq.svg?branch=master
|
||||||
|
.. _Travis: https://travis-ci.org/outini/pymailq
|
||||||
|
|
116
__init__.py
Normal file
116
__init__.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
#
|
||||||
|
# Postfix queue control python tool (pymailq)
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014 Denis Pompilio (jawa) <denis.pompilio@gmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of pymailq
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import shlex
|
||||||
|
from functools import wraps
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
try:
|
||||||
|
import configparser
|
||||||
|
except ImportError:
|
||||||
|
import ConfigParser as configparser
|
||||||
|
|
||||||
|
#: Boolean to control activation of the :func:`debug` decorator.
|
||||||
|
DEBUG = False
|
||||||
|
|
||||||
|
#: Current version of the package as :class:`str`.
|
||||||
|
VERSION = "0.9.0"
|
||||||
|
|
||||||
|
#: Module configuration as :class:`dict`.
|
||||||
|
CONFIG = {
|
||||||
|
"core": {
|
||||||
|
"postfix_spool": "/var/spool/postfix"
|
||||||
|
},
|
||||||
|
"commands": {
|
||||||
|
"use_sudo": False,
|
||||||
|
"list_queue": ["mailq"],
|
||||||
|
"cat_message": ["postcat", "-qv"],
|
||||||
|
"hold_message": ["postsuper", "-h"],
|
||||||
|
"release_message": ["postsuper", "-H"],
|
||||||
|
"requeue_message": ["postsuper", "-r"],
|
||||||
|
"delete_message": ["postsuper", "-d"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def debug(function):
|
||||||
|
"""
|
||||||
|
Decorator to print some call informations and timing debug on stderr.
|
||||||
|
|
||||||
|
Function's name, passed args and kwargs are printed to stderr. Elapsed time
|
||||||
|
is also print at the end of call. This decorator is based on the value of
|
||||||
|
:data:`DEBUG`. If ``True``, the debug informations will be displayed.
|
||||||
|
"""
|
||||||
|
@wraps(function)
|
||||||
|
def run(*args, **kwargs):
|
||||||
|
name = function.__name__
|
||||||
|
if DEBUG is True:
|
||||||
|
sys.stderr.write("[DEBUG] Running {0}\n".format(name))
|
||||||
|
sys.stderr.write("[DEBUG] args: {0}\n".format(args))
|
||||||
|
sys.stderr.write("[DEBUG] kwargs: {0}\n".format(kwargs))
|
||||||
|
start = datetime.now()
|
||||||
|
|
||||||
|
ret = function(*args, **kwargs)
|
||||||
|
|
||||||
|
if DEBUG is True:
|
||||||
|
stop = datetime.now()
|
||||||
|
sys.stderr.write("[DEBUG] Exectime of {0}: {1} seconds\n".format(
|
||||||
|
name, (stop - start).total_seconds()))
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(cfg_file):
|
||||||
|
"""
|
||||||
|
Load module configuration from .ini file
|
||||||
|
|
||||||
|
Information from this file are directly used to override values stored in
|
||||||
|
:attr:`pymailq.CONFIG`.
|
||||||
|
|
||||||
|
Commands from configuration file are treated using :func:`shlex.split` to
|
||||||
|
properly transform command string to list of arguments.
|
||||||
|
|
||||||
|
:param str cfg_file: Configuration file
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
:ref:`pymailq-configuration`
|
||||||
|
"""
|
||||||
|
global CONFIG
|
||||||
|
|
||||||
|
cfg = configparser.ConfigParser()
|
||||||
|
cfg.read(cfg_file)
|
||||||
|
|
||||||
|
if "core" in cfg.sections():
|
||||||
|
if cfg.has_option("core", "postfix_spool"):
|
||||||
|
CONFIG["core"]["postfix_spool"] = cfg.get("core", "postfix_spool")
|
||||||
|
|
||||||
|
if "commands" in cfg.sections():
|
||||||
|
for key in cfg.options("commands"):
|
||||||
|
if key == "use_sudo":
|
||||||
|
if cfg.get("commands", key) == "yes":
|
||||||
|
CONFIG["commands"]["use_sudo"] = True
|
||||||
|
else:
|
||||||
|
command = shlex.split(cfg.get("commands", key))
|
||||||
|
CONFIG["commands"][key] = command
|
172
control.py
Normal file
172
control.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
#
|
||||||
|
# Postfix queue control python tool (pymailq)
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014 Denis Pompilio (jawa) <denis.pompilio@gmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of pymailq
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
from pymailq import CONFIG, debug
|
||||||
|
|
||||||
|
|
||||||
|
class QueueControl(object):
|
||||||
|
"""
|
||||||
|
Postfix queue control using postsuper command.
|
||||||
|
|
||||||
|
The :class:`~control.QueueControl` instance defines the following
|
||||||
|
attributes:
|
||||||
|
|
||||||
|
.. attribute:: use_sudo
|
||||||
|
|
||||||
|
Boolean to control the use of `sudo` to invoke Postfix command.
|
||||||
|
Default is ``False``
|
||||||
|
|
||||||
|
.. attribute:: postsuper_cmd
|
||||||
|
|
||||||
|
Postfix command and arguments :func:`list` for mails queue
|
||||||
|
administrative operations. Default is ``["postsuper"]``
|
||||||
|
|
||||||
|
.. attribute:: known_operations
|
||||||
|
|
||||||
|
Known Postfix administrative operations :class:`dict` to associate
|
||||||
|
operations to command arguments. Known associations are::
|
||||||
|
|
||||||
|
delete: -d
|
||||||
|
hold: -h
|
||||||
|
release: -H
|
||||||
|
requeue: -r
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
Default known associations are provided for the default mails
|
||||||
|
queue administrative command `postsuper`_.
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
Postfix manual:
|
||||||
|
`postsuper`_ -- Postfix superintendent
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_operation_cmd(operation):
|
||||||
|
"""Get operation related command from configuration
|
||||||
|
|
||||||
|
This method use Postfix administrative commands defined
|
||||||
|
in :attr:`pymailq.CONFIG` attribute under the key 'list_queue'.
|
||||||
|
Command and arguments list is build on call with the configuration data.
|
||||||
|
|
||||||
|
Command keys are built with the ``operation`` argument suffixed with
|
||||||
|
``_message``. Example: ``hold_message`` for the hold command.
|
||||||
|
|
||||||
|
:param str operation: Operation name
|
||||||
|
:return: Command and arguments as :class:`list`
|
||||||
|
|
||||||
|
:raise KeyError: Operation is unknown
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
:ref:`pymailq-configuration`
|
||||||
|
"""
|
||||||
|
cmd = CONFIG['commands'][operation + '_message']
|
||||||
|
if CONFIG['commands']['use_sudo']:
|
||||||
|
cmd.insert(0, 'sudo')
|
||||||
|
return cmd
|
||||||
|
|
||||||
|
@debug
|
||||||
|
def _operate(self, operation, messages):
|
||||||
|
"""
|
||||||
|
Generic method to lead operations messages from postfix mail queue.
|
||||||
|
|
||||||
|
Operations can be one of Postfix known operations stored in
|
||||||
|
PyMailq module configuration.
|
||||||
|
|
||||||
|
:param str operation: Known operation from :attr:`pymailq.CONFIG`.
|
||||||
|
:param list messages: List of :class:`~store.Mail` objects targetted
|
||||||
|
for operation.
|
||||||
|
:return: Command's *stderr* output lines
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
# validate that object's attribute "qid" exist. Raise AttributeError.
|
||||||
|
for msg in messages:
|
||||||
|
getattr(msg, "qid")
|
||||||
|
|
||||||
|
# We may modify this part to improve security.
|
||||||
|
# It should not be possible to inject commands, but who knows...
|
||||||
|
# https://www.kevinlondon.com/2015/07/26/dangerous-python-functions.html
|
||||||
|
# And consider the use of sh module: https://amoffat.github.io/sh/
|
||||||
|
operation_cmd = self.get_operation_cmd(operation) + ['-']
|
||||||
|
try:
|
||||||
|
child = subprocess.Popen(operation_cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE)
|
||||||
|
except EnvironmentError as exc:
|
||||||
|
command_str = " ".join(operation_cmd)
|
||||||
|
error_msg = "Unable to call '%s': %s" % (command_str, str(exc))
|
||||||
|
raise RuntimeError(error_msg)
|
||||||
|
|
||||||
|
# If permissions error, the postsuper process takes ~1s to teardown.
|
||||||
|
# Wait this delay and raise error message if process has stopped.
|
||||||
|
time.sleep(1.1)
|
||||||
|
child.poll()
|
||||||
|
if child.returncode:
|
||||||
|
raise RuntimeError(child.communicate()[1].strip().decode())
|
||||||
|
|
||||||
|
try:
|
||||||
|
for msg in messages:
|
||||||
|
child.stdin.write((msg.qid+'\n').encode())
|
||||||
|
stderr = child.communicate()[1].strip()
|
||||||
|
except BrokenPipeError:
|
||||||
|
raise RuntimeError("Unexpected error: child process has crashed")
|
||||||
|
|
||||||
|
return [line.strip() for line in stderr.decode().split('\n')]
|
||||||
|
|
||||||
|
def delete_messages(self, messages):
|
||||||
|
"""
|
||||||
|
Delete several messages from postfix mail queue.
|
||||||
|
|
||||||
|
This method is a :func:`~functools.partial` wrapper on
|
||||||
|
:meth:`~control.QueueControl._operate`. Passed operation is ``delete``
|
||||||
|
"""
|
||||||
|
return self._operate('delete', messages)
|
||||||
|
|
||||||
|
def hold_messages(self, messages):
|
||||||
|
"""
|
||||||
|
Hold several messages from postfix mail queue.
|
||||||
|
|
||||||
|
This method is a :func:`~functools.partial` wrapper on
|
||||||
|
:meth:`~control.QueueControl._operate`. Passed operation is ``hold``
|
||||||
|
"""
|
||||||
|
return self._operate('hold', messages)
|
||||||
|
|
||||||
|
def release_messages(self, messages):
|
||||||
|
"""
|
||||||
|
Release several messages from postfix mail queue.
|
||||||
|
|
||||||
|
This method is a :func:`~functools.partial` wrapper on
|
||||||
|
:meth:`~control.QueueControl._operate`. Passed operation is ``release``
|
||||||
|
"""
|
||||||
|
return self._operate('release', messages)
|
||||||
|
|
||||||
|
def requeue_messages(self, messages):
|
||||||
|
"""
|
||||||
|
Requeue several messages from postfix mail queue.
|
||||||
|
|
||||||
|
This method is a :func:`~functools.partial` wrapper on
|
||||||
|
:meth:`~control.QueueControl._operate`. Passed operation is ``requeue``
|
||||||
|
"""
|
||||||
|
return self._operate('requeue', messages)
|
315
selector.py
Normal file
315
selector.py
Normal file
@ -0,0 +1,315 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
#
|
||||||
|
# Postfix queue control python tool (pymailq)
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014 Denis Pompilio (jawa) <denis.pompilio@gmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of pymailq
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import gc
|
||||||
|
from functools import wraps
|
||||||
|
from datetime import datetime
|
||||||
|
from pymailq import debug
|
||||||
|
|
||||||
|
|
||||||
|
class MailSelector(object):
|
||||||
|
"""
|
||||||
|
Mail selector class to request mails from store matching criterias.
|
||||||
|
|
||||||
|
The :class:`~selector.MailSelector` instance provides the following
|
||||||
|
attributes:
|
||||||
|
|
||||||
|
.. attribute:: mails
|
||||||
|
|
||||||
|
Currently selected :class:`~store.Mail` objects :func:`list`
|
||||||
|
|
||||||
|
.. attribute:: store
|
||||||
|
|
||||||
|
Linked :class:`~store.PostqueueStore` at the
|
||||||
|
:class:`~selector.MailSelector` instance initialization.
|
||||||
|
|
||||||
|
.. attribute:: filters
|
||||||
|
|
||||||
|
Applied filters :func:`list` on current selection. Filters list
|
||||||
|
entries are tuples containing ``(function.__name__, args, kwargs)``
|
||||||
|
for each applied filters. This list is filled by the
|
||||||
|
:meth:`~selector.MailSelector.filter_registration` decorator while
|
||||||
|
calling filtering methods. It is possible to replay registered
|
||||||
|
filter using :meth:`~selector.MailSelector.replay_filters` method.
|
||||||
|
"""
|
||||||
|
def __init__(self, store):
|
||||||
|
"""Init method"""
|
||||||
|
self.mails = []
|
||||||
|
self.store = store
|
||||||
|
self.filters = []
|
||||||
|
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def filter_registration(function):
|
||||||
|
"""
|
||||||
|
Decorator to register applied filter.
|
||||||
|
|
||||||
|
This decorated is used to wrap selection methods ``lookup_*``. It
|
||||||
|
registers a ``(function.__name__, args, kwargs)`` :func:`tuple` in
|
||||||
|
the :attr:`~MailSelector.filters` attribute.
|
||||||
|
"""
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
filterinfo = (function.__name__, args, kwargs)
|
||||||
|
self.filters.append(filterinfo)
|
||||||
|
return function(self, *args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
"""
|
||||||
|
Reset mail selector with initial store mails list.
|
||||||
|
|
||||||
|
Selected :class:`~store.Mail` objects are deleted and the
|
||||||
|
:attr:`~MailSelector.mails` attribute is removed for memory releasing
|
||||||
|
purpose (with help of :func:`gc.collect`). Attribute
|
||||||
|
:attr:`~MailSelector.mails` is then reinitialized a copy of
|
||||||
|
:attr:`~MailSelector.store`'s :attr:`~PostqueueStore.mails` attribute.
|
||||||
|
|
||||||
|
Registered :attr:`~MailSelector.filters` are also emptied.
|
||||||
|
"""
|
||||||
|
del self.mails
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
self.mails = [mail for mail in self.store.mails]
|
||||||
|
self.filters = []
|
||||||
|
|
||||||
|
def replay_filters(self):
|
||||||
|
"""
|
||||||
|
Reset selection with store content and replay registered filters.
|
||||||
|
|
||||||
|
Like with the :meth:`~selector.MailSelector.reset` method, selected
|
||||||
|
:class:`~store.Mail` objects are deleted and reinitialized with a copy
|
||||||
|
of :attr:`~MailSelector.store`'s :attr:`~PostqueueStore.mails`
|
||||||
|
attribute.
|
||||||
|
|
||||||
|
However, registered :attr:`~MailSelector.filters` are kept and replayed
|
||||||
|
on resetted selection. Use this method to refresh your store content
|
||||||
|
while keeping your filters.
|
||||||
|
"""
|
||||||
|
del self.mails
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
self.mails = [mail for mail in self.store.mails]
|
||||||
|
filters = [entry for entry in self.filters]
|
||||||
|
for filterinfo in filters:
|
||||||
|
name, args, kwargs = filterinfo
|
||||||
|
getattr(self, name)(*args, **kwargs)
|
||||||
|
self.filters = filters
|
||||||
|
|
||||||
|
def get_mails_by_qids(self, qids):
|
||||||
|
"""
|
||||||
|
Get mails with specified IDs.
|
||||||
|
|
||||||
|
This function is not registered as filter.
|
||||||
|
|
||||||
|
:param list qids: List of mail IDs.
|
||||||
|
:return: List of newly selected :class:`~store.Mail` objects
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
return [mail for mail in self.mails
|
||||||
|
if mail.qid in qids]
|
||||||
|
|
||||||
|
@debug
|
||||||
|
@filter_registration
|
||||||
|
def lookup_qids(self, qids):
|
||||||
|
"""
|
||||||
|
Lookup mails with specified IDs.
|
||||||
|
|
||||||
|
:param list qids: List of mail IDs.
|
||||||
|
:return: List of newly selected :class:`~store.Mail` objects
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
self.mails = self.get_mails_by_qids(qids)
|
||||||
|
return self.mails
|
||||||
|
|
||||||
|
@debug
|
||||||
|
@filter_registration
|
||||||
|
def lookup_header(self, header, value, exact=True):
|
||||||
|
"""
|
||||||
|
Lookup mail headers with specified value.
|
||||||
|
|
||||||
|
:param str header: Header name to filter on.
|
||||||
|
:param str value: Header value to filter on.
|
||||||
|
:param bool exact: Allow lookup with partial or exact match
|
||||||
|
|
||||||
|
:return: List of newly selected :class:`~store.Mail` objects
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
matches = []
|
||||||
|
for mail in self.mails:
|
||||||
|
header_value = getattr(mail.head, header, None)
|
||||||
|
if not header_value:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not isinstance(header_value, list):
|
||||||
|
header_value = [header_value]
|
||||||
|
|
||||||
|
if exact and value in header_value:
|
||||||
|
matches.append(mail)
|
||||||
|
elif not exact:
|
||||||
|
for entry in header_value:
|
||||||
|
if value in entry:
|
||||||
|
matches.append(mail)
|
||||||
|
break
|
||||||
|
|
||||||
|
self.mails = matches
|
||||||
|
return self.mails
|
||||||
|
|
||||||
|
@debug
|
||||||
|
@filter_registration
|
||||||
|
def lookup_status(self, status):
|
||||||
|
"""
|
||||||
|
Lookup mails with specified postqueue status.
|
||||||
|
|
||||||
|
:param list status: List of matching status to filter on.
|
||||||
|
:return: List of newly selected :class:`~store.Mail` objects
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
self.mails = [mail for mail in self.mails
|
||||||
|
if mail.status in status]
|
||||||
|
|
||||||
|
return self.mails
|
||||||
|
|
||||||
|
@debug
|
||||||
|
@filter_registration
|
||||||
|
def lookup_sender(self, sender, exact=True):
|
||||||
|
"""
|
||||||
|
Lookup mails send from a specific sender.
|
||||||
|
|
||||||
|
Optionnal parameter ``partial`` allow lookup of partial sender like
|
||||||
|
``@domain.com`` or ``sender@``. By default, ``partial`` is ``False``
|
||||||
|
and selection is made on exact sender.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Matches are made against :attr:`Mail.sender` attribute instead of
|
||||||
|
real mail header :mailheader:`Sender`.
|
||||||
|
|
||||||
|
:param str sender: Sender address to lookup in :class:`~store.Mail`
|
||||||
|
objects selection.
|
||||||
|
:param bool exact: Allow lookup with partial or exact match
|
||||||
|
:return: List of newly selected :class:`~store.Mail` objects
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
if exact is False:
|
||||||
|
self.mails = [mail for mail in self.mails
|
||||||
|
if sender in mail.sender]
|
||||||
|
else:
|
||||||
|
self.mails = [mail for mail in self.mails
|
||||||
|
if sender == mail.sender]
|
||||||
|
|
||||||
|
return self.mails
|
||||||
|
|
||||||
|
@debug
|
||||||
|
@filter_registration
|
||||||
|
def lookup_recipient(self, recipient, exact=True):
|
||||||
|
"""
|
||||||
|
Lookup mails send to a specific recipient.
|
||||||
|
|
||||||
|
Optionnal parameter ``partial`` allow lookup of partial sender like
|
||||||
|
``@domain.com`` or ``sender@``. By default, ``partial`` is ``False``
|
||||||
|
and selection is made on exact sender.
|
||||||
|
|
||||||
|
.. note::
|
||||||
|
|
||||||
|
Matches are made against :attr:`Mail.recipients` attribute instead
|
||||||
|
of real mail header :mailheader:`To`.
|
||||||
|
|
||||||
|
:param str recipient: Recipient address to lookup in
|
||||||
|
:class:`~store.Mail` objects selection.
|
||||||
|
:param bool exact: Allow lookup with partial or exact match
|
||||||
|
:return: List of newly selected :class:`~store.Mail` objects
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
if exact is False:
|
||||||
|
selected = []
|
||||||
|
for mail in self.mails:
|
||||||
|
for value in mail.recipients:
|
||||||
|
if recipient in value:
|
||||||
|
selected += [mail]
|
||||||
|
self.mails = selected
|
||||||
|
else:
|
||||||
|
self.mails = [mail for mail in self.mails
|
||||||
|
if recipient in mail.recipients]
|
||||||
|
|
||||||
|
return self.mails
|
||||||
|
|
||||||
|
@debug
|
||||||
|
@filter_registration
|
||||||
|
def lookup_error(self, error_msg):
|
||||||
|
"""
|
||||||
|
Lookup mails with specific error message (message may be partial).
|
||||||
|
|
||||||
|
:param str error_msg: Error message to filter on
|
||||||
|
:return: List of newly selected :class:`~store.Mail` objects`
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
self.mails = [mail for mail in self.mails
|
||||||
|
if True in [True for err in mail.errors
|
||||||
|
if error_msg in err]]
|
||||||
|
return self.mails
|
||||||
|
|
||||||
|
@debug
|
||||||
|
@filter_registration
|
||||||
|
def lookup_date(self, start=None, stop=None):
|
||||||
|
"""
|
||||||
|
Lookup mails send on specific date range(s).
|
||||||
|
|
||||||
|
:param datetime.date start: Start date (Default: None)
|
||||||
|
:param datetime.date stop: Stop date (Default: None)
|
||||||
|
|
||||||
|
:return: List of newly selected :class:`~store.Mail` objects
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
if start is None:
|
||||||
|
start = datetime(1970, 1, 1)
|
||||||
|
if stop is None:
|
||||||
|
stop = datetime.now()
|
||||||
|
|
||||||
|
self.mails = [mail for mail in self.mails
|
||||||
|
if start <= mail.date <= stop]
|
||||||
|
|
||||||
|
return self.mails
|
||||||
|
|
||||||
|
@debug
|
||||||
|
@filter_registration
|
||||||
|
def lookup_size(self, smin=0, smax=0): # TODO: documentation
|
||||||
|
"""
|
||||||
|
Lookup mails send with specific size.
|
||||||
|
|
||||||
|
Both arguments ``smin`` and ``smax`` are optionnal and default is set
|
||||||
|
to ``0``. Maximum size is ignored if setted to ``0``. If both ``smin``
|
||||||
|
and ``smax`` are setted to ``0``, no filtering is done and the entire
|
||||||
|
:class:`~store.Mail` objects selection is returned.
|
||||||
|
|
||||||
|
:param int smin: Minimum size (Default: ``0``)
|
||||||
|
:param int smax: Maximum size (Default: ``0``)
|
||||||
|
:return: List of newly selected :class:`~store.Mail` objects
|
||||||
|
:rtype: :func:`list`
|
||||||
|
"""
|
||||||
|
if smin == 0 and smax == 0:
|
||||||
|
return self.mails
|
||||||
|
|
||||||
|
if smax > 0:
|
||||||
|
self.mails = [mail for mail in self.mails if mail.size <= smax]
|
||||||
|
self.mails = [mail for mail in self.mails if mail.size >= smin]
|
||||||
|
|
||||||
|
return self.mails
|
539
shell.py
Normal file
539
shell.py
Normal file
@ -0,0 +1,539 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
#
|
||||||
|
# Postfix queue control python tool (pymailq)
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014 Denis Pompilio (jawa) <denis.pompilio@gmail.com>
|
||||||
|
# Copyright (C) 2014 Jocelyn Delalande <jdelalande@oasiswork.fr>
|
||||||
|
#
|
||||||
|
# This file is part of pymailq
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import cmd
|
||||||
|
from functools import partial
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from subprocess import CalledProcessError
|
||||||
|
import shlex
|
||||||
|
import inspect
|
||||||
|
from pymailq import store, control, selector, utils
|
||||||
|
|
||||||
|
|
||||||
|
class PyMailqShell(cmd.Cmd):
|
||||||
|
"""PyMailq shell for interactive mode"""
|
||||||
|
|
||||||
|
# Automatic building of supported methods and documentation
|
||||||
|
commands_info = {
|
||||||
|
'store': 'Control of Postfix queue content storage',
|
||||||
|
'select': 'Select mails from Postfix queue content',
|
||||||
|
'inspect': 'Mail content inspector',
|
||||||
|
'super': 'Call postsuper commands'
|
||||||
|
}
|
||||||
|
|
||||||
|
# XXX: do_* methods are parsed before init and must be declared here
|
||||||
|
do_inspect = None
|
||||||
|
do_store = None
|
||||||
|
do_select = None
|
||||||
|
do_super = None
|
||||||
|
|
||||||
|
def __init__(self, completekey='tab', stdin=None, stdout=None,
|
||||||
|
store_auto_load=False):
|
||||||
|
"""Init method"""
|
||||||
|
cmd.Cmd.__init__(self, completekey, stdin, stdout)
|
||||||
|
|
||||||
|
# EOF action is registered here to hide it from user
|
||||||
|
self.do_EOF = self.do_exit
|
||||||
|
|
||||||
|
for command in self.commands_info:
|
||||||
|
setattr(self, "help_%s" % (command,), partial(self._help_, command))
|
||||||
|
setattr(self, "do_%s" % (command,), partial(self.__do, command))
|
||||||
|
|
||||||
|
# show command is specific and cannot be build dynamically
|
||||||
|
setattr(self, "help_show", partial(self._help_, "show"))
|
||||||
|
|
||||||
|
self.pstore = store.PostqueueStore()
|
||||||
|
self.selector = selector.MailSelector(self.pstore)
|
||||||
|
self.qcontrol = control.QueueControl()
|
||||||
|
|
||||||
|
if store_auto_load:
|
||||||
|
self.respond("Loading mails queue content to store")
|
||||||
|
self._store_load()
|
||||||
|
|
||||||
|
def respond(self, answer):
|
||||||
|
"""Send response"""
|
||||||
|
if not isinstance(answer, str):
|
||||||
|
answer = answer.encode('utf-8')
|
||||||
|
self.stdout.write('%s\n' % answer)
|
||||||
|
|
||||||
|
# Internal functions
|
||||||
|
def emptyline(self):
|
||||||
|
"""Action on empty lines"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
def help_help(self):
|
||||||
|
"""Help of command help"""
|
||||||
|
self.respond("Show available commands")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def do_exit(arg):
|
||||||
|
"""Action on exit"""
|
||||||
|
return True
|
||||||
|
|
||||||
|
def help_exit(self):
|
||||||
|
"""Help of command exit"""
|
||||||
|
self.respond("Exit PyMailq shell (or use Ctrl-D)")
|
||||||
|
|
||||||
|
def cmdloop_nointerrupt(self):
|
||||||
|
"""Specific cmdloop to handle KeyboardInterrupt"""
|
||||||
|
can_exit = False
|
||||||
|
# intro message is not in self.intro not to display it each time
|
||||||
|
# cmdloop is restarted
|
||||||
|
self.respond("Welcome to PyMailq shell.")
|
||||||
|
while can_exit is not True:
|
||||||
|
try:
|
||||||
|
self.cmdloop()
|
||||||
|
can_exit = True
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.respond("^C")
|
||||||
|
|
||||||
|
def postloop(self):
|
||||||
|
cmd.Cmd.postloop(self)
|
||||||
|
self.respond("\nExiting shell... Bye.")
|
||||||
|
|
||||||
|
def _help_(self, command):
|
||||||
|
docstr = self.commands_info.get(
|
||||||
|
command, getattr(self, "do_%s" % (command,)).__doc__)
|
||||||
|
self.respond(inspect.cleandoc(docstr))
|
||||||
|
|
||||||
|
self.respond("Subcommands:")
|
||||||
|
for method in dir(self):
|
||||||
|
if method.startswith("_%s_" % (command,)):
|
||||||
|
docstr = getattr(self, method).__doc__
|
||||||
|
doclines = inspect.cleandoc(docstr).split('\n')
|
||||||
|
self.respond(" %-10s %s" % (method[len(command)+2:],
|
||||||
|
doclines.pop(0)))
|
||||||
|
for line in doclines:
|
||||||
|
self.respond(" %-10s %s" % ("", line))
|
||||||
|
|
||||||
|
#
|
||||||
|
# PyMailq methods
|
||||||
|
#
|
||||||
|
|
||||||
|
@property
|
||||||
|
def prompt(self):
|
||||||
|
"""Dynamic prompt with usefull informations"""
|
||||||
|
|
||||||
|
prompt = ['PyMailq']
|
||||||
|
if self.selector is not None:
|
||||||
|
prompt.append(' (sel:%d)' % (len(self.selector.mails)))
|
||||||
|
prompt.append('> ')
|
||||||
|
|
||||||
|
return "".join(prompt)
|
||||||
|
|
||||||
|
def __do(self, cmd_category, str_arg):
|
||||||
|
"""Generic do_* method to call cmd categories"""
|
||||||
|
|
||||||
|
args = shlex.split(str_arg)
|
||||||
|
if not len(args):
|
||||||
|
getattr(self, "help_%s" % (cmd_category,))()
|
||||||
|
return None
|
||||||
|
|
||||||
|
command = args.pop(0)
|
||||||
|
method = "_%s_%s" % (cmd_category, command)
|
||||||
|
try:
|
||||||
|
lines = getattr(self, method)(*args)
|
||||||
|
if lines is not None and len(lines):
|
||||||
|
self.respond('\n'.join(lines))
|
||||||
|
except AttributeError:
|
||||||
|
self.respond("%s has no subcommand: %s" % (cmd_category, command))
|
||||||
|
except (SyntaxError, TypeError) as exc:
|
||||||
|
# Rewording Python TypeError message for cli display
|
||||||
|
msg = str(exc)
|
||||||
|
if "%s()" % (method,) in msg:
|
||||||
|
msg = "%s command %s" % (cmd_category, msg[len(method)+3:])
|
||||||
|
self.respond("*** Syntax error: " + msg)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_modifiers(match, excludes=()):
|
||||||
|
"""Get modifiers from match
|
||||||
|
|
||||||
|
:param str match: String to match in modifiers
|
||||||
|
:param list excludes: Excluded modifiers
|
||||||
|
:return: Matched modifiers as :func:`list`
|
||||||
|
"""
|
||||||
|
modifiers = {
|
||||||
|
'limit': ['<n>'],
|
||||||
|
'rankby': ['<field>'],
|
||||||
|
'sortby': ['<field> [asc|desc]']
|
||||||
|
}
|
||||||
|
if match in modifiers and match not in excludes:
|
||||||
|
return modifiers[match]
|
||||||
|
return [mod for mod in modifiers
|
||||||
|
if mod not in excludes and mod.startswith(match)]
|
||||||
|
|
||||||
|
def completenames(self, text, *ignored):
|
||||||
|
"""Complete known commands"""
|
||||||
|
dotext = 'do_'+text
|
||||||
|
suggests = [a[3:] for a in self.get_names() if a.startswith(dotext)]
|
||||||
|
if len(suggests) == 1:
|
||||||
|
# Only one suggest, return it with a space
|
||||||
|
suggests[0] += " "
|
||||||
|
return suggests
|
||||||
|
|
||||||
|
def completedefault(self, text, line, *ignored):
|
||||||
|
"""Generic command completion method"""
|
||||||
|
# we may consider the use of re.match for params in completion
|
||||||
|
completion = {
|
||||||
|
'show': {
|
||||||
|
'__allow_mods__': True,
|
||||||
|
},
|
||||||
|
'inspect': {
|
||||||
|
'mails': ['<qid>[,<qid>,...]']
|
||||||
|
},
|
||||||
|
'select': {
|
||||||
|
'date': ['<datespec>'],
|
||||||
|
'error': ['<error_msg>'],
|
||||||
|
'rmfilter': ['<filterid>'],
|
||||||
|
'sender': ['<sender> [exact]'],
|
||||||
|
'recipient': ['<recipient> [exact]'],
|
||||||
|
'size': ['<-n|n|+n> [-n]'],
|
||||||
|
'status': ['<status>']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args = shlex.split(line)
|
||||||
|
command = args.pop(0)
|
||||||
|
sub_command = ""
|
||||||
|
if len(args):
|
||||||
|
sub_command = args.pop(0)
|
||||||
|
|
||||||
|
match = "_%s_" % (command,)
|
||||||
|
suggests = [name[len(match):] for name in dir(self)
|
||||||
|
if name.startswith(match + sub_command)]
|
||||||
|
|
||||||
|
# No suggests, return None
|
||||||
|
if not len(suggests):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Return multiple suggests for sub-command
|
||||||
|
if len(suggests) > 1:
|
||||||
|
return suggests
|
||||||
|
suggest = suggests.pop(0)
|
||||||
|
|
||||||
|
exact_match = True if suggest == sub_command else False
|
||||||
|
|
||||||
|
if suggest in completion.get(command, {}):
|
||||||
|
if not exact_match:
|
||||||
|
# Sub-command takes params, suffix it with a space
|
||||||
|
return [suggest + " "]
|
||||||
|
elif not len(args):
|
||||||
|
# Return sub-command params
|
||||||
|
return completion[command][sub_command]
|
||||||
|
elif not exact_match:
|
||||||
|
# Sub-command doesn't take params, return as is
|
||||||
|
return [suggest]
|
||||||
|
|
||||||
|
# Command allows modifiers
|
||||||
|
if completion[command].get('__allow_mods__'):
|
||||||
|
if len(args) or not len(text):
|
||||||
|
match = args[-1] if len(args) else ""
|
||||||
|
mods = self.get_modifiers(match, excludes=args[:-1])
|
||||||
|
if not len(mods):
|
||||||
|
mods = self.get_modifiers("", excludes=args)
|
||||||
|
if len(mods):
|
||||||
|
mods[0] += " " if len(mods) == 1 else ""
|
||||||
|
suggests = mods
|
||||||
|
|
||||||
|
if not len(suggests):
|
||||||
|
return None
|
||||||
|
return suggests
|
||||||
|
|
||||||
|
def _store_load(self, filename=None):
|
||||||
|
"""Load Postfix queue content"""
|
||||||
|
try:
|
||||||
|
self.pstore.load(filename=filename)
|
||||||
|
# Automatic load of selector if it is empty and never used.
|
||||||
|
if not len(self.selector.mails) and not len(self.selector.filters):
|
||||||
|
self.selector.reset()
|
||||||
|
return ["%d mails loaded from queue" % (len(self.pstore.mails))]
|
||||||
|
except (OSError, IOError, CalledProcessError) as exc:
|
||||||
|
return ["*** Error: unable to load store", " %s" % (exc,)]
|
||||||
|
|
||||||
|
def _store_status(self):
|
||||||
|
"""Show store status"""
|
||||||
|
if self.pstore is None or self.pstore.loaded_at is None:
|
||||||
|
return ["store is not loaded"]
|
||||||
|
return ["store loaded with %d mails at %s" % (
|
||||||
|
len(self.pstore.mails), self.pstore.loaded_at)]
|
||||||
|
|
||||||
|
def _select_reset(self):
|
||||||
|
"""Reset content of selector with store content"""
|
||||||
|
self.selector.reset()
|
||||||
|
return ["Selector resetted with store content (%s mails)" % (
|
||||||
|
len(self.selector.mails))]
|
||||||
|
|
||||||
|
def _select_replay(self):
|
||||||
|
"""Reset content of selector with store content and replay filters"""
|
||||||
|
self.selector.replay_filters()
|
||||||
|
return ["Selector resetted and filters replayed"]
|
||||||
|
|
||||||
|
def _select_rmfilter(self, filterid):
|
||||||
|
"""
|
||||||
|
Remove filter previously applied
|
||||||
|
Filters ids are used to specify filter to remove
|
||||||
|
Usage: select rmfilter <filterid>
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
idx = int(filterid)
|
||||||
|
self.selector.filters.pop(idx)
|
||||||
|
self.selector.replay_filters()
|
||||||
|
# TODO: except should be more accurate
|
||||||
|
except:
|
||||||
|
raise SyntaxError("invalid filter ID: %s" % filterid)
|
||||||
|
|
||||||
|
def _select_qids(self, *qids):
|
||||||
|
"""
|
||||||
|
Select mails by ID
|
||||||
|
Usage: select qids <qid>[,<qid>,...]
|
||||||
|
"""
|
||||||
|
self.selector.lookup_qids(qids)
|
||||||
|
|
||||||
|
def _select_status(self, status):
|
||||||
|
"""
|
||||||
|
Select mails with specific postfix status
|
||||||
|
Usage: select status <status>
|
||||||
|
"""
|
||||||
|
self.selector.lookup_status(status=status)
|
||||||
|
|
||||||
|
def _select_sender(self, sender, exact=False):
|
||||||
|
"""
|
||||||
|
Select mails from sender
|
||||||
|
Usage: select sender <sender> [exact]
|
||||||
|
"""
|
||||||
|
if exact is not False: # received from command line
|
||||||
|
if exact != "exact":
|
||||||
|
raise SyntaxError("invalid keyword: %s" % exact)
|
||||||
|
exact = True
|
||||||
|
self.selector.lookup_sender(sender=sender, exact=exact)
|
||||||
|
|
||||||
|
def _select_recipient(self, recipient, exact=False):
|
||||||
|
"""
|
||||||
|
Select mails to recipient
|
||||||
|
Usage: select recipient <recipient> [exact]
|
||||||
|
"""
|
||||||
|
if exact is not False: # received from command line
|
||||||
|
if exact != "exact":
|
||||||
|
raise SyntaxError("invalid keyword: %s" % exact)
|
||||||
|
exact = True
|
||||||
|
self.selector.lookup_recipient(recipient=recipient, exact=exact)
|
||||||
|
|
||||||
|
def _select_size(self, size_a, size_b=None):
|
||||||
|
"""
|
||||||
|
Select mails by size in Bytes
|
||||||
|
- and + are supported, if not specified, search for exact size
|
||||||
|
Size range is allowed by using - (lesser than) and + (greater than)
|
||||||
|
Usage: select size <-n|n|+n> [-n]
|
||||||
|
"""
|
||||||
|
smin = None
|
||||||
|
smax = None
|
||||||
|
exact = None
|
||||||
|
try:
|
||||||
|
for size in size_a, size_b:
|
||||||
|
if size is None:
|
||||||
|
continue
|
||||||
|
if exact is not None:
|
||||||
|
raise SyntaxError("exact size must be used alone")
|
||||||
|
if size.startswith("-"):
|
||||||
|
if smax is not None:
|
||||||
|
raise SyntaxError("multiple max sizes specified")
|
||||||
|
smax = int(size[1:])
|
||||||
|
elif size.startswith("+"):
|
||||||
|
if smin is not None:
|
||||||
|
raise SyntaxError("multiple min sizes specified")
|
||||||
|
smin = int(size[1:])
|
||||||
|
else:
|
||||||
|
exact = int(size)
|
||||||
|
except ValueError:
|
||||||
|
raise SyntaxError("specified sizes must be valid numbers")
|
||||||
|
|
||||||
|
if exact is not None:
|
||||||
|
smin = exact
|
||||||
|
smax = exact
|
||||||
|
if smax is None:
|
||||||
|
smax = 0
|
||||||
|
if smin is None:
|
||||||
|
smin = 0
|
||||||
|
|
||||||
|
if smin > smax > 0:
|
||||||
|
raise SyntaxError("minimum size is greater than maximum size")
|
||||||
|
|
||||||
|
self.selector.lookup_size(smin=smin, smax=smax)
|
||||||
|
|
||||||
|
def _select_date(self, date_spec):
|
||||||
|
"""
|
||||||
|
Select mails by date.
|
||||||
|
Usage:
|
||||||
|
select date <DATESPEC>
|
||||||
|
Where <DATESPEC> can be
|
||||||
|
YYYY-MM-DD (exact date)
|
||||||
|
YYYY-MM-DD..YYYY-MM-DD (within a date range (included))
|
||||||
|
+YYYY-MM-DD (after a date (included))
|
||||||
|
-YYYY-MM-DD (before a date (included))
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if ".." in date_spec:
|
||||||
|
(str_start, str_stop) = date_spec.split("..", 1)
|
||||||
|
start = datetime.strptime(str_start, "%Y-%m-%d")
|
||||||
|
stop = datetime.strptime(str_stop, "%Y-%m-%d")
|
||||||
|
elif date_spec.startswith("+"):
|
||||||
|
start = datetime.strptime(date_spec[1:], "%Y-%m-%d")
|
||||||
|
stop = datetime.now()
|
||||||
|
elif date_spec.startswith("-"):
|
||||||
|
start = datetime(1970, 1, 1)
|
||||||
|
stop = datetime.strptime(date_spec[1:], "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
start = datetime.strptime(date_spec, "%Y-%m-%d")
|
||||||
|
stop = start + timedelta(1)
|
||||||
|
self.selector.lookup_date(start, stop)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise SyntaxError(str(exc))
|
||||||
|
|
||||||
|
def _select_error(self, error_msg):
|
||||||
|
"""
|
||||||
|
Select mails by error message
|
||||||
|
Specified error message can be partial
|
||||||
|
Usage: select error <error_msg>
|
||||||
|
"""
|
||||||
|
self.selector.lookup_error(str(error_msg))
|
||||||
|
|
||||||
|
def _inspect_mails(self, *qids):
|
||||||
|
"""
|
||||||
|
Show mails content
|
||||||
|
Usage: inspect mails <qid> [qid] ...
|
||||||
|
"""
|
||||||
|
mails = self.selector.get_mails_by_qids(qids)
|
||||||
|
if not len(mails):
|
||||||
|
return ['Mail IDs not found']
|
||||||
|
response = []
|
||||||
|
for mail in mails:
|
||||||
|
mail.parse()
|
||||||
|
if len(mail.parse_error):
|
||||||
|
return [mail.parse_error]
|
||||||
|
response.append(mail.show())
|
||||||
|
return response
|
||||||
|
|
||||||
|
def do_show(self, str_arg):
|
||||||
|
"""
|
||||||
|
Generic viewer utility
|
||||||
|
Optionnal modifiers can be provided to alter output:
|
||||||
|
limit <n> display the first n entries
|
||||||
|
sortby <field> [asc|desc] sort output by field asc or desc
|
||||||
|
rankby <field> Produce mails ranking by field
|
||||||
|
Known fields:
|
||||||
|
qid Postqueue mail ID
|
||||||
|
date Mail date
|
||||||
|
sender Mail sender
|
||||||
|
recipients Mail recipients (list, no sort)
|
||||||
|
size Mail size
|
||||||
|
errors Postqueue deferred error messages (list, no sort)
|
||||||
|
"""
|
||||||
|
args = shlex.split(str_arg)
|
||||||
|
if not len(args):
|
||||||
|
return self.help_show()
|
||||||
|
|
||||||
|
sub_cmd = args.pop(0)
|
||||||
|
try:
|
||||||
|
lines = getattr(self, "_show_%s" % sub_cmd)(*args)
|
||||||
|
except (TypeError, AttributeError):
|
||||||
|
self.respond("*** Syntax error: show {0}".format(str_arg))
|
||||||
|
return self.help_show()
|
||||||
|
except SyntaxError as error:
|
||||||
|
# Rewording Python TypeError message for cli display
|
||||||
|
msg = str(error)
|
||||||
|
if "%s()" % sub_cmd in msg:
|
||||||
|
msg = "show command %s" % msg
|
||||||
|
self.respond("*** Syntax error: " + msg)
|
||||||
|
return self.help_show()
|
||||||
|
|
||||||
|
self.respond("\n".join(lines))
|
||||||
|
|
||||||
|
@utils.viewer
|
||||||
|
@utils.ranker
|
||||||
|
@utils.sorter
|
||||||
|
def _show_selected(self):
|
||||||
|
"""
|
||||||
|
Show selected mails
|
||||||
|
Usage: show selected [modifiers]
|
||||||
|
"""
|
||||||
|
return self.selector.mails
|
||||||
|
|
||||||
|
def _show_filters(self):
|
||||||
|
"""
|
||||||
|
Show filters applied on current mails selection
|
||||||
|
Usage: show filters
|
||||||
|
"""
|
||||||
|
if not len(self.selector.filters):
|
||||||
|
return ["No filters applied on current selection"]
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for idx, pqfilter in enumerate(self.selector.filters):
|
||||||
|
name, _args, _kwargs = pqfilter
|
||||||
|
# name should always be prefixed with lookup_
|
||||||
|
lines.append('%d: select %s:' % (idx, name[7:]))
|
||||||
|
for key in sorted(_kwargs):
|
||||||
|
lines.append(" %s: %s" % (key, _kwargs[key]))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
# Postsuper generic command
|
||||||
|
def __do_super(self, operation):
|
||||||
|
"""Postsuper generic command"""
|
||||||
|
if not self.pstore.loaded_at:
|
||||||
|
return ["The store is not loaded"]
|
||||||
|
if not len(self.selector.mails):
|
||||||
|
return ["No mail selected"]
|
||||||
|
else:
|
||||||
|
func = getattr(self.qcontrol, '%s_messages' % operation)
|
||||||
|
try:
|
||||||
|
resp = func(self.selector.mails)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
return [str(exc)]
|
||||||
|
|
||||||
|
# reloads the data
|
||||||
|
self._store_load()
|
||||||
|
self._select_replay()
|
||||||
|
|
||||||
|
return [resp[-1]]
|
||||||
|
|
||||||
|
def _super_delete(self):
|
||||||
|
"""Deletes the mails in current selection
|
||||||
|
Usage: super delete
|
||||||
|
"""
|
||||||
|
return self.__do_super('delete')
|
||||||
|
|
||||||
|
def _super_hold(self):
|
||||||
|
"""Put on hold the mails in current selection
|
||||||
|
Usage: super hold
|
||||||
|
"""
|
||||||
|
return self.__do_super('hold')
|
||||||
|
|
||||||
|
def _super_release(self):
|
||||||
|
"""Releases from hold the mails in current selection
|
||||||
|
Usage: super release
|
||||||
|
"""
|
||||||
|
return self.__do_super('release')
|
||||||
|
|
||||||
|
def _super_requeue(self):
|
||||||
|
"""requeue the mails in current selection
|
||||||
|
Usage: super requeue
|
||||||
|
"""
|
||||||
|
return self.__do_super('requeue')
|
733
store.py
Normal file
733
store.py
Normal file
@ -0,0 +1,733 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
#
|
||||||
|
# Postfix queue control python tool (pymailq)
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014 Denis Pompilio (jawa) <denis.pompilio@gmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of pymailq
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import gc
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import email
|
||||||
|
from email import header
|
||||||
|
from collections import Counter
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pymailq import CONFIG, debug
|
||||||
|
|
||||||
|
|
||||||
|
class MailHeaders(object):
|
||||||
|
"""
|
||||||
|
Simple object to store mail headers.
|
||||||
|
|
||||||
|
Object's attributes are dynamically created when parent :class:`~store.Mail`
|
||||||
|
object's method :meth:`~store.Mail.parse` is called. Those attributes are
|
||||||
|
retrieved with help of :func:`~email.message_from_string` method provided
|
||||||
|
by the :mod:`email` module.
|
||||||
|
|
||||||
|
Standard RFC *822-style* mail headers becomes attributes including but not
|
||||||
|
limited to:
|
||||||
|
|
||||||
|
- :mailheader:`Received`
|
||||||
|
- :mailheader:`From`
|
||||||
|
- :mailheader:`To`
|
||||||
|
- :mailheader:`Cc`
|
||||||
|
- :mailheader:`Bcc`
|
||||||
|
- :mailheader:`Sender`
|
||||||
|
- :mailheader:`Reply-To`
|
||||||
|
- :mailheader:`Subject`
|
||||||
|
|
||||||
|
Case is kept while creating attribute and access will be made with
|
||||||
|
:attr:`Mail.From` or :attr:`Mail.Received` for example. All those
|
||||||
|
attributes will return *list* of values.
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
Python modules:
|
||||||
|
:mod:`email` -- An email and MIME handling package
|
||||||
|
|
||||||
|
:class:`email.message.Message` -- Representing an email message
|
||||||
|
|
||||||
|
:rfc:`822` -- Standard for ARPA Internet Text Messages
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Mail(object):
|
||||||
|
"""
|
||||||
|
Simple object to manipulate email messages.
|
||||||
|
|
||||||
|
This class provides the necessary methods to load and inspect mails
|
||||||
|
content. This object functionnalities are mainly based on :mod:`email`
|
||||||
|
module's provided class and methods. However,
|
||||||
|
:class:`email.message.Message` instance's stored informations are
|
||||||
|
extracted to extend :class:`~store.Mail` instances attributes.
|
||||||
|
|
||||||
|
Initialization of :class:`~store.Mail` instances are made the following
|
||||||
|
way:
|
||||||
|
|
||||||
|
:param str mail_id: Mail's queue ID string
|
||||||
|
:param int size: Mail size in Bytes (Default: ``0``)
|
||||||
|
:param datetime.datetime date: Acceptance date and time in mails queue.
|
||||||
|
(Default: :data:`None`)
|
||||||
|
:param str sender: Mail sender string as seen in mails queue.
|
||||||
|
(Default: empty :func:`str`)
|
||||||
|
|
||||||
|
The :class:`~pymailq.Mail` class defines the following attributes:
|
||||||
|
|
||||||
|
.. attribute:: qid
|
||||||
|
|
||||||
|
Mail Postfix queue ID string, validated by
|
||||||
|
:meth:`~store.PostqueueStore._is_mail_id` method.
|
||||||
|
|
||||||
|
.. attribute:: size
|
||||||
|
|
||||||
|
Mail size in bytes. Expected type is :func:`int`.
|
||||||
|
|
||||||
|
.. attribute:: parsed
|
||||||
|
|
||||||
|
:func:`bool` value to track if mail's content has been loaded from
|
||||||
|
corresponding spool file.
|
||||||
|
|
||||||
|
.. attribute:: parse_error
|
||||||
|
|
||||||
|
Last encountered parse error message :func:`str`.
|
||||||
|
|
||||||
|
.. attribute:: date
|
||||||
|
|
||||||
|
:class:`~datetime.datetime` object of acceptance date and time in
|
||||||
|
mails queue.
|
||||||
|
|
||||||
|
.. attribute:: status
|
||||||
|
|
||||||
|
Mail's queue status :func:`str`.
|
||||||
|
|
||||||
|
.. attribute:: sender
|
||||||
|
|
||||||
|
Mail's sender :func:`str` as seen in mails queue.
|
||||||
|
|
||||||
|
.. attribute:: recipients
|
||||||
|
|
||||||
|
Recipients :func:`list` as seen in mails queue.
|
||||||
|
|
||||||
|
.. attribute:: errors
|
||||||
|
|
||||||
|
Mail deliver errors :func:`list` as seen in mails queue.
|
||||||
|
|
||||||
|
.. attribute:: head
|
||||||
|
|
||||||
|
Mail's headers :class:`~store.MailHeaders` structure.
|
||||||
|
|
||||||
|
.. attribute:: raw_content
|
||||||
|
|
||||||
|
Mail's raw content :func:`str`.
|
||||||
|
|
||||||
|
.. attribute:: postcat_cmd
|
||||||
|
|
||||||
|
This property use Postfix mails content parsing command defined in
|
||||||
|
:attr:`pymailq.CONFIG` attribute under the key 'cat_message'.
|
||||||
|
Command and arguments list is build on call with the configuration
|
||||||
|
data.
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
:ref:`pymailq-configuration`
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, mail_id, size=0, date=None, sender=""):
|
||||||
|
"""Init method"""
|
||||||
|
self.parsed = False
|
||||||
|
self.parse_error = ""
|
||||||
|
self.qid = mail_id
|
||||||
|
self.date = date
|
||||||
|
self.status = ""
|
||||||
|
self.size = int(size)
|
||||||
|
self.sender = sender
|
||||||
|
self.recipients = []
|
||||||
|
self.errors = []
|
||||||
|
self.head = MailHeaders()
|
||||||
|
|
||||||
|
# Getting optionnal status from postqueue mail_id
|
||||||
|
postqueue_status = {'*': "active", '!': "hold"}
|
||||||
|
if mail_id[-1] in postqueue_status:
|
||||||
|
self.qid = mail_id[:-1]
|
||||||
|
self.status = postqueue_status.get(mail_id[-1], "deferred")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def postcat_cmd(self):
|
||||||
|
"""
|
||||||
|
Get the cat_message command from configuration
|
||||||
|
:return: Command as :class:`list`
|
||||||
|
"""
|
||||||
|
postcat_cmd = CONFIG['commands']['cat_message'] + [self.qid]
|
||||||
|
if CONFIG['commands']['use_sudo']:
|
||||||
|
postcat_cmd.insert(0, 'sudo')
|
||||||
|
return postcat_cmd
|
||||||
|
|
||||||
|
def show(self):
|
||||||
|
"""
|
||||||
|
Return mail detailled representation for printing
|
||||||
|
|
||||||
|
:return: Representation as :class:`str`
|
||||||
|
"""
|
||||||
|
output = "=== Mail %s ===\n" % (self.qid,)
|
||||||
|
for attr in sorted(dir(self.head)):
|
||||||
|
if attr.startswith("_"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
value = getattr(self.head, attr)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
value = ", ".join(value)
|
||||||
|
|
||||||
|
if attr == "Subject":
|
||||||
|
#print(attr, value)
|
||||||
|
value, enc = header.decode_header(value)[0]
|
||||||
|
#print(enc, attr, value)
|
||||||
|
if sys.version_info[0] == 2:
|
||||||
|
value = value.decode(enc) if enc else unicode(value)
|
||||||
|
|
||||||
|
output += "%s: %s\n" % (attr, value)
|
||||||
|
return output
|
||||||
|
|
||||||
|
@debug
|
||||||
|
def parse(self):
|
||||||
|
"""
|
||||||
|
Parse message content.
|
||||||
|
|
||||||
|
This method use Postfix mails content parsing command defined in
|
||||||
|
:attr:`~Mail.postcat_cmd` attribute.
|
||||||
|
This command is runned using :class:`subprocess.Popen` instance.
|
||||||
|
|
||||||
|
Parsed headers become attributes and are retrieved with help of
|
||||||
|
:func:`~email.message_from_string` function provided by the
|
||||||
|
:mod:`email` module.
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
Postfix manual:
|
||||||
|
`postcat`_ -- Show Postfix queue file contents
|
||||||
|
|
||||||
|
"""
|
||||||
|
# Reset parsing error message
|
||||||
|
self.parse_error = ""
|
||||||
|
|
||||||
|
child = subprocess.Popen(self.postcat_cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE)
|
||||||
|
stdout, stderr = child.communicate()
|
||||||
|
|
||||||
|
if not len(stdout):
|
||||||
|
# Ignore first 3 line on stderr which are:
|
||||||
|
# postcat: name_mask: all
|
||||||
|
# postcat: inet_addr_local: configured 3 IPv4 addresses
|
||||||
|
# postcat: inet_addr_local: configured 3 IPv6 addresses
|
||||||
|
self.parse_error = "\n".join(stderr.decode().split('\n')[3:])
|
||||||
|
return
|
||||||
|
|
||||||
|
raw_content = ""
|
||||||
|
for line in stdout.decode('utf-8', errors='replace').split('\n'):
|
||||||
|
if self.size == 0 and line.startswith("message_size: "):
|
||||||
|
self.size = int(line[14:].strip().split()[0])
|
||||||
|
elif self.date is None and line.startswith("create_time: "):
|
||||||
|
self.date = datetime.strptime(line[13:].strip(),
|
||||||
|
"%a %b %d %H:%M:%S %Y")
|
||||||
|
elif not len(self.sender) and line.startswith("sender: "):
|
||||||
|
self.sender = line[8:].strip()
|
||||||
|
elif line.startswith("regular_text: "):
|
||||||
|
raw_content += "%s\n" % (line[14:],)
|
||||||
|
|
||||||
|
# For python2.7 compatibility, encode unicode to str
|
||||||
|
if not isinstance(raw_content, str):
|
||||||
|
raw_content = raw_content.encode('utf-8')
|
||||||
|
|
||||||
|
message = email.message_from_string(raw_content)
|
||||||
|
|
||||||
|
for mailheader in set(message.keys()):
|
||||||
|
value = message.get_all(mailheader)
|
||||||
|
setattr(self.head, mailheader, value)
|
||||||
|
|
||||||
|
self.raw_content = raw_content
|
||||||
|
|
||||||
|
self.parsed = True
|
||||||
|
|
||||||
|
@debug
|
||||||
|
def dump(self):
|
||||||
|
"""
|
||||||
|
Dump mail's gathered informations to a :class:`dict` object.
|
||||||
|
|
||||||
|
Mails informations are splitted in two parts in dictionnary.
|
||||||
|
``postqueue`` key regroups every informations directly gathered from
|
||||||
|
Postfix queue, while ``headers`` regroups :class:`~store.MailHeaders`
|
||||||
|
attributes converted from mail content with the
|
||||||
|
:meth:`~store.Mail.parse` method.
|
||||||
|
|
||||||
|
If mail has not been parsed with the :meth:`~store.Mail.parse` method,
|
||||||
|
informations under the ``headers`` key will be empty.
|
||||||
|
|
||||||
|
:return: Mail gathered informations
|
||||||
|
:rtype: :class:`dict`
|
||||||
|
"""
|
||||||
|
datas = {'postqueue': {},
|
||||||
|
'headers': {}}
|
||||||
|
|
||||||
|
for attr in self.__dict__:
|
||||||
|
if attr[0] != "_" and attr != 'head':
|
||||||
|
datas['postqueue'].update({attr: getattr(self, attr)})
|
||||||
|
|
||||||
|
for mailheader in self.head.__dict__:
|
||||||
|
if mailheader[0] != "_":
|
||||||
|
datas['headers'].update(
|
||||||
|
{mailheader: getattr(self.head, mailheader)}
|
||||||
|
)
|
||||||
|
|
||||||
|
return datas
|
||||||
|
|
||||||
|
|
||||||
|
class PostqueueStore(object):
|
||||||
|
"""
|
||||||
|
Postfix mails queue informations storage.
|
||||||
|
|
||||||
|
The :class:`~store.PostqueueStore` provides methods to load Postfix
|
||||||
|
queued mails informations into Python structures. Thoses structures are
|
||||||
|
based on :class:`~store.Mail` and :class:`~store.MailHeaders` classes
|
||||||
|
which can be processed by a :class:`~selector.MailSelector` instance.
|
||||||
|
|
||||||
|
The :class:`~store.PostqueueStore` class defines the following attributes:
|
||||||
|
|
||||||
|
.. attribute:: mails
|
||||||
|
|
||||||
|
Loaded :class:`MailClass` objects :func:`list`.
|
||||||
|
|
||||||
|
.. attribute:: loaded_at
|
||||||
|
|
||||||
|
:class:`datetime.datetime` instance to store load date and time
|
||||||
|
informations, useful for datas deprecation tracking. Updated on
|
||||||
|
:meth:`~store.PostqueueStore.load` call with
|
||||||
|
:meth:`datetime.datetime.now` method.
|
||||||
|
|
||||||
|
.. attribute:: postqueue_cmd
|
||||||
|
|
||||||
|
:obj:`list` object to store Postfix command and arguments to view
|
||||||
|
the mails queue content. This property use Postfix mails content
|
||||||
|
parsing command defined in :attr:`pymailq.CONFIG` attribute under
|
||||||
|
the key 'list_queue'. Command and arguments list is build on call
|
||||||
|
with the configuration data.
|
||||||
|
|
||||||
|
.. attribute:: spool_path
|
||||||
|
|
||||||
|
Postfix spool path string.
|
||||||
|
Default is ``"/var/spool/postfix"``.
|
||||||
|
|
||||||
|
.. attribute:: postqueue_mailstatus
|
||||||
|
|
||||||
|
Postfix known queued mail status list.
|
||||||
|
Default is ``['active', 'deferred', 'hold']``.
|
||||||
|
|
||||||
|
.. attribute:: mail_id_re
|
||||||
|
|
||||||
|
Python compiled regular expression object (:class:`re.RegexObject`)
|
||||||
|
provided by :func:`re.compile` method to match postfix IDs.
|
||||||
|
Recognized IDs are either:
|
||||||
|
- hexadecimals, 8 to 12 chars length (regular queue IDs)
|
||||||
|
- encoded in a 52-character alphabet, 11 to 16 chars length
|
||||||
|
(long queue IDs)
|
||||||
|
They can be followed with ``*`` or ``!``.
|
||||||
|
Default used regular expression is:
|
||||||
|
``r"^([A-F0-9]{8,12}|[B-Zb-z0-9]{11,16})[*!]?$"``.
|
||||||
|
|
||||||
|
.. attribute:: mail_addr_re
|
||||||
|
|
||||||
|
Python compiled regular expression object (:class:`re.RegexObject`)
|
||||||
|
provided by :func:`re.compile` method to match email addresses.
|
||||||
|
Default used regular expression is:
|
||||||
|
``r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]+$"``
|
||||||
|
|
||||||
|
.. attribute:: MailClass
|
||||||
|
|
||||||
|
The class used to manipulate/parse mails individually.
|
||||||
|
Default is :class:`~store.Mail`.
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
Python modules:
|
||||||
|
:mod:`datetime` -- Basic date and time types
|
||||||
|
|
||||||
|
:mod:`re` -- Regular expression operations
|
||||||
|
|
||||||
|
Postfix manual:
|
||||||
|
`postqueue`_ -- Postfix queue control
|
||||||
|
|
||||||
|
:rfc:`3696` -- Checking and Transformation of Names
|
||||||
|
"""
|
||||||
|
postqueue_cmd = None
|
||||||
|
spool_path = None
|
||||||
|
postqueue_mailstatus = ['active', 'deferred', 'hold']
|
||||||
|
mail_id_re = re.compile(r"^([A-F0-9]{8,12}|[B-Zb-z0-9]{11,16})[*!]?$")
|
||||||
|
mail_addr_re = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]+$")
|
||||||
|
MailClass = Mail
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Init method"""
|
||||||
|
self.spool_path = CONFIG['core']['postfix_spool']
|
||||||
|
self.postqueue_cmd = CONFIG['commands']['list_queue']
|
||||||
|
if CONFIG['commands']['use_sudo']:
|
||||||
|
self.postqueue_cmd.insert(0, 'sudo')
|
||||||
|
|
||||||
|
self.loaded_at = None
|
||||||
|
self.mails = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
@debug
|
||||||
|
def known_headers(self):
|
||||||
|
"""Return known headers from loaded mails
|
||||||
|
|
||||||
|
:return: headers as :func:`set`
|
||||||
|
"""
|
||||||
|
headers = set()
|
||||||
|
for mail in self.mails:
|
||||||
|
for mailheader in dir(mail.head):
|
||||||
|
if not mailheader.startswith("_"):
|
||||||
|
headers.add(mailheader)
|
||||||
|
return headers
|
||||||
|
|
||||||
|
@debug
|
||||||
|
def _get_postqueue_output(self):
|
||||||
|
"""
|
||||||
|
Get Postfix postqueue command output.
|
||||||
|
|
||||||
|
This method used the postfix command defined in
|
||||||
|
:attr:`~PostqueueStore.postqueue_cmd` attribute to view the mails queue
|
||||||
|
content.
|
||||||
|
|
||||||
|
Command defined in :attr:`~PostqueueStore.postqueue_cmd` attribute is
|
||||||
|
runned using a :class:`subprocess.Popen` instance.
|
||||||
|
|
||||||
|
:return: Command's output lines.
|
||||||
|
:rtype: :func:`list`
|
||||||
|
|
||||||
|
.. seealso::
|
||||||
|
|
||||||
|
Python module:
|
||||||
|
:mod:`subprocess` -- Subprocess management
|
||||||
|
"""
|
||||||
|
child = subprocess.Popen(self.postqueue_cmd,
|
||||||
|
stdout=subprocess.PIPE)
|
||||||
|
stdout = child.communicate()[0]
|
||||||
|
|
||||||
|
# return lines list without the headers and footers
|
||||||
|
return [line.strip() for line in stdout.decode().split('\n')][1:-2]
|
||||||
|
|
||||||
|
def _is_mail_id(self, mail_id):
|
||||||
|
"""
|
||||||
|
Check mail_id for a valid postfix queued mail ID.
|
||||||
|
|
||||||
|
Validation is made using a :class:`re.RegexObject` stored in
|
||||||
|
the :attr:`~PostqueueStore.mail_id_re` attribute of the
|
||||||
|
:class:`~store.PostqueueStore` instance.
|
||||||
|
|
||||||
|
:param str mail_id: Mail Postfix queue ID string
|
||||||
|
:return: True or false
|
||||||
|
:rtype: :func:`bool`
|
||||||
|
"""
|
||||||
|
|
||||||
|
if self.mail_id_re.match(mail_id) is None:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@debug
|
||||||
|
def _load_from_postqueue(self, filename=None, parse=False):
|
||||||
|
"""
|
||||||
|
Load content from postfix queue using postqueue command output.
|
||||||
|
|
||||||
|
Output lines from :attr:`~store.PostqueueStore._get_postqueue_output`
|
||||||
|
are parsed to build :class:`~store.Mail` objects. Sample Postfix queue
|
||||||
|
control tool (`postqueue`_) output::
|
||||||
|
|
||||||
|
C0004979687 4769 Tue Apr 29 06:35:05 sender@domain.com
|
||||||
|
(error message from mx.remote1.org with parenthesis)
|
||||||
|
first.rcpt@remote1.org
|
||||||
|
(error message from mx.remote2.org with parenthesis)
|
||||||
|
second.rcpt@remote2.org
|
||||||
|
third.rcpt@remote2.org
|
||||||
|
|
||||||
|
Parsing rules are pretty simple:
|
||||||
|
|
||||||
|
- Line starts with a valid :attr:`Mail.qid`: create new
|
||||||
|
:class:`~store.Mail` object with :attr:`~Mail.qid`,
|
||||||
|
:attr:`~Mail.size`, :attr:`~Mail.date` and :attr:`~Mail.sender`
|
||||||
|
informations from line.
|
||||||
|
|
||||||
|
+-------------+------+---------------------------+-----------------+
|
||||||
|
| Queue ID | Size | Reception date and time | Sender |
|
||||||
|
+-------------+------+-----+-----+----+----------+-----------------+
|
||||||
|
| C0004979687 | 4769 | Tue | Apr | 29 | 06:35:05 | user@domain.com |
|
||||||
|
+-------------+------+-----+-----+----+----------+-----------------+
|
||||||
|
|
||||||
|
- Line starts with a parenthesis: store error messages to last created
|
||||||
|
:class:`~store.Mail` object's :attr:`~Mail.errors` attribute.
|
||||||
|
|
||||||
|
- Any other matches: add new recipient to the :attr:`~Mail.recipients`
|
||||||
|
attribute of the last created :class:`~store.Mail` object.
|
||||||
|
|
||||||
|
Optionnal argument ``filename`` can be set with a file containing
|
||||||
|
output of the `postqueue`_ command. In this case, output lines of
|
||||||
|
`postqueue`_ command are directly read from ``filename`` and parsed,
|
||||||
|
the `postqueue`_ command is never used.
|
||||||
|
|
||||||
|
Optionnal argument ``parse`` controls whether mails are parsed or not.
|
||||||
|
This is useful to load every known mail headers for later filtering.
|
||||||
|
|
||||||
|
:param str filename: File to load mails from
|
||||||
|
:param bool parse: Controls whether loaded mails are parsed or not.
|
||||||
|
"""
|
||||||
|
if filename is None:
|
||||||
|
postqueue_output = self._get_postqueue_output()
|
||||||
|
else:
|
||||||
|
postqueue_output = open(filename).readlines()
|
||||||
|
|
||||||
|
mail = None
|
||||||
|
for line in postqueue_output:
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
# Headers and footers start with dash (-)
|
||||||
|
if line.startswith('-'):
|
||||||
|
continue
|
||||||
|
# Mails are blank line separated
|
||||||
|
if not len(line):
|
||||||
|
continue
|
||||||
|
|
||||||
|
fields = line.split()
|
||||||
|
if "(" == fields[0][0]:
|
||||||
|
# Store error message without parenthesis: [1:-1]
|
||||||
|
# gathered errors must be associated with specific recipients
|
||||||
|
# TODO: change recipients or errors structures to link these
|
||||||
|
# objects together.
|
||||||
|
mail.errors.append(" ".join(fields)[1:-1])
|
||||||
|
else:
|
||||||
|
if self._is_mail_id(fields[0]):
|
||||||
|
# postfix does not precise year in mails timestamps so
|
||||||
|
# we consider mails have been sent this year.
|
||||||
|
# If gathered date is in the future:
|
||||||
|
# mail has been received last year (or NTP problem).
|
||||||
|
now = datetime.now()
|
||||||
|
datestr = "{0} {1}".format(" ".join(fields[2:-1]), now.year)
|
||||||
|
date = datetime.strptime(datestr, "%a %b %d %H:%M:%S %Y")
|
||||||
|
if date > now:
|
||||||
|
date = date - timedelta(days=365)
|
||||||
|
|
||||||
|
mail = self.MailClass(fields[0], size=fields[1],
|
||||||
|
date=date,
|
||||||
|
sender=fields[-1])
|
||||||
|
self.mails.append(mail)
|
||||||
|
else:
|
||||||
|
# Email address validity check can be tricky. RFC3696 talks
|
||||||
|
# about. Fow now, we use a simple regular expression to
|
||||||
|
# match most of email addresses.
|
||||||
|
rcpt_email_addr = " ".join(fields)
|
||||||
|
if self.mail_addr_re.match(rcpt_email_addr):
|
||||||
|
mail.recipients.append(rcpt_email_addr)
|
||||||
|
|
||||||
|
if parse:
|
||||||
|
#print("parsing mails")
|
||||||
|
[mail.parse() for mail in self.mails]
|
||||||
|
|
||||||
|
@debug
|
||||||
|
def _load_from_spool(self, parse=True):
|
||||||
|
"""
|
||||||
|
Load content from postfix queue using files from spool.
|
||||||
|
|
||||||
|
Mails are loaded using the command defined in
|
||||||
|
:attr:`~PostqueueStore.postqueue_cmd` attribute. Some informations may
|
||||||
|
be missing using the :meth:`~store.PostqueueStore._load_from_spool`
|
||||||
|
method, including at least :attr:`Mail.status` field.
|
||||||
|
|
||||||
|
Optionnal argument ``parse`` controls whether mails are parsed or not.
|
||||||
|
This is useful to load every known mail headers for later filtering.
|
||||||
|
|
||||||
|
Loaded mails are stored as :class:`~store.Mail` objects in
|
||||||
|
:attr:`~PostqueueStore.mails` attribute.
|
||||||
|
|
||||||
|
:param bool parse: Controls whether loaded mails are parsed or not.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
Be aware that parsing mails on disk is slow and can lead to
|
||||||
|
high load usage on system with large mails queue.
|
||||||
|
"""
|
||||||
|
for status in self.postqueue_mailstatus:
|
||||||
|
for fs_data in os.walk("%s/%s" % (self.spool_path, status)):
|
||||||
|
for mail_id in fs_data[2]:
|
||||||
|
mail = self.MailClass(mail_id)
|
||||||
|
mail.status = status
|
||||||
|
|
||||||
|
mail.parse()
|
||||||
|
|
||||||
|
self.mails.append(mail)
|
||||||
|
|
||||||
|
@debug
|
||||||
|
def _load_from_file(self, filename):
|
||||||
|
"""Unimplemented method"""
|
||||||
|
|
||||||
|
@debug
|
||||||
|
def load(self, method="postqueue", filename=None, parse=False):
|
||||||
|
"""
|
||||||
|
Load content from postfix mails queue.
|
||||||
|
|
||||||
|
Mails are loaded using postqueue command line tool or reading directly
|
||||||
|
from spool. The optionnal argument, if present, is a method string and
|
||||||
|
specifies the method used to gather mails informations. By default,
|
||||||
|
method is set to ``"postqueue"`` and the standard Postfix queue
|
||||||
|
control tool: `postqueue`_ is used.
|
||||||
|
|
||||||
|
Optionnal argument ``parse`` controls whether mails are parsed or not.
|
||||||
|
This is useful to load every known mail headers for later filtering.
|
||||||
|
|
||||||
|
:param str method: Method used to load mails from Postfix queue
|
||||||
|
:param str filename: File to load mails from
|
||||||
|
:param bool parse: Controls whether loaded mails are parsed or not.
|
||||||
|
|
||||||
|
Provided method :func:`str` name is directly used with :func:`getattr`
|
||||||
|
to find a *self._load_from_<method>* method.
|
||||||
|
"""
|
||||||
|
# releasing memory
|
||||||
|
del self.mails
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
self.mails = []
|
||||||
|
if filename is None:
|
||||||
|
getattr(self, "_load_from_{0}".format(method))(parse=parse)
|
||||||
|
else:
|
||||||
|
getattr(self, "_load_from_{0}".format(method))(filename, parse)
|
||||||
|
self.loaded_at = datetime.now()
|
||||||
|
|
||||||
|
@debug
|
||||||
|
def summary(self):
|
||||||
|
"""
|
||||||
|
Summarize the mails queue content.
|
||||||
|
|
||||||
|
:return: Mail queue summary as :class:`dict`
|
||||||
|
|
||||||
|
Sizes are in bytes.
|
||||||
|
|
||||||
|
Example response::
|
||||||
|
|
||||||
|
{
|
||||||
|
'total_mails': 500,
|
||||||
|
'total_mails_size': 709750,
|
||||||
|
'average_mail_size': 1419.5,
|
||||||
|
'max_mail_size': 2414,
|
||||||
|
'min_mail_size': 423,
|
||||||
|
'top_errors': [
|
||||||
|
('mail transport unavailable', 484),
|
||||||
|
('Test error message', 16)
|
||||||
|
],
|
||||||
|
'top_recipient_domains': [
|
||||||
|
('test-domain.tld', 500)
|
||||||
|
],
|
||||||
|
'top_recipients': [
|
||||||
|
('user-3@test-domain.tld', 200),
|
||||||
|
('user-2@test-domain.tld', 200),
|
||||||
|
('user-1@test-domain.tld', 100)
|
||||||
|
],
|
||||||
|
'top_sender_domains': [
|
||||||
|
('test-domain.tld', 500)
|
||||||
|
],
|
||||||
|
'top_senders': [
|
||||||
|
('sender-1@test-domain.tld', 100),
|
||||||
|
('sender-2@test-domain.tld', 100),
|
||||||
|
('sender-7@test-domain.tld', 50),
|
||||||
|
('sender-4@test-domain.tld', 50),
|
||||||
|
('sender-5@test-domain.tld', 50)
|
||||||
|
],
|
||||||
|
'top_status': [
|
||||||
|
('deferred', 500),
|
||||||
|
('active', 0),
|
||||||
|
('hold', 0)
|
||||||
|
],
|
||||||
|
'unique_recipient_domains': 1,
|
||||||
|
'unique_recipients': 3,
|
||||||
|
'unique_sender_domains': 1,
|
||||||
|
'unique_senders': 8
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
senders = Counter()
|
||||||
|
sender_domains = Counter()
|
||||||
|
recipients = Counter()
|
||||||
|
recipient_domains = Counter()
|
||||||
|
status = Counter(active=0, hold=0, deferred=0)
|
||||||
|
errors = Counter()
|
||||||
|
total_mails_size = 0
|
||||||
|
average_mail_size = 0
|
||||||
|
max_mail_size = 0
|
||||||
|
min_mail_size = 0
|
||||||
|
mails_by_age = {
|
||||||
|
'last_24h': 0,
|
||||||
|
'1_to_4_days_ago': 0,
|
||||||
|
'older_than_4_days': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
for mail in self.mails:
|
||||||
|
status[mail.status] += 1
|
||||||
|
senders[mail.sender] += 1
|
||||||
|
if '@' in mail.sender:
|
||||||
|
sender_domains[mail.sender.split('@', 1)[1]] += 1
|
||||||
|
for recipient in mail.recipients:
|
||||||
|
recipients[recipient] += 1
|
||||||
|
if '@' in recipient:
|
||||||
|
recipient_domains[recipient.split('@', 1)[1]] += 1
|
||||||
|
for error in mail.errors:
|
||||||
|
errors[error] += 1
|
||||||
|
total_mails_size += mail.size
|
||||||
|
if mail.size > max_mail_size:
|
||||||
|
max_mail_size = mail.size
|
||||||
|
if min_mail_size == 0:
|
||||||
|
min_mail_size = mail.size
|
||||||
|
elif mail.size < min_mail_size:
|
||||||
|
min_mail_size = mail.size
|
||||||
|
|
||||||
|
mail_age = datetime.now() - mail.date
|
||||||
|
if mail_age.days >= 4:
|
||||||
|
mails_by_age['older_than_4_days'] += 1
|
||||||
|
elif mail_age.days == 1:
|
||||||
|
mails_by_age['1_to_4_days_ago'] += 1
|
||||||
|
elif mail_age.days == 0:
|
||||||
|
mails_by_age['last_24h'] += 1
|
||||||
|
|
||||||
|
if len(self.mails):
|
||||||
|
average_mail_size = total_mails_size / len(self.mails)
|
||||||
|
|
||||||
|
summary = {
|
||||||
|
'total_mails': len(self.mails),
|
||||||
|
'mails_by_age': mails_by_age,
|
||||||
|
'total_mails_size': total_mails_size,
|
||||||
|
'average_mail_size': average_mail_size,
|
||||||
|
'max_mail_size': max_mail_size,
|
||||||
|
'min_mail_size': min_mail_size,
|
||||||
|
'top_status': status.most_common()[:5],
|
||||||
|
'unique_senders': len(list(senders)),
|
||||||
|
'unique_sender_domains': len(list(sender_domains)),
|
||||||
|
'unique_recipients': len(list(recipients)),
|
||||||
|
'unique_recipient_domains': len(list(recipient_domains)),
|
||||||
|
'top_senders': senders.most_common()[:5],
|
||||||
|
'top_sender_domains': sender_domains.most_common()[:5],
|
||||||
|
'top_recipients': recipients.most_common()[:5],
|
||||||
|
'top_recipient_domains': recipient_domains.most_common()[:5],
|
||||||
|
'top_errors': errors.most_common()[:5]
|
||||||
|
}
|
||||||
|
return summary
|
189
utils.py
Normal file
189
utils.py
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
# coding: utf-8
|
||||||
|
#
|
||||||
|
# Postfix queue control python tool (pymailq)
|
||||||
|
#
|
||||||
|
# Copyright (C) 2014 Denis Pompilio (jawa) <denis.pompilio@gmail.com>
|
||||||
|
#
|
||||||
|
# This file is part of pymailq
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or
|
||||||
|
# modify it under the terms of the GNU General Public License
|
||||||
|
# as published by the Free Software Foundation; either version 2
|
||||||
|
# of the License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program; if not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
import re
|
||||||
|
from functools import wraps
|
||||||
|
from collections import Counter
|
||||||
|
|
||||||
|
|
||||||
|
FORMAT_PARSER = re.compile(r'\{[^{}]+\}')
|
||||||
|
FORMATS = {
|
||||||
|
'brief': "{date} {qid} [{status}] {sender} ({size}B)",
|
||||||
|
'long': ("{date} {qid} [{status}] {sender} ({size}B)\n"
|
||||||
|
" Rcpt: {recipients}\n"
|
||||||
|
" Err: {errors}")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def viewer(function):
|
||||||
|
"""Result viewer decorator
|
||||||
|
|
||||||
|
:param func function: Function to decorate
|
||||||
|
"""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
args = list(args) # conversion need for arguments cleaning
|
||||||
|
limit = None
|
||||||
|
overhead = 0
|
||||||
|
try:
|
||||||
|
if "limit" in args:
|
||||||
|
limit_idx = args.index('limit')
|
||||||
|
args.pop(limit_idx) # pop option, next arg is value
|
||||||
|
limit = int(args.pop(limit_idx))
|
||||||
|
except (IndexError, TypeError, ValueError):
|
||||||
|
raise SyntaxError("limit modifier needs a valid number")
|
||||||
|
|
||||||
|
output = "brief"
|
||||||
|
for known in FORMATS:
|
||||||
|
if known in args:
|
||||||
|
output = args.pop(args.index(known))
|
||||||
|
break
|
||||||
|
out_format = FORMATS[output]
|
||||||
|
|
||||||
|
elements = function(*args, **kwargs)
|
||||||
|
|
||||||
|
total_elements = len(elements)
|
||||||
|
if not total_elements:
|
||||||
|
return ["No element to display"]
|
||||||
|
|
||||||
|
# Check for headers and increase limit accordingly
|
||||||
|
headers = 0
|
||||||
|
if total_elements > 1 and "========" in str(elements[1]):
|
||||||
|
headers = 2
|
||||||
|
|
||||||
|
if limit is not None:
|
||||||
|
if total_elements > (limit + headers):
|
||||||
|
overhead = total_elements - (limit + headers)
|
||||||
|
else:
|
||||||
|
limit = total_elements
|
||||||
|
else:
|
||||||
|
limit = total_elements
|
||||||
|
|
||||||
|
out_format_attrs = FORMAT_PARSER.findall(out_format)
|
||||||
|
formatted = []
|
||||||
|
for element in elements[:limit + headers]:
|
||||||
|
# if attr qid exists, assume this is a mail
|
||||||
|
if hasattr(element, "qid"):
|
||||||
|
attrs = {}
|
||||||
|
for att in out_format_attrs:
|
||||||
|
if att == "{recipients}":
|
||||||
|
rcpts = getattr(element, att[1:-1], ["-"])
|
||||||
|
attrs[att[1:-1]] = ", ".join(rcpts)
|
||||||
|
elif att == "{errors}":
|
||||||
|
errors = getattr(element, att[1:-1], ["-"])
|
||||||
|
attrs[att[1:-1]] = "\n".join(errors)
|
||||||
|
else:
|
||||||
|
attrs[att[1:-1]] = getattr(element, att[1:-1], "-")
|
||||||
|
formatted.append(out_format.format(**attrs))
|
||||||
|
else:
|
||||||
|
formatted.append(element)
|
||||||
|
|
||||||
|
if overhead > 0:
|
||||||
|
msg = "...Preview of first %d (%d more)..." % (limit, overhead)
|
||||||
|
formatted.append(msg)
|
||||||
|
|
||||||
|
return formatted
|
||||||
|
wrapper.__doc__ = function.__doc__
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def sorter(function):
|
||||||
|
"""Result sorter decorator.
|
||||||
|
|
||||||
|
This decorator inspect decorated function arguments and search for
|
||||||
|
known keyword to sort decorated function result.
|
||||||
|
"""
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
args = list(args) # conversion need for arguments cleaning
|
||||||
|
sortkey = "date" # default sort by date
|
||||||
|
reverse = True # default sorting is desc
|
||||||
|
if "sortby" in args:
|
||||||
|
sortby_idx = args.index('sortby')
|
||||||
|
args.pop(sortby_idx) # pop option, next arg is value
|
||||||
|
|
||||||
|
try:
|
||||||
|
sortkey = args.pop(sortby_idx)
|
||||||
|
except IndexError:
|
||||||
|
raise SyntaxError("sortby requires a field")
|
||||||
|
|
||||||
|
# third param may be asc or desc, ignore unknown values
|
||||||
|
try:
|
||||||
|
if "asc" == args[sortby_idx]:
|
||||||
|
args.pop(sortby_idx)
|
||||||
|
reverse = False
|
||||||
|
elif "desc" == args[sortby_idx]:
|
||||||
|
args.pop(sortby_idx)
|
||||||
|
except IndexError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
elements = function(*args, **kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
sorted_elements = sorted(elements,
|
||||||
|
key=lambda x: getattr(x, sortkey),
|
||||||
|
reverse=reverse)
|
||||||
|
except AttributeError:
|
||||||
|
msg = "elements cannot be sorted by %s" % sortkey
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
return sorted_elements
|
||||||
|
wrapper.__doc__ = function.__doc__
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def ranker(function):
|
||||||
|
"""Result ranker decorator
|
||||||
|
"""
|
||||||
|
@wraps(function)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
args = list(args) # conversion need for arguments cleaning
|
||||||
|
rankkey = None
|
||||||
|
if "rankby" in args:
|
||||||
|
rankby_idx = args.index('rankby')
|
||||||
|
args.pop(rankby_idx) # pop option, next arg is value
|
||||||
|
|
||||||
|
try:
|
||||||
|
rankkey = args.pop(rankby_idx)
|
||||||
|
except IndexError:
|
||||||
|
raise SyntaxError("rankby requires a field")
|
||||||
|
|
||||||
|
elements = function(*args, **kwargs)
|
||||||
|
|
||||||
|
if rankkey is not None:
|
||||||
|
try:
|
||||||
|
rank = Counter()
|
||||||
|
for element in elements:
|
||||||
|
rank[getattr(element, rankkey)] += 1
|
||||||
|
|
||||||
|
# XXX: headers are taken in elements display limit :(
|
||||||
|
ranked_elements = ['%-40s count' % rankkey, '='*48]
|
||||||
|
for entry in rank.most_common():
|
||||||
|
key, value = entry
|
||||||
|
ranked_elements.append('%-40s %s' % (key, value))
|
||||||
|
return ranked_elements
|
||||||
|
|
||||||
|
except AttributeError:
|
||||||
|
msg = "elements cannot be ranked by %s" % rankkey
|
||||||
|
raise SyntaxError(msg)
|
||||||
|
|
||||||
|
return elements
|
||||||
|
wrapper.__doc__ = function.__doc__
|
||||||
|
return wrapper
|
Loading…
Reference in New Issue
Block a user