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