├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGES ├── LICENSE ├── README.rst ├── VERSION ├── bin └── pqshell ├── docs ├── Makefile ├── conf.py ├── config.rst ├── control.rst ├── docs.requirements.txt ├── index.rst ├── pqshell.rst ├── selector.rst ├── shell.rst └── store.rst ├── helpers ├── make_deb_package.sh ├── make_manpages.sh ├── make_pip_sdist.sh ├── update_deb_changelog.sh └── update_version.sh ├── man └── pqshell.1 ├── packaging ├── debian-binary │ ├── pymailq_0.8.0-1_amd64.changes │ ├── pymailq_0.9.0-1_amd64.changes │ ├── python-pymailq_0.8.0-0~17.30_all.deb │ ├── python-pymailq_0.9.0-0~17.30_all.deb │ ├── python3-pymailq_0.8.0-0~17.30_all.deb │ └── python3-pymailq_0.9.0-0~17.30_all.deb ├── debian │ ├── changelog │ ├── compat │ ├── control │ ├── copyright │ ├── rules │ └── watch └── pymailq.spec ├── pymailq ├── __init__.py ├── control.py ├── selector.py ├── shell.py ├── store.py └── utils.py ├── setup.py ├── share └── doc │ └── examples │ └── pymailq.ini └── tests ├── README.md ├── commands.txt ├── generate_samples.sh ├── run_tests.sh ├── samples ├── mailq.sample └── pymailq.ini ├── test_mail_api.py ├── test_pymailq_shell.py └── tests.requirements.txt /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | 5 | [run] 6 | omit = 7 | */.local/share/virtualenvs/* 8 | tests/* 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | 5 | # C extensions 6 | *.so 7 | 8 | # Distribution / packaging 9 | .Python 10 | env/ 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | eggs/ 15 | lib/ 16 | lib64/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | MANIFEST 24 | 25 | # Installer logs 26 | pip-log.txt 27 | pip-delete-this-directory.txt 28 | 29 | # Unit test / coverage reports 30 | htmlcov/ 31 | .tox/ 32 | .coverage 33 | .cache 34 | nosetests.xml 35 | coverage.xml 36 | 37 | # Translations 38 | *.mo 39 | 40 | # Sphinx documentation 41 | .buildinfo 42 | docs/_build/ 43 | docs/gh_pages/ 44 | 45 | # Vim swap files 46 | .*.swp 47 | 48 | # pycharm project 49 | .idea 50 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | - 3.4 5 | - 3.5 6 | - 3.6 7 | before_install: 8 | - sudo apt-get update -qq 9 | - sudo apt-get install -qq postfix 10 | install: 11 | - pip install -r tests/tests.requirements.txt 12 | - python ./setup.py install 13 | before_script: 14 | - sudo ./tests/generate_samples.sh 15 | - sudo mailq 16 | script: 17 | - pytest tests/ 18 | -------------------------------------------------------------------------------- /CHANGES: -------------------------------------------------------------------------------- 1 | pymailq - Simple mail queue management 2 | ====================================== 3 | 4 | Contact: Denis Pompilio (jawa) 5 | https://github.com/outini/pymailq 6 | 7 | === v0.9.0 07/09/2017 === 8 | * [bug] Fixing wrong usage for command inspect 9 | * Auto-decoding encoded mail subjects 10 | * Better encoding support for python2.7 and python3+ 11 | * Mail.show() method now display every mail headers 12 | * Implementation of store auto loading 13 | * Added Debian binary packages 14 | 15 | === v0.8.0 28/08/2017 === 16 | * [bug] Fixing handling of modifiers completion 17 | * [bug] Fixing parse_error message retention 18 | * [bug] Fixing size selection error when using only maximum size 19 | * Removing exception StoreNotLoaded 20 | * Making 'cat_message' command dynamic in Mail objects 21 | * Implementation of the "long" output format 22 | * Implementation of mail queue summary 23 | * Implementation of Mail.show() method 24 | * Implementation of selection by queue IDs 25 | * Improved unittests 26 | 27 | === v0.7.0 24/08/2017 === 28 | * Support of configuration file 29 | 30 | === v0.6.0 16/08/2017 === 31 | * Pqshell now have usage and options 32 | * Pqshell can now show pymailq package version 33 | * Pqshell can now be started in debug mode 34 | * Improved shell completion with suggests and modifiers 35 | * Implementation of the "mails by date" selector 36 | * Reworked postsuper commands handling 37 | * Better pep8 support 38 | * Unit testing for python2.7 and python3 39 | * Using code coverage 40 | 41 | === v0.5.3 19/10/2014 === 42 | * Complete support for python2.7 and python3 43 | * Project renamed to PyMailq 44 | 45 | === v0.5.2 23/07/2014 === 46 | * Massive code updates for Python 3 support. 47 | * Converted README to reSt format. 48 | * Added CHANGES file for change tracking. 49 | * Added mails queue loading from file for code tests and validation. 50 | * Updated SPECS file for pyqueue v0.5.2 51 | * Corrected shell behavior for better KeyboardInterrupt handling. 52 | * Corrected debug display. 53 | 54 | === v0.5.1 05/05/2014 === 55 | * Added examples in pqshell MAN page. 56 | * Improved documentation generation. 57 | * Corrected some typos in setup.py 58 | 59 | === v0.5.0 05/05/2014 === 60 | * pyqueue Python module becomes Python package. 61 | * Added pqshell for interactive queue management. 62 | * Added SPECS file for RPM packaging. Thanks to Nils Ratusznik. 63 | * Added MAN page for pqshell. 64 | * Corrected setup.py to reflect module to package transition. 65 | * Corrected setup.py to ship additionnal files. 66 | * Corrected shell's subprocess errors handling. 67 | * Updated code documentation. 68 | 69 | === v0.4 30/04/2014 === 70 | * Added COPYRIGHT and LICENSE. 71 | * Added code documentation. 72 | * Added setup.py for Python packaging. 73 | * Corrected mails queue load error handling. 74 | * Corrected mails arrival_date handling. 75 | 76 | === 26/04/2014 === 77 | * Initial release of pyqueue. 78 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | |PythonPIP|_ |PythonSupport|_ |License|_ |Codacy|_ |Coverage|_ |RTFD|_ |Travis|_ 2 | 3 | pymailq - Simple Postfix queue management 4 | ========================================= 5 | 6 | | **Contact:** Denis 'jawa' Pompilio 7 | | **Sources:** https://github.com/outini/pymailq/ 8 | | 9 | | A full content documentation, is online at https://pymailq.readthedocs.io/en/latest/ 10 | | 11 | | The pymailq module makes it easy to view and control Postfix mails queue. It 12 | | provide several classes to store, view and interact with mail queue using 13 | | Postfix command line tools. This module is provided for automation and 14 | | monitoring developments. 15 | | 16 | | This project also provides a shell-like to interact with Postfix mails queue. 17 | | It provide simple means to view the queue content, filter mails on criterias 18 | | like Sender or delivery errors and lead administrative operations. 19 | 20 | Installation 21 | ------------ 22 | 23 | Install pymailq module from https://pypi.python.org:: 24 | 25 | pip install pymailq 26 | 27 | Install pymailq module from sources:: 28 | 29 | python setup.py install 30 | 31 | A SPEC file is also provided for RPM builds (currently tested only on Fedora), 32 | thanks to Nils Ratusznik (https://github.com/ahpnils). Debian binary packages 33 | are also available. 34 | 35 | Requirements 36 | ------------ 37 | 38 | This module actually support the following Python versions: 39 | 40 | * *Python 2.7* 41 | * *Python 3+* 42 | 43 | A shell is provided for interactive administration. Based on Python *cmd* 44 | module, using Python compiled with *readline* support is highly recommended 45 | to access shell's full features. 46 | 47 | Using the shell 48 | --------------- 49 | 50 | Mails queue summary:: 51 | 52 | ~$ pqshell --summary 53 | 54 | ====================== Mail queue summary ======================== 55 | Total mails in queue: 1773 56 | Total queue size: 40.2 MB 57 | 58 | Mails by accepted date: 59 | last 24h: 939 60 | 1 to 4 days ago: 326 61 | older than 4 days: 173 62 | 63 | ----- Mails by status ---------- ----- Mails by size ---------- 64 | Active 2 Average size 23.239 KB 65 | Hold 896 Maximum size 1305.029 KB 66 | Deferred 875 Minimum size 0.517 KB 67 | 68 | ----- Unique senders ----------- ----- Unique recipients ------ 69 | Senders 156 Recipients 1003 70 | Domains 141 Domains 240 71 | 72 | ----- Top senders ------------------------------------------------ 73 | 228 sender-3@domain-1.tld 74 | 195 sender-1@domain-4.tld 75 | 116 MAILER-DAEMON 76 | 105 sender-2@domain-2.tld 77 | 61 sender-7@domain-3.tld 78 | 79 | ----- Top sender domains ----------------------------------------- 80 | 228 domain-1.tld 81 | 195 domain-4.tld 82 | 105 domain-2.tld 83 | 75 domain-0.tld 84 | 61 domain-3.tld 85 | 86 | ----- Top recipients --------------------------------------------- 87 | 29 user-1@domain-5.tld 88 | 28 user-5@domain-9.tld 89 | 23 user-2@domain-8.tld 90 | 20 user-3@domain-6.tld 91 | 20 user-4@domain-7.tld 92 | 93 | ----- Top recipient domains -------------------------------------- 94 | 697 domain-7.tld 95 | 455 domain-5.tld 96 | 37 domain-6.tld 97 | 37 domain-9.tld 98 | 34 domain-8.tld 99 | 100 | Using the shell in interactive mode:: 101 | 102 | ~$ pqshell 103 | Welcome to PyMailq shell. 104 | PyMailq (sel:0)> store load 105 | 500 mails loaded from queue 106 | PyMailq (sel:500)> show selected limit 5 107 | 2017-09-02 17:54:34 B04C91183774 [deferred] sender-6@test-domain.tld (425B) 108 | 2017-09-02 17:54:34 B21D71183681 [deferred] sender-2@test-domain.tld (435B) 109 | 2017-09-02 17:54:34 B422D11836AB [deferred] sender-7@test-domain.tld (2416B) 110 | 2017-09-02 17:54:34 B21631183753 [deferred] sender-6@test-domain.tld (425B) 111 | 2017-09-02 17:54:34 F2A7E1183789 [deferred] sender-2@test-domain.tld (2416B) 112 | ...Preview of first 5 (495 more)... 113 | PyMailq (sel:500)> show selected limit 5 long 114 | 2017-09-02 17:54:34 B04C91183774 [deferred] sender-6@test-domain.tld (425B) 115 | Rcpt: user-3@test-domain.tld 116 | Err: Test error message 117 | 2017-09-02 17:54:34 B21D71183681 [deferred] sender-2@test-domain.tld (435B) 118 | Rcpt: user-3@test-domain.tld 119 | Err: Test error message 120 | 2017-09-02 17:54:34 B422D11836AB [deferred] sender-7@test-domain.tld (2416B) 121 | Rcpt: user-2@test-domain.tld 122 | Err: mail transport unavailable 123 | 2017-09-02 17:54:34 B21631183753 [deferred] sender-6@test-domain.tld (425B) 124 | Rcpt: user-3@test-domain.tld 125 | Err: mail transport unavailable 126 | 2017-09-02 17:54:34 F2A7E1183789 [deferred] sender-2@test-domain.tld (2416B) 127 | Rcpt: user-1@test-domain.tld 128 | Err: mail transport unavailable 129 | ...Preview of first 5 (495 more)... 130 | PyMailq (sel:500)> select error "Test error message" 131 | PyMailq (sel:16)> show selected rankby sender 132 | sender count 133 | ================================================ 134 | sender-2@test-domain.tld 7 135 | sender-4@test-domain.tld 3 136 | sender-6@test-domain.tld 2 137 | sender-5@test-domain.tld 1 138 | sender-8@test-domain.tld 1 139 | sender-3@test-domain.tld 1 140 | sender-1@test-domain.tld 1 141 | PyMailq (sel:16)> select sender sender-2@test-domain.tld 142 | PyMailq (sel:7)> super hold 143 | postsuper: Placed on hold: 7 messages 144 | PyMailq (sel:7)> select reset 145 | Selector resetted with store content (500 mails) 146 | PyMailq (sel:500)> show selected rankby status 147 | status count 148 | ================================================ 149 | deferred 493 150 | hold 7 151 | PyMailq (sel:500)> exit 152 | Exiting shell... Bye. 153 | 154 | Packaging 155 | --------- 156 | 157 | Binary packages for some linux distribution are available. See the *packaging* 158 | directory for more information. 159 | 160 | License 161 | ------- 162 | 163 | "GNU GENERAL PUBLIC LICENSE" (Version 2) *(see LICENSE file)* 164 | 165 | 166 | .. |PythonPIP| image:: https://img.shields.io/pypi/v/pymailq.svg 167 | .. _PythonPIP: https://pypi.python.org/pypi/pymailq/ 168 | .. |PythonSupport| image:: https://img.shields.io/badge/python-2.7,%203.4,%203.5,%203.6-blue.svg 169 | .. _PythonSupport: https://github.com/outini/pymailq/ 170 | .. |License| image:: https://img.shields.io/badge/license-GPLv2-blue.svg 171 | .. _License: https://github.com/outini/pymailq/ 172 | .. |Codacy| image:: https://api.codacy.com/project/badge/Grade/8444a0f124fe463d86a91d80a2a52e7c 173 | .. _Codacy: https://www.codacy.com/app/outini/pymailq 174 | .. |Coverage| image:: https://api.codacy.com/project/badge/Coverage/8444a0f124fe463d86a91d80a2a52e7c 175 | .. _Coverage: https://www.codacy.com/app/outini/pymailq 176 | .. |RTFD| image:: https://readthedocs.org/projects/pymailq/badge/?version=latest 177 | .. _RTFD: http://pymailq.readthedocs.io/en/latest/?badge=latest 178 | .. |Travis| image:: https://travis-ci.org/outini/pymailq.svg?branch=master 179 | .. _Travis: https://travis-ci.org/outini/pymailq 180 | 181 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.9.0 2 | -------------------------------------------------------------------------------- /bin/pqshell: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python 2 | 3 | # coding: utf-8 4 | # 5 | # Postfix queue control python tool (pymailq) 6 | # 7 | # Copyright (C) 2014 Denis Pompilio (jawa) 8 | # 9 | # This file is part of pymailq 10 | # 11 | # This program is free software; you can redistribute it and/or 12 | # modify it under the terms of the GNU General Public License 13 | # as published by the Free Software Foundation; either version 2 14 | # of the License, or (at your option) any later version. 15 | # 16 | # This program is distributed in the hope that it will be useful, 17 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 18 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 19 | # GNU General Public License for more details. 20 | # 21 | # You should have received a copy of the GNU General Public License 22 | # along with this program; if not, see . 23 | 24 | import argparse 25 | import pymailq 26 | from pymailq import shell, store 27 | 28 | 29 | SUMMARY = """ 30 | ====================== Mail queue summary ======================== 31 | Total mails in queue: {total_mails} 32 | Total queue size: {total_mails_size:.3} MB 33 | 34 | Mails by accepted date: 35 | last 24h: {mails_by_age[last_24h]} 36 | 1 to 4 days ago: {mails_by_age[1_to_4_days_ago]} 37 | older than 4 days: {mails_by_age[older_than_4_days]} 38 | 39 | ----- Mails by status ---------- ----- Mails by size ---------- 40 | Active {active_mails:<18} Average size {average_mail_size:10.3f} KB 41 | Hold {hold_mails:<16} Maximum size {max_mail_size:10.3f} KB 42 | Deferred {deferred_mails:<20} Minimum size {min_mail_size:10.3f} KB 43 | 44 | ----- Unique senders ----------- ----- Unique recipients ------ 45 | Senders {unique_senders:<20} Recipients {unique_recipients:>10} 46 | Domains {u_s_domains:<17} Domains {u_r_domains:>10} 47 | 48 | ----- Top senders ------------------------------------------------ 49 | {top_senders} 50 | 51 | ----- Top sender domains ----------------------------------------- 52 | {top_sender_domains} 53 | 54 | ----- Top recipients --------------------------------------------- 55 | {top_recipients} 56 | 57 | ----- Top recipient domains -------------------------------------- 58 | {top_recipient_domains} 59 | """ 60 | 61 | 62 | def main(store_auto_load=True): 63 | """main function""" 64 | cli = shell.PyMailqShell(store_auto_load=store_auto_load) 65 | cli.cmdloop_nointerrupt() 66 | 67 | 68 | if __name__ == "__main__": 69 | parser = argparse.ArgumentParser(description='Postfix queue control shell') 70 | parser.add_argument('--version', dest='version', action='store_const', 71 | const=True, default=False, 72 | help='show shell version') 73 | parser.add_argument('--debug', dest='debug', action='store_const', 74 | const=True, default=False, 75 | help='show shell debug and timing info') 76 | parser.add_argument('--config', dest='cfg_file', 77 | help='specify a configuration file for PyMailq') 78 | parser.add_argument('--summary', dest='summary', action='store_const', 79 | const=True, default=False, 80 | help='summarize the mails queue content') 81 | parser.add_argument('--no-auto-load', dest='noautoload', 82 | action='store_const', const=True, default=False, 83 | help='deactivate store auto load at shell startup') 84 | 85 | args = parser.parse_args() 86 | 87 | if args.version: 88 | print("Shell version: %s" % pymailq.VERSION) 89 | exit() 90 | 91 | if args.debug: 92 | print("Setting shell in debug mode.") 93 | pymailq.DEBUG = True 94 | 95 | if args.cfg_file: 96 | print("Using custom configuration: " + str(args.cfg_file)) 97 | pymailq.load_config(args.cfg_file) 98 | 99 | if args.summary: 100 | pstore = store.PostqueueStore() 101 | pstore.load() 102 | data = pstore.summary() 103 | data['total_mails_size'] /= 1024*1024.0 104 | data['average_mail_size'] /= 1024.0 105 | data['max_mail_size'] /= 1024.0 106 | data['min_mail_size'] /= 1024.0 107 | data['u_s_domains'] = data['unique_sender_domains'] 108 | data['u_r_domains'] = data['unique_recipient_domains'] 109 | for status in data['top_status']: 110 | data[status[0] + '_mails'] = status[1] 111 | 112 | ranks = ['top_senders', 'top_sender_domains', 113 | 'top_recipients', 'top_recipient_domains'] 114 | for key in ranks: 115 | data[key] = "\n".join(["%-5s %s" % (value[1], value[0]) 116 | for value in data[key]]) 117 | 118 | print(SUMMARY.format(**data)) 119 | exit() 120 | 121 | auto_load = False if args.noautoload else True 122 | 123 | main(store_auto_load=auto_load) 124 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 18 | 19 | help: 20 | @echo "Please use \`make ' where is one of" 21 | @echo " html to make standalone HTML files" 22 | @echo " dirhtml to make HTML files named index.html in directories" 23 | @echo " singlehtml to make a single large HTML file" 24 | @echo " pickle to make pickle files" 25 | @echo " json to make JSON files" 26 | @echo " htmlhelp to make HTML files and a HTML help project" 27 | @echo " qthelp to make HTML files and a qthelp project" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 31 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 32 | @echo " text to make text files" 33 | @echo " man to make manual pages" 34 | @echo " texinfo to make Texinfo files" 35 | @echo " info to make Texinfo files and run them through makeinfo" 36 | @echo " gettext to make PO message catalogs" 37 | @echo " changes to make an overview of all changed/added/deprecated items" 38 | @echo " linkcheck to check all external links for integrity" 39 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 40 | 41 | clean: 42 | -rm -rf $(BUILDDIR)/* 43 | 44 | html: 45 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 46 | @echo 47 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 48 | 49 | dirhtml: 50 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 51 | @echo 52 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 53 | 54 | singlehtml: 55 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 56 | @echo 57 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 58 | 59 | pickle: 60 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 61 | @echo 62 | @echo "Build finished; now you can process the pickle files." 63 | 64 | json: 65 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 66 | @echo 67 | @echo "Build finished; now you can process the JSON files." 68 | 69 | htmlhelp: 70 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 71 | @echo 72 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 73 | ".hhp project file in $(BUILDDIR)/htmlhelp." 74 | 75 | qthelp: 76 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 77 | @echo 78 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 79 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 80 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/PyMailq.qhcp" 81 | @echo "To view the help file:" 82 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/PyMailq.qhc" 83 | 84 | devhelp: 85 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 86 | @echo 87 | @echo "Build finished." 88 | @echo "To view the help file:" 89 | @echo "# mkdir -p $$HOME/.local/share/devhelp/PyMailq" 90 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/PyMailq" 91 | @echo "# devhelp" 92 | 93 | epub: 94 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 95 | @echo 96 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 97 | 98 | latex: 99 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 100 | @echo 101 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 102 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 103 | "(use \`make latexpdf' here to do that automatically)." 104 | 105 | latexpdf: 106 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 107 | @echo "Running LaTeX files through pdflatex..." 108 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 109 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 110 | 111 | text: 112 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 113 | @echo 114 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 115 | 116 | man: 117 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 118 | @echo 119 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 120 | 121 | texinfo: 122 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 123 | @echo 124 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 125 | @echo "Run \`make' in that directory to run these through makeinfo" \ 126 | "(use \`make info' here to do that automatically)." 127 | 128 | info: 129 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 130 | @echo "Running Texinfo files through makeinfo..." 131 | make -C $(BUILDDIR)/texinfo info 132 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 133 | 134 | gettext: 135 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 136 | @echo 137 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 138 | 139 | changes: 140 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 141 | @echo 142 | @echo "The overview file is in $(BUILDDIR)/changes." 143 | 144 | linkcheck: 145 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 146 | @echo 147 | @echo "Link check complete; look for any errors in the above output " \ 148 | "or in $(BUILDDIR)/linkcheck/output.txt." 149 | 150 | doctest: 151 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 152 | @echo "Testing of doctests in the sources finished, look at the " \ 153 | "results in $(BUILDDIR)/doctest/output.txt." 154 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # PyMailq documentation build configuration file, created by 4 | # sphinx-quickstart on Thu Apr 17 19:26:49 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys 15 | import os 16 | 17 | 18 | sys.path.insert(0, os.path.abspath("../pymailq")) 19 | sys.path.insert(0, os.path.abspath("..")) 20 | 21 | # If extensions (or modules to document with autodoc) are in another directory, 22 | # add these directories to sys.path here. If the directory is relative to the 23 | # documentation root, use os.path.abspath to make it absolute, like shown here. 24 | #sys.path.insert(0, os.path.abspath('.')) 25 | 26 | # -- General configuration ----------------------------------------------------- 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be extensions 32 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 33 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.ifconfig', 34 | 'sphinx.ext.viewcode', 'sphinx.ext.intersphinx'] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # The suffix of source filenames. 40 | source_suffix = '.rst' 41 | 42 | # The encoding of source files. 43 | #source_encoding = 'utf-8-sig' 44 | 45 | # The master toctree document. 46 | master_doc = 'index' 47 | 48 | # General information about the project. 49 | project = 'PyMailq' 50 | copyright = '2014, Denis \'jawa\' Pompilio' 51 | 52 | # The version info for the project you're documenting, acts as replacement for 53 | # |version| and |release|, also used in various other places throughout the 54 | # built documents. 55 | # 56 | # The full version, including alpha/beta/rc tags. 57 | release = open(os.path.join(os.path.dirname(__file__), "..", "VERSION")).read() 58 | # The short X.Y version. 59 | version = ".".join(release.split('.')[0:2]) 60 | 61 | # The language for content autogenerated by Sphinx. Refer to documentation 62 | # for a list of supported languages. 63 | #language = None 64 | 65 | # There are two options for replacing |today|: either, you set today to some 66 | # non-false value, then it is used: 67 | #today = '' 68 | # Else, today_fmt is used as the format for a strftime call. 69 | #today_fmt = '%B %d, %Y' 70 | 71 | # List of patterns, relative to source directory, that match files and 72 | # directories to ignore when looking for source files. 73 | exclude_patterns = ['_build'] 74 | 75 | # The reST default role (used for this markup: `text`) to use for all documents. 76 | #default_role = None 77 | 78 | # If true, '()' will be appended to :func: etc. cross-reference text. 79 | #add_function_parentheses = True 80 | 81 | # If true, the current module name will be prepended to all description 82 | # unit titles (such as .. function::). 83 | #add_module_names = True 84 | 85 | # If true, sectionauthor and moduleauthor directives will be shown in the 86 | # output. They are ignored by default. 87 | #show_authors = False 88 | 89 | # The name of the Pygments (syntax highlighting) style to use. 90 | pygments_style = 'sphinx' 91 | 92 | # A list of ignored prefixes for module index sorting. 93 | #modindex_common_prefix = [] 94 | 95 | 96 | # -- Options for HTML output --------------------------------------------------- 97 | 98 | # The theme to use for HTML and HTML Help pages. See the documentation for 99 | # a list of builtin themes. 100 | on_rtd = os.environ.get('READTHEDOCS') == 'True' 101 | if on_rtd: 102 | html_theme = 'default' 103 | else: 104 | html_theme = 'haiku' 105 | 106 | # Theme options are theme-specific and customize the look and feel of a theme 107 | # further. For a list of options available for each theme, see the 108 | # documentation. 109 | #html_theme_options = {} 110 | 111 | # Add any paths that contain custom themes here, relative to this directory. 112 | #html_theme_path = [] 113 | 114 | # The name for this set of Sphinx documents. If None, it defaults to 115 | # " v documentation". 116 | #html_title = None 117 | 118 | # A shorter title for the navigation bar. Default is the same as html_title. 119 | #html_short_title = None 120 | 121 | # The name of an image file (relative to this directory) to place at the top 122 | # of the sidebar. 123 | #html_logo = None 124 | 125 | # The name of an image file (within the static path) to use as favicon of the 126 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 127 | # pixels large. 128 | #html_favicon = None 129 | 130 | # Add any paths that contain custom static files (such as style sheets) here, 131 | # relative to this directory. They are copied after the builtin static files, 132 | # so a file named "default.css" will overwrite the builtin "default.css". 133 | html_static_path = ['static'] 134 | 135 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 136 | # using the given strftime format. 137 | #html_last_updated_fmt = '%b %d, %Y' 138 | 139 | # If true, SmartyPants will be used to convert quotes and dashes to 140 | # typographically correct entities. 141 | #html_use_smartypants = True 142 | 143 | # Custom sidebar templates, maps document names to template names. 144 | #html_sidebars = {} 145 | 146 | # Additional templates that should be rendered to pages, maps page names to 147 | # template names. 148 | #html_additional_pages = {} 149 | 150 | # If false, no module index is generated. 151 | #html_domain_indices = True 152 | 153 | # If false, no index is generated. 154 | #html_use_index = True 155 | 156 | # If true, the index is split into individual pages for each letter. 157 | #html_split_index = False 158 | 159 | # If true, links to the reST sources are added to the pages. 160 | #html_show_sourcelink = True 161 | 162 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 163 | #html_show_sphinx = True 164 | 165 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 166 | #html_show_copyright = True 167 | 168 | # If true, an OpenSearch description file will be output, and all pages will 169 | # contain a tag referring to it. The value of this option must be the 170 | # base URL from which the finished HTML is served. 171 | #html_use_opensearch = '' 172 | 173 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 174 | #html_file_suffix = None 175 | 176 | # Output file base name for HTML help builder. 177 | htmlhelp_basename = 'PyMailqdoc' 178 | 179 | 180 | # -- Options for LaTeX output -------------------------------------------------- 181 | 182 | latex_elements = { 183 | # The paper size ('letterpaper' or 'a4paper'). 184 | #'papersize': 'letterpaper', 185 | 186 | # The font size ('10pt', '11pt' or '12pt'). 187 | #'pointsize': '10pt', 188 | 189 | # Additional stuff for the LaTeX preamble. 190 | #'preamble': '', 191 | } 192 | 193 | # Grouping the document tree into LaTeX files. List of tuples 194 | # (source start file, target name, title, author, documentclass [howto/manual]). 195 | latex_documents = [ 196 | ('index', 'PyMailq.tex', 'PyMailq Documentation', 197 | 'Denis \'jawa\' Pompilio', 'manual'), 198 | ] 199 | 200 | # The name of an image file (relative to this directory) to place at the top of 201 | # the title page. 202 | #latex_logo = None 203 | 204 | # For "manual" documents, if this is true, then toplevel headings are parts, 205 | # not chapters. 206 | #latex_use_parts = False 207 | 208 | # If true, show page references after internal links. 209 | #latex_show_pagerefs = False 210 | 211 | # If true, show URL addresses after external links. 212 | #latex_show_urls = False 213 | 214 | # Documents to append as an appendix to all manuals. 215 | #latex_appendices = [] 216 | 217 | # If false, no module index is generated. 218 | #latex_domain_indices = True 219 | 220 | 221 | # -- Options for manual page output -------------------------------------------- 222 | 223 | # One entry per manual page. List of tuples 224 | # (source start file, name, description, authors, manual section). 225 | man_pages = [( 226 | 'pqshell', 'pqshell', 227 | 'A shell-like to interact with a Postfix mails queue', 228 | ['Denis Pompilio (jawa) '], 1 229 | )] 230 | 231 | # If true, show URL addresses after external links. 232 | #man_show_urls = False 233 | 234 | 235 | # -- Options for Texinfo output ------------------------------------------------ 236 | 237 | # Grouping the document tree into Texinfo files. List of tuples 238 | # (source start file, target name, title, author, 239 | # dir menu entry, description, category) 240 | texinfo_documents = [ 241 | ('index', 'PyMailq', 'PyMailq Documentation', 242 | 'Denis \'jawa\' Pompilio', 'PyMailq', 'One line description of project.', 243 | 'Miscellaneous'), 244 | ] 245 | 246 | # Documents to append as an appendix to all manuals. 247 | #texinfo_appendices = [] 248 | 249 | # If false, no module index is generated. 250 | #texinfo_domain_indices = True 251 | 252 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 253 | #texinfo_show_urls = 'footnote' 254 | 255 | # intersphinx mapping to docs.python.org 256 | intersphinx_mapping = {'python': ('http://docs.python.org/2.7', None)} 257 | -------------------------------------------------------------------------------- /docs/config.rst: -------------------------------------------------------------------------------- 1 | .. _pymailq-configuration: 2 | 3 | pymailq.CONFIG -- Configuration structure and usage 4 | =================================================== 5 | 6 | PyMailq module takes an optional `.ini` configuration file. 7 | 8 | Section: core 9 | ------------- 10 | 11 | - ``postfix_spool`` 12 | Path to postfix spool (defaults to `/var/spool/postfix`) 13 | 14 | Section: commands 15 | ----------------- 16 | 17 | - ``use_sudo`` (yes|no) 18 | Control the use of sudo to invoke commands (default: `yes`) 19 | - ``list_queue`` 20 | Command to list messages queue (default: `mailq`) 21 | - ``cat_message`` 22 | Command to cat message (default: `postcat -qv`) 23 | - ``hold_message`` 24 | Command to hold message (default: `postsuper -h`) 25 | - ``release_message`` 26 | Command to release message (default: `postsuper -H`) 27 | - ``requeue_message`` 28 | Command to requeue message (default: `postsuper -r`) 29 | - ``delete_message`` 30 | Command to delete message (default: `postsuper -d`) 31 | 32 | Example 33 | ------- 34 | 35 | **pymailq.ini**:: 36 | 37 | [core] 38 | postfix_spool = /var/spool/postfix 39 | 40 | [commands] 41 | use_sudo = yes 42 | list_queue = mailq 43 | cat_message = postcat -qv 44 | hold_message = postsuper -h 45 | release_message = postsuper -H 46 | requeue_message = postsuper -r 47 | delete_message = postsuper -d 48 | -------------------------------------------------------------------------------- /docs/control.rst: -------------------------------------------------------------------------------- 1 | pymailq.control -- Mails queue adminitrative operations 2 | ======================================================= 3 | 4 | The :mod:`control` module define a basic python class to simplify 5 | administrative operations against the mails queue. This module is mainly based 6 | on the `postsuper`_ administrative tool functionnalities. 7 | 8 | :class:`~control.QueueControl` Objects 9 | -------------------------------------- 10 | 11 | .. autoclass:: control.QueueControl() 12 | 13 | The :class:`~control.QueueControl` instance provides the following methods: 14 | 15 | .. automethod:: control.QueueControl.get_operation_cmd 16 | .. automethod:: control.QueueControl._operate 17 | .. automethod:: control.QueueControl.delete_messages 18 | .. automethod:: control.QueueControl.hold_messages 19 | .. automethod:: control.QueueControl.release_messages 20 | .. automethod:: control.QueueControl.requeue_messages 21 | 22 | .. External links for documentation 23 | .. _postsuper: http://www.postfix.org/postsuper.1.html 24 | -------------------------------------------------------------------------------- /docs/docs.requirements.txt: -------------------------------------------------------------------------------- 1 | Sphinx 2 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | pymailq -- Simple Postfix queue management 2 | ========================================== 3 | 4 | .. module:: pymailq 5 | :synopsis: Simple Postfix queue management XXX. 6 | .. moduleauthor:: Denis 'jawa' Pompilio 7 | .. sectionauthor:: Denis 'jawa' Pompilio 8 | 9 | The :mod:`pymailq` package makes it easy to view and control Postfix mails 10 | queue. It provide several classes to store, view and interact with mail queue 11 | using Postfix command line tools. This module is provided for automation and 12 | monitoring. 13 | 14 | The :mod:`pymailq` package defines the following attribute: 15 | 16 | .. autodata:: DEBUG 17 | .. autodata:: VERSION 18 | .. autodata:: CONFIG 19 | :annotation: = dict() 20 | 21 | The :mod:`pymailq` package defines the following decorators: 22 | 23 | .. autofunction:: debug(function) 24 | .. autofunction:: load_config(cfg_file) 25 | 26 | The :mod:`pymailq` package provides the following submodules: 27 | 28 | .. toctree:: 29 | :maxdepth: 1 30 | 31 | store 32 | selector 33 | control 34 | shell 35 | config 36 | pqshell 37 | -------------------------------------------------------------------------------- /docs/pqshell.rst: -------------------------------------------------------------------------------- 1 | pqshell -- A shell-like to interact with a Postfix mails queue 2 | ============================================================== 3 | 4 | DESCRIPTION 5 | *********** 6 | 7 | pqshell is a shell-like to interact with Postfix mails queue. It provide simple 8 | means to view the queue content, filter mails on criterias like `Sender` or 9 | `delivery errors` and lead administrative operations. 10 | 11 | SYNOPSIS 12 | ******** 13 | 14 | :: 15 | 16 | pqshell [OPTION]... 17 | 18 | FEATURES 19 | ******** 20 | 21 | - Asynchronous interactions with Postfix mails queue. 22 | - Mails filtering on various criterias. 23 | - Administrative operations on mails queue 24 | - History and autocomplete via readline, if installed. 25 | 26 | OPTIONS 27 | ******* 28 | 29 | -h, --help show help message and exit 30 | --version show shell version and exit 31 | --debug activate shell debug and timing info 32 | --config CFG_FILE specify a configuration file for PyMailq 33 | --summary show mails queue summary and exit 34 | --no-auto-load deactivate store auto load at shell startup 35 | 36 | SHELL COMMANDS 37 | ************** 38 | 39 | An inside help is available with the help command. Each provided command takes 40 | subcommands and command's help can be obtained while running it without 41 | argument. 42 | 43 | store 44 | ----- 45 | 46 | Control of Postfix queue content storage 47 | 48 | **Subcommands**: 49 | 50 | **status** 51 | Show store status. 52 | 53 | **load** 54 | Load Postfix queue content. 55 | 56 | **Example**:: 57 | 58 | PyMailq (sel:0)> store status 59 | store is not loaded 60 | PyMailq (sel:0)> store load 61 | 590 mails loaded from queue 62 | PyMailq (sel:590)> store status 63 | store loaded with 590 mails at 2014-05-05 13:43:22.592767 64 | 65 | select 66 | ------ 67 | 68 | Select mails from Postfix queue content. Filters are cumulatives and 69 | designed to simply implement advanced filtering with simple syntax. The 70 | default prompt will show how many mails are currently selected by all 71 | applied filters. Order of filters application is also important. 72 | 73 | **Subcommands**: 74 | 75 | **qids** 76 | Select mails by queue IDs. 77 | 78 | Usage: ``select qids [qid] ...`` 79 | 80 | **date** 81 | Select mails by date. 82 | 83 | Usage: ``select date `` 84 | 85 | Where `` can be:: 86 | 87 | YYYY-MM-DD (exact date) 88 | YYYY-MM-DD..YYYY-MM-DD (within a date range (included)) 89 | +YYYY-MM-DD (after a date (included)) 90 | -YYYY-MM-DD (before a date (included)) 91 | 92 | **error** 93 | Select mails by error message. Specified error message can be 94 | partial and will be check against the whole error message. 95 | 96 | Usage: ``select error `` 97 | 98 | **replay** 99 | Reset content of selector with store content and replay filters. 100 | 101 | **reset** 102 | Reset content of selector with store content, remove filters. 103 | 104 | **rmfilter** 105 | Remove filter previously applied. Filters ids are used to specify 106 | filter to remove. 107 | 108 | Usage: ``select rmfilter `` 109 | 110 | **sender** 111 | Select mails from sender. 112 | 113 | Usage: ``select sender [exact]`` 114 | 115 | **recipient** 116 | Select mails to recipient. 117 | 118 | Usage: ``select recipient [exact]`` 119 | 120 | **size** 121 | Select mails by size in Bytes. Signs - and + are supported, if not 122 | specified, search for exact size. Size range is allowed by 123 | using ``-`` (lesser than) and ``+`` (greater than). 124 | 125 | Usage: ``select size <-n|n|+n> [-n]`` 126 | 127 | **status** 128 | Select mails with specific postfix status. 129 | 130 | Usage: ``select status `` 131 | 132 | **Filtering Example**:: 133 | 134 | PyMailq (sel:608)> select size -5000 135 | PyMailq (sel:437)> select sender MAILER-DAEMON 136 | PyMailq (sel:316)> select status active 137 | PyMailq (sel:0)> 138 | 139 | **Filters management**:: 140 | 141 | PyMailq (sel:608)> select size -5000 142 | PyMailq (sel:437)> select sender MAILER-DAEMON 143 | PyMailq (sel:316)> show filters 144 | 0: select size: 145 | smax: 5000 146 | smin: 0 147 | 1: select sender: 148 | partial: True 149 | sender: MAILER-DAEMON 150 | PyMailq (sel:316)> select rmfilter 1 151 | PyMailq (sel:437)> select sender greedy-sender@domain.com 152 | PyMailq (sel:25)> select reset 153 | Selector resetted with store content (608 mails) 154 | PyMailq (sel:608)> 155 | 156 | inspect 157 | ------- 158 | 159 | Display mails content. 160 | 161 | **Subcommands:** 162 | 163 | **mails:** 164 | Show mails most common fields content including by not limited to 165 | `From`, `To`, `Subject`, `Received`, ... This command parses mails 166 | content and requires specific privileges or the use of `sudo` in 167 | configuration. 168 | 169 | Usage: ``inspect mails [qid] ...`` 170 | 171 | show 172 | ---- 173 | 174 | Display the content of current mails selection or specific mail IDs. 175 | Modifiers have been implemented to allow quick output manipulation. These 176 | allow you to sort, limit or even output a ranking by specific field. By 177 | default, output is sorted by **date of acceptance** in queue. 178 | 179 | **Optionnal modifiers** can be provided to alter output: 180 | ``limit `` 181 | Display the first n entries. 182 | 183 | ``sortby [asc|desc]`` 184 | Sort output by field asc or desc. Default sorting is made 185 | descending. 186 | 187 | ``rankby `` 188 | Produce mails ranking by field. 189 | 190 | **Known fields:** 191 | 192 | * ``qid`` -- Postqueue mail ID. 193 | * ``date`` -- Mail date. 194 | * ``sender`` -- Mail sender. 195 | * ``recipients`` -- Mail recipients (list, no sort). 196 | * ``size`` -- Mail size. 197 | * ``errors`` -- Postqueue deferred error messages (list, no sort). 198 | 199 | **Output formatting:** 200 | 201 | * ``brief`` -- Default single line output to display selection 202 | * ``long`` -- Long format to also display errors and recipients 203 | 204 | **Subcommands:** 205 | 206 | **filters** 207 | Show filters applied on current mails selection. 208 | 209 | Usage: ``show filters`` 210 | 211 | **selected** 212 | Show selected mails. 213 | 214 | Usage: ``show selected [modifiers]`` 215 | 216 | **Example**:: 217 | 218 | PyMailq (sel:608)> show selected limit 5 219 | 2014-05-05 20:54:24 699C11831669 [active] jjj@dom1.com (14375B) 220 | 2014-05-05 20:43:39 8D60C13C14C6 [deferred] bbb@dom9.com (39549B) 221 | 2014-05-05 20:35:08 B0077198BC31 [deferred] rrr@dom2.com (4809B) 222 | 2014-05-05 20:30:09 014E21AB4B78 [deferred] aaa@dom7.com (2450B) 223 | 2014-05-05 20:25:04 CF1BE127A8D3 [deferred] xxx@dom2.com (4778B) 224 | ...Preview of first 5 (603 more)... 225 | PyMailq (sel:608)> show selected sortby sender limit 5 asc 226 | 2014-05-02 11:36:16 40AA9149A9D7 [deferred] aaa@dom1.com (8262B) 227 | 2014-05-01 05:30:23 5E0B2162BE63 [deferred] bbb@dom4.com (3052B) 228 | 2014-05-02 05:30:20 653471AC5F76 [deferred] ccc@dom5.com (3052B) 229 | 2014-05-02 09:49:01 A00D3159AEE [deferred] ddd@dom1.com (3837B) 230 | 2014-05-05 18:18:59 98E9A790749 [deferred] ddd@dom2.com (1551B) 231 | ...Preview of first 5 (603 more)... 232 | PyMailq (sel:608)> show selected rankby sender limit 5 233 | sender count 234 | ================================================ 235 | jjj@dom8.com 334 236 | xxx@dom4.com 43 237 | nnn@dom1.com 32 238 | ccc@dom3.com 14 239 | sss@dom5.com 13 240 | ...Preview of first 5 (64 more)... 241 | 242 | -------------------------------------------------------------------------------- /docs/selector.rst: -------------------------------------------------------------------------------- 1 | pymailq.selector -- Mails queue filtering 2 | ========================================= 3 | 4 | The :mod:`selector` module mainly provide a selector class to interact with 5 | structures from the :mod:`store` module. 6 | 7 | :class:`~selector.MailSelector` Objects 8 | --------------------------------------- 9 | 10 | .. autoclass:: selector.MailSelector(store) 11 | 12 | The :class:`~selector.MailSelector` instance provides the following methods: 13 | 14 | .. automethod:: selector.MailSelector.filter_registration 15 | .. automethod:: selector.MailSelector.reset 16 | .. automethod:: selector.MailSelector.replay_filters 17 | .. automethod:: selector.MailSelector.get_mails_by_qids 18 | .. automethod:: selector.MailSelector.lookup_qids 19 | .. automethod:: selector.MailSelector.lookup_status 20 | .. automethod:: selector.MailSelector.lookup_sender 21 | .. automethod:: selector.MailSelector.lookup_recipient 22 | .. automethod:: selector.MailSelector.lookup_error 23 | .. automethod:: selector.MailSelector.lookup_date 24 | .. automethod:: selector.MailSelector.lookup_size 25 | -------------------------------------------------------------------------------- /docs/shell.rst: -------------------------------------------------------------------------------- 1 | pymailq.shell -- Mails queue management shell 2 | ============================================= 3 | 4 | .. module:: pymailq.shell 5 | :synopsis: Mails queue management shell. 6 | .. moduleauthor:: Denis 'jawa' Pompilio 7 | .. sectionauthor:: Denis 'jawa' Pompilio 8 | 9 | The :mod:`pymailq.shell` module provide a shell to view and control Postfix 10 | mails queue. More documentation will soon be available. 11 | -------------------------------------------------------------------------------- /docs/store.rst: -------------------------------------------------------------------------------- 1 | pymailq.store -- Mails queue storage objects 2 | ============================================ 3 | 4 | The :mod:`store` module provide several objects to convert mails queue content 5 | into python structures. 6 | 7 | :class:`~store.PostqueueStore` Objects 8 | -------------------------------------- 9 | 10 | .. autoclass:: pymailq.store.PostqueueStore() 11 | 12 | The :class:`~store.PostqueueStore` instance provides the following methods: 13 | 14 | .. automethod:: store.PostqueueStore.load([method]) 15 | .. automethod:: store.PostqueueStore._load_from_postqueue() 16 | .. automethod:: store.PostqueueStore._load_from_spool() 17 | .. automethod:: store.PostqueueStore._get_postqueue_output() 18 | .. automethod:: store.PostqueueStore._is_mail_id(mail_id) 19 | .. automethod:: store.PostqueueStore.summary() 20 | 21 | :class:`~store.Mail` Objects 22 | ---------------------------- 23 | 24 | .. autoclass:: pymailq.store.Mail(mail_id[, size[, date[, sender]]]) 25 | 26 | The :class:`~store.Mail` instance provides the following methods: 27 | 28 | .. automethod:: store.Mail.parse() 29 | .. automethod:: store.Mail.dump() 30 | .. automethod:: store.Mail.show() 31 | 32 | :class:`~store.MailHeaders` Objects 33 | ----------------------------------- 34 | 35 | .. autoclass:: store.MailHeaders() 36 | 37 | 38 | .. External links for documentation 39 | .. _postqueue: http://www.postfix.org/postqueue.1.html 40 | .. _postcat: http://www.postfix.org/postcat.1.html 41 | -------------------------------------------------------------------------------- /helpers/make_deb_package.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | PACKAGING_DIR="packaging" 4 | 5 | requirements=" 6 | debhelper 7 | devscripts 8 | build-essential 9 | python-support 10 | python-all 11 | python3-all 12 | " 13 | 14 | change_file="pymailq_*.changes" 15 | packages="python-pymailq python3-pymailq" 16 | 17 | # Checking requirements 18 | dpkg_output=$(dpkg -l $requirements) 19 | grep -q '^un' <<< "$dpkg_output" && { 20 | read -p "Missing packages, May I install those? (yes|no) " answer 21 | [ "$answer" == "yes" ] || { 22 | echo "Build of debian package aborted." 23 | exit 1 24 | } 25 | sudo apt-get install -qq $requirements 26 | } 27 | 28 | [ -d "$PACKAGING_DIR" ] || { echo "Packaging directory not found."; exit 2; } 29 | cd "$PACKAGING_DIR" 30 | 31 | # Preparing build directory 32 | [ -d build ] && { echo "Cleaning build directory."; rm -r build; } 33 | echo "Preparing build directory." 34 | mkdir build && cp -a debian build/ && 35 | [ -d build ] || { echo "Fail to prepare build directory."; exit 2; } 36 | 37 | # Building binary package 38 | (cd build && dpkg-buildpackage -us -uc -b) || { 39 | echo "Failed to build package." 40 | exit 2 41 | } 42 | 43 | # Verify your build 44 | echo "Running lintian and inspecting package content." 45 | lintian -IviE --display-experimental --pedantic -L ">=wishlist" \ 46 | --color auto --show-overrides --checksums --profile debian \ 47 | $change_file 48 | 49 | echo "Showing packages infos and content." 50 | for pkg in $packages; do 51 | dpkg-deb -I ${pkg}_*.deb 52 | dpkg-deb -c ${pkg}_*.deb 53 | done 54 | -------------------------------------------------------------------------------- /helpers/make_manpages.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # You may validate your documentation with: 4 | # (cd docs && make html && xdg-open _build/html/index.html) 5 | 6 | DOCS_DIR="docs" 7 | MAN_DIR="man" 8 | 9 | (cd "$DOCS_DIR" && make man) 10 | cp "$DOCS_DIR/_build/man/"* "$MAN_DIR/" 11 | -------------------------------------------------------------------------------- /helpers/make_pip_sdist.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | usage() { 4 | echo "Usage: $0 [-u]"; 5 | exit 1 6 | } 7 | 8 | make_sdist() { 9 | python setup.py sdist 10 | } 11 | 12 | sign_sdist() { 13 | gpg --detach-sign -a dist/${1}-${2}.tar.gz 14 | } 15 | 16 | upload_sdist() { 17 | twine upload dist/${1}-${2}.tar.gz* 18 | } 19 | 20 | # --- Main --- 21 | 22 | pkg=pymailq 23 | version=`cat VERSION` 24 | upload=false 25 | 26 | while getopts :uh opt; do 27 | case "$opt" in 28 | h) usage ;; 29 | u) upload=true ;; 30 | \?) echo "Unknown option $OPTARG"; exit 2 ;; 31 | :) echo "Option -$OPTARG requires an argument."; exit 2 ;; 32 | esac 33 | done 34 | 35 | read -p "Build sdist for $pkg v$version ? (^C to abort)" 36 | 37 | make_sdist 38 | sign_sdist "$pkg" "$version" 39 | 40 | $upload && { 41 | read -p "Upload sdist ${pkg}-${version} ? (^C to abort)" 42 | upload_sdist "$pkg" "$version" 43 | } 44 | 45 | echo "All done." -------------------------------------------------------------------------------- /helpers/update_deb_changelog.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | PACKAGING_DIR="packaging" 4 | 5 | VERSION=$(cat "VERSION" 2>/dev/null) 6 | CHANGES="CHANGES" 7 | DEB_DISTRO="unstable" 8 | 9 | get_changes() { 10 | sed -n ' 11 | /=== v'$1' /{ 12 | :getchange 13 | s/.*//;n 14 | /^\s*$/q 15 | s/^\s*[*-]\s\+\(.*\)/\1/p 16 | bgetchange 17 | } 18 | ' "$2" 19 | } 20 | 21 | [ -z "$VERSION" ] && { echo "Unable to get current version."; exit 2; } 22 | [ -f "$CHANGES" ] || { echo "File $CHANGES not found."; exit 2; } 23 | [ -d "$PACKAGING_DIR" ] || { echo "Packaging directory not found."; exit 2; } 24 | cd "$PACKAGING_DIR" 25 | 26 | read -p "Updating changelog with CHANGES content. 27 | Don't forget to export DEB variables: 28 | DEBEMAIL='${DEBEMAIL}' 29 | DEBFULLNAME='${DEBFULLNAME}' 30 | proceed ? (^C to abort)" 31 | 32 | debchange -D "$DEB_DISTRO" -u low -i "Update to upstream version v$VERSION" 33 | get_changes "$VERSION" "../$CHANGES" | while read changeline; do 34 | echo "Adding change: $changeline" 35 | debchange -a "$changeline" 36 | done 37 | debchange -r 38 | -------------------------------------------------------------------------------- /helpers/update_version.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | 4 | VERSION_FILE="VERSION" 5 | LIB_FILE="pymailq/__init__.py" 6 | SETUP_FILE="setup.py" 7 | CHANGES_FILE="CHANGES" 8 | 9 | 10 | update_master_version() { 11 | echo "Updating master version: $VERSION_FILE" 12 | echo $1 > $VERSION_FILE 13 | } 14 | 15 | update_lib_version() { 16 | echo "Updating libraries: $LIB_FILE" 17 | sed -i "s/^VERSION\s*=\s*.*/VERSION = \"$1\"/" $LIB_FILE 18 | } 19 | 20 | update_setup_version() { 21 | echo "Updating setup: $SETUP_FILE" 22 | sed -i "s/release\s*=\s*\".*\"/release = \"$1\"/" $SETUP_FILE 23 | } 24 | 25 | create_changes_entry() { 26 | echo "Creating v$1 entry in: $CHANGES_FILE" 27 | grep -q "=== v$1 " $CHANGES_FILE && { 28 | echo "Changes have already an entry for version $1" 29 | return 30 | } 31 | today=$(date "+%d/%m/%Y") 32 | sed -i ' 33 | 0,/^=== v/{ 34 | /^=== v/i=== v'$1' '$today' ===\n * Document your changes\n 35 | } 36 | ' $CHANGES_FILE 37 | echo "Don't forget to fill new entry with changes." 38 | } 39 | 40 | 41 | # --- Main --- 42 | [ $# -eq 0 ] && { echo "Usage: $0 -v "; exit 1; } 43 | while getopts :v: opt; do 44 | case "$opt" in 45 | v) version=$OPTARG ;; 46 | \?) echo "Unknown option $OPTARG"; exit 2 ;; 47 | :) echo "Option -$OPTARG requires an argument."; exit 2 ;; 48 | esac 49 | done 50 | 51 | echo "Updating to version $version" 52 | update_master_version $version 53 | update_lib_version $version 54 | update_setup_version $version 55 | create_changes_entry $version 56 | -------------------------------------------------------------------------------- /man/pqshell.1: -------------------------------------------------------------------------------- 1 | .\" Man page generated from reStructuredText. 2 | . 3 | .TH "PQSHELL" "1" "Sep 07, 2017" "0.9" "PyMailq" 4 | .SH NAME 5 | pqshell \- A shell-like to interact with a Postfix mails queue 6 | . 7 | .nr rst2man-indent-level 0 8 | . 9 | .de1 rstReportMargin 10 | \\$1 \\n[an-margin] 11 | level \\n[rst2man-indent-level] 12 | level margin: \\n[rst2man-indent\\n[rst2man-indent-level]] 13 | - 14 | \\n[rst2man-indent0] 15 | \\n[rst2man-indent1] 16 | \\n[rst2man-indent2] 17 | .. 18 | .de1 INDENT 19 | .\" .rstReportMargin pre: 20 | . RS \\$1 21 | . nr rst2man-indent\\n[rst2man-indent-level] \\n[an-margin] 22 | . nr rst2man-indent-level +1 23 | .\" .rstReportMargin post: 24 | .. 25 | .de UNINDENT 26 | . RE 27 | .\" indent \\n[an-margin] 28 | .\" old: \\n[rst2man-indent\\n[rst2man-indent-level]] 29 | .nr rst2man-indent-level -1 30 | .\" new: \\n[rst2man-indent\\n[rst2man-indent-level]] 31 | .in \\n[rst2man-indent\\n[rst2man-indent-level]]u 32 | .. 33 | .SH DESCRIPTION 34 | .sp 35 | pqshell is a shell\-like to interact with Postfix mails queue. It provide simple 36 | means to view the queue content, filter mails on criterias like \fISender\fP or 37 | \fIdelivery errors\fP and lead administrative operations. 38 | .SH SYNOPSIS 39 | .INDENT 0.0 40 | .INDENT 3.5 41 | .sp 42 | .nf 43 | .ft C 44 | pqshell [OPTION]... 45 | .ft P 46 | .fi 47 | .UNINDENT 48 | .UNINDENT 49 | .SH FEATURES 50 | .INDENT 0.0 51 | .IP \(bu 2 52 | Asynchronous interactions with Postfix mails queue. 53 | .IP \(bu 2 54 | Mails filtering on various criterias. 55 | .IP \(bu 2 56 | Administrative operations on mails queue 57 | .IP \(bu 2 58 | History and autocomplete via readline, if installed. 59 | .UNINDENT 60 | .SH OPTIONS 61 | .INDENT 0.0 62 | .INDENT 3.5 63 | .INDENT 0.0 64 | .TP 65 | .B \-h\fP,\fB \-\-help 66 | show help message and exit 67 | .TP 68 | .B \-\-version 69 | show shell version and exit 70 | .TP 71 | .B \-\-debug 72 | activate shell debug and timing info 73 | .TP 74 | .BI \-\-config \ CFG_FILE 75 | specify a configuration file for PyMailq 76 | .TP 77 | .B \-\-summary 78 | show mails queue summary and exit 79 | .TP 80 | .B \-\-no\-auto\-load 81 | deactivate store auto load at shell startup 82 | .UNINDENT 83 | .UNINDENT 84 | .UNINDENT 85 | .SH SHELL COMMANDS 86 | .sp 87 | An inside help is available with the help command. Each provided command takes 88 | subcommands and command’s help can be obtained while running it without 89 | argument. 90 | .SS store 91 | .INDENT 0.0 92 | .INDENT 3.5 93 | Control of Postfix queue content storage 94 | .sp 95 | \fBSubcommands\fP: 96 | .INDENT 0.0 97 | .INDENT 3.5 98 | .INDENT 0.0 99 | .TP 100 | \fBstatus\fP 101 | Show store status. 102 | .TP 103 | \fBload\fP 104 | Load Postfix queue content. 105 | .UNINDENT 106 | .UNINDENT 107 | .UNINDENT 108 | .sp 109 | \fBExample\fP: 110 | .INDENT 0.0 111 | .INDENT 3.5 112 | .sp 113 | .nf 114 | .ft C 115 | PyMailq (sel:0)> store status 116 | store is not loaded 117 | PyMailq (sel:0)> store load 118 | 590 mails loaded from queue 119 | PyMailq (sel:590)> store status 120 | store loaded with 590 mails at 2014\-05\-05 13:43:22.592767 121 | .ft P 122 | .fi 123 | .UNINDENT 124 | .UNINDENT 125 | .UNINDENT 126 | .UNINDENT 127 | .SS select 128 | .INDENT 0.0 129 | .INDENT 3.5 130 | Select mails from Postfix queue content. Filters are cumulatives and 131 | designed to simply implement advanced filtering with simple syntax. The 132 | default prompt will show how many mails are currently selected by all 133 | applied filters. Order of filters application is also important. 134 | .sp 135 | \fBSubcommands\fP: 136 | .INDENT 0.0 137 | .INDENT 3.5 138 | .INDENT 0.0 139 | .TP 140 | \fBqids\fP 141 | Select mails by queue IDs. 142 | .sp 143 | Usage: \fBselect qids [qid] ...\fP 144 | .TP 145 | \fBdate\fP 146 | Select mails by date. 147 | .sp 148 | Usage: \fBselect date \fP 149 | .sp 150 | Where \fI\fP can be: 151 | .INDENT 7.0 152 | .INDENT 3.5 153 | .sp 154 | .nf 155 | .ft C 156 | YYYY\-MM\-DD (exact date) 157 | YYYY\-MM\-DD..YYYY\-MM\-DD (within a date range (included)) 158 | +YYYY\-MM\-DD (after a date (included)) 159 | \-YYYY\-MM\-DD (before a date (included)) 160 | .ft P 161 | .fi 162 | .UNINDENT 163 | .UNINDENT 164 | .TP 165 | \fBerror\fP 166 | Select mails by error message. Specified error message can be 167 | partial and will be check against the whole error message. 168 | .sp 169 | Usage: \fBselect error \fP 170 | .TP 171 | \fBreplay\fP 172 | Reset content of selector with store content and replay filters. 173 | .TP 174 | \fBreset\fP 175 | Reset content of selector with store content, remove filters. 176 | .TP 177 | \fBrmfilter\fP 178 | Remove filter previously applied. Filters ids are used to specify 179 | filter to remove. 180 | .sp 181 | Usage: \fBselect rmfilter \fP 182 | .TP 183 | \fBsender\fP 184 | Select mails from sender. 185 | .sp 186 | Usage: \fBselect sender [exact]\fP 187 | .TP 188 | \fBrecipient\fP 189 | Select mails to recipient. 190 | .sp 191 | Usage: \fBselect recipient [exact]\fP 192 | .TP 193 | \fBsize\fP 194 | Select mails by size in Bytes. Signs \- and + are supported, if not 195 | specified, search for exact size. Size range is allowed by 196 | using \fB\-\fP (lesser than) and \fB+\fP (greater than). 197 | .sp 198 | Usage: \fBselect size <\-n|n|+n> [\-n]\fP 199 | .TP 200 | \fBstatus\fP 201 | Select mails with specific postfix status. 202 | .sp 203 | Usage: \fBselect status \fP 204 | .UNINDENT 205 | .UNINDENT 206 | .UNINDENT 207 | .sp 208 | \fBFiltering Example\fP: 209 | .INDENT 0.0 210 | .INDENT 3.5 211 | .sp 212 | .nf 213 | .ft C 214 | PyMailq (sel:608)> select size \-5000 215 | PyMailq (sel:437)> select sender MAILER\-DAEMON 216 | PyMailq (sel:316)> select status active 217 | PyMailq (sel:0)> 218 | .ft P 219 | .fi 220 | .UNINDENT 221 | .UNINDENT 222 | .sp 223 | \fBFilters management\fP: 224 | .INDENT 0.0 225 | .INDENT 3.5 226 | .sp 227 | .nf 228 | .ft C 229 | PyMailq (sel:608)> select size \-5000 230 | PyMailq (sel:437)> select sender MAILER\-DAEMON 231 | PyMailq (sel:316)> show filters 232 | 0: select size: 233 | smax: 5000 234 | smin: 0 235 | 1: select sender: 236 | partial: True 237 | sender: MAILER\-DAEMON 238 | PyMailq (sel:316)> select rmfilter 1 239 | PyMailq (sel:437)> select sender greedy\-sender@domain.com 240 | PyMailq (sel:25)> select reset 241 | Selector resetted with store content (608 mails) 242 | PyMailq (sel:608)> 243 | .ft P 244 | .fi 245 | .UNINDENT 246 | .UNINDENT 247 | .UNINDENT 248 | .UNINDENT 249 | .SS inspect 250 | .INDENT 0.0 251 | .INDENT 3.5 252 | Display mails content. 253 | .sp 254 | \fBSubcommands:\fP 255 | .INDENT 0.0 256 | .INDENT 3.5 257 | .INDENT 0.0 258 | .TP 259 | \fBmails:\fP 260 | Show mails most common fields content including by not limited to 261 | \fIFrom\fP, \fITo\fP, \fISubject\fP, \fIReceived\fP, … This command parses mails 262 | content and requires specific privileges or the use of \fIsudo\fP in 263 | configuration. 264 | .sp 265 | Usage: \fBinspect mails [qid] ...\fP 266 | .UNINDENT 267 | .UNINDENT 268 | .UNINDENT 269 | .UNINDENT 270 | .UNINDENT 271 | .SS show 272 | .INDENT 0.0 273 | .INDENT 3.5 274 | Display the content of current mails selection or specific mail IDs. 275 | Modifiers have been implemented to allow quick output manipulation. These 276 | allow you to sort, limit or even output a ranking by specific field. By 277 | default, output is sorted by \fBdate of acceptance\fP in queue. 278 | .INDENT 0.0 279 | .TP 280 | \fBOptionnal modifiers\fP can be provided to alter output: 281 | .INDENT 7.0 282 | .TP 283 | .B \fBlimit \fP 284 | Display the first n entries. 285 | .TP 286 | .B \fBsortby [asc|desc]\fP 287 | Sort output by field asc or desc. Default sorting is made 288 | descending. 289 | .TP 290 | .B \fBrankby \fP 291 | Produce mails ranking by field. 292 | .UNINDENT 293 | .UNINDENT 294 | .sp 295 | \fBKnown fields:\fP 296 | .INDENT 0.0 297 | .INDENT 3.5 298 | .INDENT 0.0 299 | .IP \(bu 2 300 | \fBqid\fP – Postqueue mail ID. 301 | .IP \(bu 2 302 | \fBdate\fP – Mail date. 303 | .IP \(bu 2 304 | \fBsender\fP – Mail sender. 305 | .IP \(bu 2 306 | \fBrecipients\fP – Mail recipients (list, no sort). 307 | .IP \(bu 2 308 | \fBsize\fP – Mail size. 309 | .IP \(bu 2 310 | \fBerrors\fP – Postqueue deferred error messages (list, no sort). 311 | .UNINDENT 312 | .UNINDENT 313 | .UNINDENT 314 | .sp 315 | \fBOutput formatting:\fP 316 | .INDENT 0.0 317 | .INDENT 3.5 318 | .INDENT 0.0 319 | .IP \(bu 2 320 | \fBbrief\fP – Default single line output to display selection 321 | .IP \(bu 2 322 | \fBlong\fP – Long format to also display errors and recipients 323 | .UNINDENT 324 | .UNINDENT 325 | .UNINDENT 326 | .sp 327 | \fBSubcommands:\fP 328 | .INDENT 0.0 329 | .INDENT 3.5 330 | .INDENT 0.0 331 | .TP 332 | \fBfilters\fP 333 | Show filters applied on current mails selection. 334 | .sp 335 | Usage: \fBshow filters\fP 336 | .TP 337 | \fBselected\fP 338 | Show selected mails. 339 | .sp 340 | Usage: \fBshow selected [modifiers]\fP 341 | .UNINDENT 342 | .UNINDENT 343 | .UNINDENT 344 | .sp 345 | \fBExample\fP: 346 | .INDENT 0.0 347 | .INDENT 3.5 348 | .sp 349 | .nf 350 | .ft C 351 | PyMailq (sel:608)> show selected limit 5 352 | 2014\-05\-05 20:54:24 699C11831669 [active] jjj@dom1.com (14375B) 353 | 2014\-05\-05 20:43:39 8D60C13C14C6 [deferred] bbb@dom9.com (39549B) 354 | 2014\-05\-05 20:35:08 B0077198BC31 [deferred] rrr@dom2.com (4809B) 355 | 2014\-05\-05 20:30:09 014E21AB4B78 [deferred] aaa@dom7.com (2450B) 356 | 2014\-05\-05 20:25:04 CF1BE127A8D3 [deferred] xxx@dom2.com (4778B) 357 | \&...Preview of first 5 (603 more)... 358 | PyMailq (sel:608)> show selected sortby sender limit 5 asc 359 | 2014\-05\-02 11:36:16 40AA9149A9D7 [deferred] aaa@dom1.com (8262B) 360 | 2014\-05\-01 05:30:23 5E0B2162BE63 [deferred] bbb@dom4.com (3052B) 361 | 2014\-05\-02 05:30:20 653471AC5F76 [deferred] ccc@dom5.com (3052B) 362 | 2014\-05\-02 09:49:01 A00D3159AEE [deferred] ddd@dom1.com (3837B) 363 | 2014\-05\-05 18:18:59 98E9A790749 [deferred] ddd@dom2.com (1551B) 364 | \&...Preview of first 5 (603 more)... 365 | PyMailq (sel:608)> show selected rankby sender limit 5 366 | sender count 367 | ================================================ 368 | jjj@dom8.com 334 369 | xxx@dom4.com 43 370 | nnn@dom1.com 32 371 | ccc@dom3.com 14 372 | sss@dom5.com 13 373 | \&...Preview of first 5 (64 more)... 374 | .ft P 375 | .fi 376 | .UNINDENT 377 | .UNINDENT 378 | .UNINDENT 379 | .UNINDENT 380 | .SH AUTHOR 381 | Denis Pompilio (jawa) 382 | .SH COPYRIGHT 383 | 2014, Denis 'jawa' Pompilio 384 | .\" Generated by docutils manpage writer. 385 | . 386 | -------------------------------------------------------------------------------- /packaging/debian-binary/pymailq_0.8.0-1_amd64.changes: -------------------------------------------------------------------------------- 1 | Format: 1.8 2 | Date: Tue, 05 Sep 2017 16:56:17 +0200 3 | Source: pymailq 4 | Binary: python-pymailq python3-pymailq 5 | Architecture: all 6 | Version: 0.8.0-1 7 | Distribution: UNRELEASED 8 | Urgency: low 9 | Maintainer: Denis Pompilio 10 | Changed-By: Denis Pompilio 11 | Description: 12 | python-pymailq - Postfix queue control python tool (Python 2) 13 | python3-pymailq - Postfix queue control python tool (Python 3) 14 | Changes: 15 | pymailq (0.8.0-1) UNRELEASED; urgency=low 16 | . 17 | * [bug] Fixing handling of modifiers completion 18 | * [bug] Fixing parse_error message retention 19 | * [bug] Fixing size selection error when using only maximum size 20 | * Removing exception StoreNotLoaded 21 | * Making 'cat_message' command dynamic in Mail objects 22 | * Implementation of the "long" output format 23 | * Implementation of mail queue summary 24 | * Implementation of Mail.show() method 25 | * Implementation of selection by queue IDs 26 | * Improved unittests 27 | Checksums-Sha1: 28 | 072ff0972cbc8dcb7ed7db072e6dfa6eee239e21 30128 python-pymailq_0.8.0-0~17.30_all.deb 29 | bddeef04ad6094b724eb86cbfc0d87fc9dea9d87 30216 python3-pymailq_0.8.0-0~17.30_all.deb 30 | Checksums-Sha256: 31 | f81b0ea4eda93a02d437e8c24d5f5d51bc72112beacda2553cd060bdaa9326f5 30128 python-pymailq_0.8.0-0~17.30_all.deb 32 | 0e8371b6c697019e3cd36812666f0ac40b8ced69014d2d3dee7aa84f5b7c9e20 30216 python3-pymailq_0.8.0-0~17.30_all.deb 33 | Files: 34 | 178a07746839e1a2ac06e78230c1a781 30128 python optional python-pymailq_0.8.0-0~17.30_all.deb 35 | 2814d8483de288c666fbbe54d9322ca6 30216 python optional python3-pymailq_0.8.0-0~17.30_all.deb 36 | -------------------------------------------------------------------------------- /packaging/debian-binary/pymailq_0.9.0-1_amd64.changes: -------------------------------------------------------------------------------- 1 | Format: 1.8 2 | Date: Thu, 07 Sep 2017 14:58:29 +0200 3 | Source: pymailq 4 | Binary: python-pymailq python3-pymailq 5 | Architecture: all 6 | Version: 0.9.0-1 7 | Distribution: unstable 8 | Urgency: low 9 | Maintainer: Denis Pompilio 10 | Changed-By: Denis Pompilio 11 | Description: 12 | python-pymailq - Postfix queue control python tool (Python 2) 13 | python3-pymailq - Postfix queue control python tool (Python 3) 14 | Changes: 15 | pymailq (0.9.0-1) unstable; urgency=low 16 | . 17 | * Update to upstream version v0.9.0 18 | * [bug] Fixing wrong usage for command inspect 19 | * Auto-decoding encoded mail subjects 20 | * Better encoding support for python2.7 and python3+ 21 | * Mail.show() method now display every mail headers 22 | * Implementation of store auto loading 23 | * Added Debian binary packages 24 | Checksums-Sha1: 25 | 00415404b3824f6313b87cf3a9fe2dc9dd65e0e0 33808 python-pymailq_0.9.0-0~17.30_all.deb 26 | d1cb8c1a2ca5046d6f09296f73587f836f2d9384 33880 python3-pymailq_0.9.0-0~17.30_all.deb 27 | Checksums-Sha256: 28 | 2110c37043c20c072cec31815df5497d40362ff07c9b4dad3f9b021be99da2eb 33808 python-pymailq_0.9.0-0~17.30_all.deb 29 | 827b263401fa9be5f4e15ae46e97e6afbb99efa5da5e74f665e4e04706ca6723 33880 python3-pymailq_0.9.0-0~17.30_all.deb 30 | Files: 31 | f5f5fcb07268d261ce5247d200ec4808 33808 python optional python-pymailq_0.9.0-0~17.30_all.deb 32 | f594ca98ddd1c0f471f71a3f75466be0 33880 python optional python3-pymailq_0.9.0-0~17.30_all.deb 33 | -------------------------------------------------------------------------------- /packaging/debian-binary/python-pymailq_0.8.0-0~17.30_all.deb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outini/pymailq/768abada70542ed35ef9c045ca6b476a091a2fe9/packaging/debian-binary/python-pymailq_0.8.0-0~17.30_all.deb -------------------------------------------------------------------------------- /packaging/debian-binary/python-pymailq_0.9.0-0~17.30_all.deb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outini/pymailq/768abada70542ed35ef9c045ca6b476a091a2fe9/packaging/debian-binary/python-pymailq_0.9.0-0~17.30_all.deb -------------------------------------------------------------------------------- /packaging/debian-binary/python3-pymailq_0.8.0-0~17.30_all.deb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outini/pymailq/768abada70542ed35ef9c045ca6b476a091a2fe9/packaging/debian-binary/python3-pymailq_0.8.0-0~17.30_all.deb -------------------------------------------------------------------------------- /packaging/debian-binary/python3-pymailq_0.9.0-0~17.30_all.deb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/outini/pymailq/768abada70542ed35ef9c045ca6b476a091a2fe9/packaging/debian-binary/python3-pymailq_0.9.0-0~17.30_all.deb -------------------------------------------------------------------------------- /packaging/debian/changelog: -------------------------------------------------------------------------------- 1 | pymailq (0.9.0-1) unstable; urgency=low 2 | 3 | * Update to upstream version v0.9.0 4 | * [bug] Fixing wrong usage for command inspect 5 | * Auto-decoding encoded mail subjects 6 | * Better encoding support for python2.7 and python3+ 7 | * Mail.show() method now display every mail headers 8 | * Implementation of store auto loading 9 | * Added Debian binary packages 10 | 11 | -- Denis Pompilio Thu, 07 Sep 2017 14:58:29 +0200 12 | 13 | pymailq (0.8.0-1) unstable; urgency=low 14 | 15 | * [bug] Fixing handling of modifiers completion 16 | * [bug] Fixing parse_error message retention 17 | * [bug] Fixing size selection error when using only maximum size 18 | * Removing exception StoreNotLoaded 19 | * Making 'cat_message' command dynamic in Mail objects 20 | * Implementation of the "long" output format 21 | * Implementation of mail queue summary 22 | * Implementation of Mail.show() method 23 | * Implementation of selection by queue IDs 24 | * Improved unittests 25 | 26 | -- Denis Pompilio Thu, 07 Sep 2017 14:02:55 +0200 27 | -------------------------------------------------------------------------------- /packaging/debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /packaging/debian/control: -------------------------------------------------------------------------------- 1 | Source: pymailq 2 | Maintainer: Denis Pompilio 3 | Section: python 4 | X-Python-Version: >= 2.7 5 | Priority: optional 6 | Build-Depends: debhelper (>= 9), dh-python, 7 | python-all, python3-all, 8 | python-setuptools, python3-setuptools, 9 | wget, ca-certificates, lsb-release 10 | 11 | Package: python-pymailq 12 | Architecture: all 13 | Homepage: https://github.com/outini/pymailq 14 | Depends: ${misc:Depends}, ${python:Depends} 15 | Conflicts: python3-pymailq 16 | Replaces: python3-pymailq 17 | Description: Postfix queue control python tool (Python 2) 18 | The pymailq module makes it easy to view and control Postfix mails queue. It 19 | provide several classes to store, view and interact with mail queue using 20 | Postfix command line tools. This module is provided for automation and 21 | monitoring developments. 22 | This project also provides a shell-like to interact with Postfix mails queue. 23 | It provide simple means to view the queue content, filter mails on criterias 24 | like Sender or delivery errors and lead administrative operations. 25 | . 26 | This package installs the library for Python 2. 27 | 28 | Package: python3-pymailq 29 | Architecture: all 30 | Homepage: https://github.com/outini/pymailq 31 | Depends: ${misc:Depends}, ${python3:Depends} 32 | Conflicts: python-pymailq 33 | Replaces: python-pymailq 34 | Description: Postfix queue control python tool (Python 3) 35 | The pymailq module makes it easy to view and control Postfix mails queue. It 36 | provide several classes to store, view and interact with mail queue using 37 | Postfix command line tools. This module is provided for automation and 38 | monitoring developments. 39 | This project also provides a shell-like to interact with Postfix mails queue. 40 | It provide simple means to view the queue content, filter mails on criterias 41 | like Sender or delivery errors and lead administrative operations. 42 | . 43 | This package installs the library for Python 3. 44 | -------------------------------------------------------------------------------- /packaging/debian/copyright: -------------------------------------------------------------------------------- 1 | This package was debianized by Denis Pompilio (jawa) 2 | on Tue, 29 Aug 2017 19:17:44 +0200. 3 | 4 | It was downloaded from https://github.com/outini/pymailq 5 | 6 | Upstream Author: 7 | 8 | Denis Pompilio (jawa) 9 | 10 | Files: * 11 | Copyright: 12 | 2014-2017, Denis Pompilio 13 | License: GPL 14 | 15 | Files: debian/* 16 | Copyright: 17 | 2014-2017, Denis Pompilio 18 | License: GPL 19 | 20 | License: GPL 21 | This package is free software; you can redistribute it and/or modify 22 | it under the terms of the GNU General Public License as published by 23 | the Free Software Foundation; either version 2 of the License, or 24 | (at your option) any later version. 25 | 26 | This package is distributed in the hope that it will be useful, 27 | but WITHOUT ANY WARRANTY; without even the implied warranty of 28 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 29 | GNU General Public License for more details. 30 | 31 | You should have received a copy of the GNU General Public License 32 | along with this package; if not, see . 33 | 34 | On Debian systems, the complete text of the GNU General 35 | Public License can be found in `/usr/share/common-licenses/GPL-3'. -------------------------------------------------------------------------------- /packaging/debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | 3 | #export DH_VERBOSE = 1 4 | 5 | export PYBUILD_NAME=pymailq 6 | export PYBUILD_SYSTEM=distutils 7 | export PYBUILD_DESTDIR_python2=debian/python-pymailq/ 8 | export PYBUILD_DESTDIR_python3=debian/python3-pymailq/ 9 | 10 | VERSION = 0.9.0 11 | DISTRIBUTION = $(shell lsb_release -sr) 12 | PACKAGEVERSION = $(VERSION)-0~$(DISTRIBUTION)0 13 | TARBALL = v$(VERSION).tar.gz 14 | URL = https://github.com/outini/pymailq/archive/$(TARBALL) 15 | 16 | %: 17 | dh $@ --with python2,python3 --buildsystem pybuild 18 | 19 | override_dh_auto_clean: 20 | wget -N --progress=dot:mega $(URL) 21 | tar --strip-components=1 -xzf $(TARBALL) 22 | 23 | override_dh_auto_test: 24 | override_dh_gencontrol: 25 | dh_gencontrol -- -v$(PACKAGEVERSION) 26 | -------------------------------------------------------------------------------- /packaging/debian/watch: -------------------------------------------------------------------------------- 1 | version=3 2 | opts=filenamemangle=s/.+\/v?(\d\S+)\.tar\.gz/pymailq-$1\.tar\.gz/ \ 3 | https://github.com/outini/pymailq/releases .*/v(\d[\d\.]*)\.tar\.gz 4 | -------------------------------------------------------------------------------- /packaging/pymailq.spec: -------------------------------------------------------------------------------- 1 | %{!?python_sitelib: %global python_sitelib %(%{__python} -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())")} 2 | 3 | Name: pymailq 4 | Version: 0.8.0 5 | Release: 1%{?dist} 6 | Summary: Simple Postfix queue management 7 | 8 | Group: Development/Languages 9 | License: GPVLv2 10 | URL: https://github.com/outini/pymailq/ 11 | Source0: https://github.com/outini/pymailq/archive/v%{version}.tar.gz 12 | BuildRoot: %{_tmppath}/%{name}-%{version}-%{release}-root-%(%{__id_u} -n) 13 | 14 | BuildArch: noarch 15 | BuildRequires: python-devel 16 | 17 | %description 18 | 19 | 20 | %prep 21 | %setup -q 22 | 23 | 24 | %build 25 | # Remove CFLAGS=... for noarch packages (unneeded) 26 | CFLAGS="$RPM_OPT_FLAGS" %{__python} setup.py build 27 | 28 | 29 | %install 30 | rm -rf $RPM_BUILD_ROOT 31 | %{__python} setup.py install -O1 --skip-build --root $RPM_BUILD_ROOT 32 | 33 | 34 | %clean 35 | rm -rf $RPM_BUILD_ROOT 36 | 37 | 38 | %files 39 | %defattr(-,root,root,-) 40 | %doc 41 | %{python_sitelib}/* 42 | /usr/bin/pqshell 43 | /usr/share/doc/pymailq/LICENSE 44 | /usr/share/doc/pymailq/README.rst 45 | /usr/share/doc/pymailq/CHANGES 46 | /usr/share/doc/pymailq/examples/pymailq.ini 47 | /usr/share/man/man1/pqshell.1.gz 48 | 49 | 50 | %changelog 51 | * Mon Aug 28 2017 Denis Pompilio - 0.8.0-1 52 | - [bug] Fixing handling of modifiers completion 53 | - [bug] Fixing parse_error message retention 54 | - [bug] Fixing size selection error when using only maximum size 55 | - Removing exception StoreNotLoaded 56 | - Making 'cat_message' command dynamic in Mail objects 57 | - Implementation of the "long" output format 58 | - Implementation of mail queue summary 59 | - Implementation of Mail.show() method 60 | - Implementation of selection by queue IDs 61 | - Improved unittests 62 | * Thu Aug 24 2017 Denis Pompilio - 0.7.0-1 63 | - Support of configuration file 64 | * Thu Aug 24 2017 Denis Pompilio - 0.6.0-2 65 | - Added CHANGES file and examples to package 66 | * Wed Aug 16 2017 Denis Pompilio - 0.6.0-1 67 | - Pqshell now have usage and options 68 | - Pqshell can now show pymailq package version 69 | - Pqshell can now be started in debug mode 70 | - Improved shell completion with suggests and modifiers 71 | - Implementation of the "mails by date" selector 72 | - Reworked postsuper commands handling 73 | - Better pep8 support 74 | - Unit testing for python2.7 and python3 75 | - Using code coverage 76 | * Mon Oct 27 2014 Nils Ratusznik - 0.5.3-2 77 | - Automated version update for Source0 78 | - renamed pyqueue.spec to pymailq.spec 79 | - corrected errors in files packaging 80 | - corrected errors and warnings displayed by rpmlint 81 | * Sun Oct 19 2014 Denis Pompilio - 0.5.3-1 82 | - 0.5.3 update 83 | * Thu May 08 2014 Denis Pompilio - 0.5.2-1 84 | - 0.5.2 update 85 | * Fri May 02 2014 Nils Ratusznik - 0.4-1 86 | - Initial package 87 | -------------------------------------------------------------------------------- /pymailq/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # Postfix queue control python tool (pymailq) 4 | # 5 | # Copyright (C) 2014 Denis Pompilio (jawa) 6 | # 7 | # This file is part of pymailq 8 | # 9 | # This program is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU General Public License 11 | # as published by the Free Software Foundation; either version 2 12 | # of the License, or (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, see . 21 | 22 | import sys 23 | import shlex 24 | from functools import wraps 25 | from datetime import datetime 26 | 27 | try: 28 | import configparser 29 | except ImportError: 30 | import ConfigParser as configparser 31 | 32 | #: Boolean to control activation of the :func:`debug` decorator. 33 | DEBUG = False 34 | 35 | #: Current version of the package as :class:`str`. 36 | VERSION = "0.9.0" 37 | 38 | #: Module configuration as :class:`dict`. 39 | CONFIG = { 40 | "core": { 41 | "postfix_spool": "/var/spool/postfix" 42 | }, 43 | "commands": { 44 | "use_sudo": False, 45 | "list_queue": ["mailq"], 46 | "cat_message": ["postcat", "-qv"], 47 | "hold_message": ["postsuper", "-h"], 48 | "release_message": ["postsuper", "-H"], 49 | "requeue_message": ["postsuper", "-r"], 50 | "delete_message": ["postsuper", "-d"] 51 | } 52 | } 53 | 54 | 55 | def debug(function): 56 | """ 57 | Decorator to print some call informations and timing debug on stderr. 58 | 59 | Function's name, passed args and kwargs are printed to stderr. Elapsed time 60 | is also print at the end of call. This decorator is based on the value of 61 | :data:`DEBUG`. If ``True``, the debug informations will be displayed. 62 | """ 63 | @wraps(function) 64 | def run(*args, **kwargs): 65 | name = function.__name__ 66 | if DEBUG is True: 67 | sys.stderr.write("[DEBUG] Running {0}\n".format(name)) 68 | sys.stderr.write("[DEBUG] args: {0}\n".format(args)) 69 | sys.stderr.write("[DEBUG] kwargs: {0}\n".format(kwargs)) 70 | start = datetime.now() 71 | 72 | ret = function(*args, **kwargs) 73 | 74 | if DEBUG is True: 75 | stop = datetime.now() 76 | sys.stderr.write("[DEBUG] Exectime of {0}: {1} seconds\n".format( 77 | name, (stop - start).total_seconds())) 78 | 79 | return ret 80 | 81 | return run 82 | 83 | 84 | def load_config(cfg_file): 85 | """ 86 | Load module configuration from .ini file 87 | 88 | Information from this file are directly used to override values stored in 89 | :attr:`pymailq.CONFIG`. 90 | 91 | Commands from configuration file are treated using :func:`shlex.split` to 92 | properly transform command string to list of arguments. 93 | 94 | :param str cfg_file: Configuration file 95 | 96 | .. seealso:: 97 | 98 | :ref:`pymailq-configuration` 99 | """ 100 | global CONFIG 101 | 102 | cfg = configparser.ConfigParser() 103 | cfg.read(cfg_file) 104 | 105 | if "core" in cfg.sections(): 106 | if cfg.has_option("core", "postfix_spool"): 107 | CONFIG["core"]["postfix_spool"] = cfg.get("core", "postfix_spool") 108 | 109 | if "commands" in cfg.sections(): 110 | for key in cfg.options("commands"): 111 | if key == "use_sudo": 112 | if cfg.get("commands", key) == "yes": 113 | CONFIG["commands"]["use_sudo"] = True 114 | else: 115 | command = shlex.split(cfg.get("commands", key)) 116 | CONFIG["commands"][key] = command 117 | -------------------------------------------------------------------------------- /pymailq/control.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # Postfix queue control python tool (pymailq) 4 | # 5 | # Copyright (C) 2014 Denis Pompilio (jawa) 6 | # 7 | # This file is part of pymailq 8 | # 9 | # This program is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU General Public License 11 | # as published by the Free Software Foundation; either version 2 12 | # of the License, or (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, see . 21 | 22 | import time 23 | import subprocess 24 | from pymailq import CONFIG, debug 25 | 26 | 27 | class QueueControl(object): 28 | """ 29 | Postfix queue control using postsuper command. 30 | 31 | The :class:`~control.QueueControl` instance defines the following 32 | attributes: 33 | 34 | .. attribute:: use_sudo 35 | 36 | Boolean to control the use of `sudo` to invoke Postfix command. 37 | Default is ``False`` 38 | 39 | .. attribute:: postsuper_cmd 40 | 41 | Postfix command and arguments :func:`list` for mails queue 42 | administrative operations. Default is ``["postsuper"]`` 43 | 44 | .. attribute:: known_operations 45 | 46 | Known Postfix administrative operations :class:`dict` to associate 47 | operations to command arguments. Known associations are:: 48 | 49 | delete: -d 50 | hold: -h 51 | release: -H 52 | requeue: -r 53 | 54 | .. warning:: 55 | 56 | Default known associations are provided for the default mails 57 | queue administrative command `postsuper`_. 58 | 59 | .. seealso:: 60 | 61 | Postfix manual: 62 | `postsuper`_ -- Postfix superintendent 63 | """ 64 | 65 | @staticmethod 66 | def get_operation_cmd(operation): 67 | """Get operation related command from configuration 68 | 69 | This method use Postfix administrative commands defined 70 | in :attr:`pymailq.CONFIG` attribute under the key 'list_queue'. 71 | Command and arguments list is build on call with the configuration data. 72 | 73 | Command keys are built with the ``operation`` argument suffixed with 74 | ``_message``. Example: ``hold_message`` for the hold command. 75 | 76 | :param str operation: Operation name 77 | :return: Command and arguments as :class:`list` 78 | 79 | :raise KeyError: Operation is unknown 80 | 81 | .. seealso:: 82 | 83 | :ref:`pymailq-configuration` 84 | """ 85 | cmd = CONFIG['commands'][operation + '_message'] 86 | if CONFIG['commands']['use_sudo']: 87 | cmd.insert(0, 'sudo') 88 | return cmd 89 | 90 | @debug 91 | def _operate(self, operation, messages): 92 | """ 93 | Generic method to lead operations messages from postfix mail queue. 94 | 95 | Operations can be one of Postfix known operations stored in 96 | PyMailq module configuration. 97 | 98 | :param str operation: Known operation from :attr:`pymailq.CONFIG`. 99 | :param list messages: List of :class:`~store.Mail` objects targetted 100 | for operation. 101 | :return: Command's *stderr* output lines 102 | :rtype: :func:`list` 103 | """ 104 | # validate that object's attribute "qid" exist. Raise AttributeError. 105 | for msg in messages: 106 | getattr(msg, "qid") 107 | 108 | # We may modify this part to improve security. 109 | # It should not be possible to inject commands, but who knows... 110 | # https://www.kevinlondon.com/2015/07/26/dangerous-python-functions.html 111 | # And consider the use of sh module: https://amoffat.github.io/sh/ 112 | operation_cmd = self.get_operation_cmd(operation) + ['-'] 113 | try: 114 | child = subprocess.Popen(operation_cmd, 115 | stdin=subprocess.PIPE, 116 | stderr=subprocess.PIPE) 117 | except EnvironmentError as exc: 118 | command_str = " ".join(operation_cmd) 119 | error_msg = "Unable to call '%s': %s" % (command_str, str(exc)) 120 | raise RuntimeError(error_msg) 121 | 122 | # If permissions error, the postsuper process takes ~1s to teardown. 123 | # Wait this delay and raise error message if process has stopped. 124 | time.sleep(1.1) 125 | child.poll() 126 | if child.returncode: 127 | raise RuntimeError(child.communicate()[1].strip().decode()) 128 | 129 | try: 130 | for msg in messages: 131 | child.stdin.write((msg.qid+'\n').encode()) 132 | stderr = child.communicate()[1].strip() 133 | except BrokenPipeError: 134 | raise RuntimeError("Unexpected error: child process has crashed") 135 | 136 | return [line.strip() for line in stderr.decode().split('\n')] 137 | 138 | def delete_messages(self, messages): 139 | """ 140 | Delete several messages from postfix mail queue. 141 | 142 | This method is a :func:`~functools.partial` wrapper on 143 | :meth:`~control.QueueControl._operate`. Passed operation is ``delete`` 144 | """ 145 | return self._operate('delete', messages) 146 | 147 | def hold_messages(self, messages): 148 | """ 149 | Hold several messages from postfix mail queue. 150 | 151 | This method is a :func:`~functools.partial` wrapper on 152 | :meth:`~control.QueueControl._operate`. Passed operation is ``hold`` 153 | """ 154 | return self._operate('hold', messages) 155 | 156 | def release_messages(self, messages): 157 | """ 158 | Release several messages from postfix mail queue. 159 | 160 | This method is a :func:`~functools.partial` wrapper on 161 | :meth:`~control.QueueControl._operate`. Passed operation is ``release`` 162 | """ 163 | return self._operate('release', messages) 164 | 165 | def requeue_messages(self, messages): 166 | """ 167 | Requeue several messages from postfix mail queue. 168 | 169 | This method is a :func:`~functools.partial` wrapper on 170 | :meth:`~control.QueueControl._operate`. Passed operation is ``requeue`` 171 | """ 172 | return self._operate('requeue', messages) 173 | -------------------------------------------------------------------------------- /pymailq/selector.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # Postfix queue control python tool (pymailq) 4 | # 5 | # Copyright (C) 2014 Denis Pompilio (jawa) 6 | # 7 | # This file is part of pymailq 8 | # 9 | # This program is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU General Public License 11 | # as published by the Free Software Foundation; either version 2 12 | # of the License, or (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, see . 21 | 22 | import gc 23 | from functools import wraps 24 | from datetime import datetime 25 | from pymailq import debug 26 | 27 | 28 | class MailSelector(object): 29 | """ 30 | Mail selector class to request mails from store matching criterias. 31 | 32 | The :class:`~selector.MailSelector` instance provides the following 33 | attributes: 34 | 35 | .. attribute:: mails 36 | 37 | Currently selected :class:`~store.Mail` objects :func:`list` 38 | 39 | .. attribute:: store 40 | 41 | Linked :class:`~store.PostqueueStore` at the 42 | :class:`~selector.MailSelector` instance initialization. 43 | 44 | .. attribute:: filters 45 | 46 | Applied filters :func:`list` on current selection. Filters list 47 | entries are tuples containing ``(function.__name__, args, kwargs)`` 48 | for each applied filters. This list is filled by the 49 | :meth:`~selector.MailSelector.filter_registration` decorator while 50 | calling filtering methods. It is possible to replay registered 51 | filter using :meth:`~selector.MailSelector.replay_filters` method. 52 | """ 53 | def __init__(self, store): 54 | """Init method""" 55 | self.mails = [] 56 | self.store = store 57 | self.filters = [] 58 | 59 | self.reset() 60 | 61 | def filter_registration(function): 62 | """ 63 | Decorator to register applied filter. 64 | 65 | This decorated is used to wrap selection methods ``lookup_*``. It 66 | registers a ``(function.__name__, args, kwargs)`` :func:`tuple` in 67 | the :attr:`~MailSelector.filters` attribute. 68 | """ 69 | @wraps(function) 70 | def wrapper(self, *args, **kwargs): 71 | filterinfo = (function.__name__, args, kwargs) 72 | self.filters.append(filterinfo) 73 | return function(self, *args, **kwargs) 74 | return wrapper 75 | 76 | def reset(self): 77 | """ 78 | Reset mail selector with initial store mails list. 79 | 80 | Selected :class:`~store.Mail` objects are deleted and the 81 | :attr:`~MailSelector.mails` attribute is removed for memory releasing 82 | purpose (with help of :func:`gc.collect`). Attribute 83 | :attr:`~MailSelector.mails` is then reinitialized a copy of 84 | :attr:`~MailSelector.store`'s :attr:`~PostqueueStore.mails` attribute. 85 | 86 | Registered :attr:`~MailSelector.filters` are also emptied. 87 | """ 88 | del self.mails 89 | gc.collect() 90 | 91 | self.mails = [mail for mail in self.store.mails] 92 | self.filters = [] 93 | 94 | def replay_filters(self): 95 | """ 96 | Reset selection with store content and replay registered filters. 97 | 98 | Like with the :meth:`~selector.MailSelector.reset` method, selected 99 | :class:`~store.Mail` objects are deleted and reinitialized with a copy 100 | of :attr:`~MailSelector.store`'s :attr:`~PostqueueStore.mails` 101 | attribute. 102 | 103 | However, registered :attr:`~MailSelector.filters` are kept and replayed 104 | on resetted selection. Use this method to refresh your store content 105 | while keeping your filters. 106 | """ 107 | del self.mails 108 | gc.collect() 109 | 110 | self.mails = [mail for mail in self.store.mails] 111 | filters = [entry for entry in self.filters] 112 | for filterinfo in filters: 113 | name, args, kwargs = filterinfo 114 | getattr(self, name)(*args, **kwargs) 115 | self.filters = filters 116 | 117 | def get_mails_by_qids(self, qids): 118 | """ 119 | Get mails with specified IDs. 120 | 121 | This function is not registered as filter. 122 | 123 | :param list qids: List of mail IDs. 124 | :return: List of newly selected :class:`~store.Mail` objects 125 | :rtype: :func:`list` 126 | """ 127 | return [mail for mail in self.mails 128 | if mail.qid in qids] 129 | 130 | @debug 131 | @filter_registration 132 | def lookup_qids(self, qids): 133 | """ 134 | Lookup mails with specified IDs. 135 | 136 | :param list qids: List of mail IDs. 137 | :return: List of newly selected :class:`~store.Mail` objects 138 | :rtype: :func:`list` 139 | """ 140 | self.mails = self.get_mails_by_qids(qids) 141 | return self.mails 142 | 143 | @debug 144 | @filter_registration 145 | def lookup_header(self, header, value, exact=True): 146 | """ 147 | Lookup mail headers with specified value. 148 | 149 | :param str header: Header name to filter on. 150 | :param str value: Header value to filter on. 151 | :param bool exact: Allow lookup with partial or exact match 152 | 153 | :return: List of newly selected :class:`~store.Mail` objects 154 | :rtype: :func:`list` 155 | """ 156 | matches = [] 157 | for mail in self.mails: 158 | header_value = getattr(mail.head, header, None) 159 | if not header_value: 160 | continue 161 | 162 | if not isinstance(header_value, list): 163 | header_value = [header_value] 164 | 165 | if exact and value in header_value: 166 | matches.append(mail) 167 | elif not exact: 168 | for entry in header_value: 169 | if value in entry: 170 | matches.append(mail) 171 | break 172 | 173 | self.mails = matches 174 | return self.mails 175 | 176 | @debug 177 | @filter_registration 178 | def lookup_status(self, status): 179 | """ 180 | Lookup mails with specified postqueue status. 181 | 182 | :param list status: List of matching status to filter on. 183 | :return: List of newly selected :class:`~store.Mail` objects 184 | :rtype: :func:`list` 185 | """ 186 | self.mails = [mail for mail in self.mails 187 | if mail.status in status] 188 | 189 | return self.mails 190 | 191 | @debug 192 | @filter_registration 193 | def lookup_sender(self, sender, exact=True): 194 | """ 195 | Lookup mails send from a specific sender. 196 | 197 | Optionnal parameter ``partial`` allow lookup of partial sender like 198 | ``@domain.com`` or ``sender@``. By default, ``partial`` is ``False`` 199 | and selection is made on exact sender. 200 | 201 | .. note:: 202 | 203 | Matches are made against :attr:`Mail.sender` attribute instead of 204 | real mail header :mailheader:`Sender`. 205 | 206 | :param str sender: Sender address to lookup in :class:`~store.Mail` 207 | objects selection. 208 | :param bool exact: Allow lookup with partial or exact match 209 | :return: List of newly selected :class:`~store.Mail` objects 210 | :rtype: :func:`list` 211 | """ 212 | if exact is False: 213 | self.mails = [mail for mail in self.mails 214 | if sender in mail.sender] 215 | else: 216 | self.mails = [mail for mail in self.mails 217 | if sender == mail.sender] 218 | 219 | return self.mails 220 | 221 | @debug 222 | @filter_registration 223 | def lookup_recipient(self, recipient, exact=True): 224 | """ 225 | Lookup mails send to a specific recipient. 226 | 227 | Optionnal parameter ``partial`` allow lookup of partial sender like 228 | ``@domain.com`` or ``sender@``. By default, ``partial`` is ``False`` 229 | and selection is made on exact sender. 230 | 231 | .. note:: 232 | 233 | Matches are made against :attr:`Mail.recipients` attribute instead 234 | of real mail header :mailheader:`To`. 235 | 236 | :param str recipient: Recipient address to lookup in 237 | :class:`~store.Mail` objects selection. 238 | :param bool exact: Allow lookup with partial or exact match 239 | :return: List of newly selected :class:`~store.Mail` objects 240 | :rtype: :func:`list` 241 | """ 242 | if exact is False: 243 | selected = [] 244 | for mail in self.mails: 245 | for value in mail.recipients: 246 | if recipient in value: 247 | selected += [mail] 248 | self.mails = selected 249 | else: 250 | self.mails = [mail for mail in self.mails 251 | if recipient in mail.recipients] 252 | 253 | return self.mails 254 | 255 | @debug 256 | @filter_registration 257 | def lookup_error(self, error_msg): 258 | """ 259 | Lookup mails with specific error message (message may be partial). 260 | 261 | :param str error_msg: Error message to filter on 262 | :return: List of newly selected :class:`~store.Mail` objects` 263 | :rtype: :func:`list` 264 | """ 265 | self.mails = [mail for mail in self.mails 266 | if True in [True for err in mail.errors 267 | if error_msg in err]] 268 | return self.mails 269 | 270 | @debug 271 | @filter_registration 272 | def lookup_date(self, start=None, stop=None): 273 | """ 274 | Lookup mails send on specific date range(s). 275 | 276 | :param datetime.date start: Start date (Default: None) 277 | :param datetime.date stop: Stop date (Default: None) 278 | 279 | :return: List of newly selected :class:`~store.Mail` objects 280 | :rtype: :func:`list` 281 | """ 282 | if start is None: 283 | start = datetime(1970, 1, 1) 284 | if stop is None: 285 | stop = datetime.now() 286 | 287 | self.mails = [mail for mail in self.mails 288 | if start <= mail.date <= stop] 289 | 290 | return self.mails 291 | 292 | @debug 293 | @filter_registration 294 | def lookup_size(self, smin=0, smax=0): # TODO: documentation 295 | """ 296 | Lookup mails send with specific size. 297 | 298 | Both arguments ``smin`` and ``smax`` are optionnal and default is set 299 | to ``0``. Maximum size is ignored if setted to ``0``. If both ``smin`` 300 | and ``smax`` are setted to ``0``, no filtering is done and the entire 301 | :class:`~store.Mail` objects selection is returned. 302 | 303 | :param int smin: Minimum size (Default: ``0``) 304 | :param int smax: Maximum size (Default: ``0``) 305 | :return: List of newly selected :class:`~store.Mail` objects 306 | :rtype: :func:`list` 307 | """ 308 | if smin == 0 and smax == 0: 309 | return self.mails 310 | 311 | if smax > 0: 312 | self.mails = [mail for mail in self.mails if mail.size <= smax] 313 | self.mails = [mail for mail in self.mails if mail.size >= smin] 314 | 315 | return self.mails 316 | -------------------------------------------------------------------------------- /pymailq/shell.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # Postfix queue control python tool (pymailq) 4 | # 5 | # Copyright (C) 2014 Denis Pompilio (jawa) 6 | # Copyright (C) 2014 Jocelyn Delalande 7 | # 8 | # This file is part of pymailq 9 | # 10 | # This program is free software; you can redistribute it and/or 11 | # modify it under the terms of the GNU General Public License 12 | # as published by the Free Software Foundation; either version 2 13 | # of the License, or (at your option) any later version. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU General Public License 21 | # along with this program; if not, see . 22 | 23 | import cmd 24 | from functools import partial 25 | from datetime import datetime, timedelta 26 | from subprocess import CalledProcessError 27 | import shlex 28 | import inspect 29 | from pymailq import store, control, selector, utils 30 | 31 | 32 | class PyMailqShell(cmd.Cmd): 33 | """PyMailq shell for interactive mode""" 34 | 35 | # Automatic building of supported methods and documentation 36 | commands_info = { 37 | 'store': 'Control of Postfix queue content storage', 38 | 'select': 'Select mails from Postfix queue content', 39 | 'inspect': 'Mail content inspector', 40 | 'super': 'Call postsuper commands' 41 | } 42 | 43 | # XXX: do_* methods are parsed before init and must be declared here 44 | do_inspect = None 45 | do_store = None 46 | do_select = None 47 | do_super = None 48 | 49 | def __init__(self, completekey='tab', stdin=None, stdout=None, 50 | store_auto_load=False): 51 | """Init method""" 52 | cmd.Cmd.__init__(self, completekey, stdin, stdout) 53 | 54 | # EOF action is registered here to hide it from user 55 | self.do_EOF = self.do_exit 56 | 57 | for command in self.commands_info: 58 | setattr(self, "help_%s" % (command,), partial(self._help_, command)) 59 | setattr(self, "do_%s" % (command,), partial(self.__do, command)) 60 | 61 | # show command is specific and cannot be build dynamically 62 | setattr(self, "help_show", partial(self._help_, "show")) 63 | 64 | self.pstore = store.PostqueueStore() 65 | self.selector = selector.MailSelector(self.pstore) 66 | self.qcontrol = control.QueueControl() 67 | 68 | if store_auto_load: 69 | self.respond("Loading mails queue content to store") 70 | self._store_load() 71 | 72 | def respond(self, answer): 73 | """Send response""" 74 | if not isinstance(answer, str): 75 | answer = answer.encode('utf-8') 76 | self.stdout.write('%s\n' % answer) 77 | 78 | # Internal functions 79 | def emptyline(self): 80 | """Action on empty lines""" 81 | pass 82 | 83 | def help_help(self): 84 | """Help of command help""" 85 | self.respond("Show available commands") 86 | 87 | @staticmethod 88 | def do_exit(arg): 89 | """Action on exit""" 90 | return True 91 | 92 | def help_exit(self): 93 | """Help of command exit""" 94 | self.respond("Exit PyMailq shell (or use Ctrl-D)") 95 | 96 | def cmdloop_nointerrupt(self): 97 | """Specific cmdloop to handle KeyboardInterrupt""" 98 | can_exit = False 99 | # intro message is not in self.intro not to display it each time 100 | # cmdloop is restarted 101 | self.respond("Welcome to PyMailq shell.") 102 | while can_exit is not True: 103 | try: 104 | self.cmdloop() 105 | can_exit = True 106 | except KeyboardInterrupt: 107 | self.respond("^C") 108 | 109 | def postloop(self): 110 | cmd.Cmd.postloop(self) 111 | self.respond("\nExiting shell... Bye.") 112 | 113 | def _help_(self, command): 114 | docstr = self.commands_info.get( 115 | command, getattr(self, "do_%s" % (command,)).__doc__) 116 | self.respond(inspect.cleandoc(docstr)) 117 | 118 | self.respond("Subcommands:") 119 | for method in dir(self): 120 | if method.startswith("_%s_" % (command,)): 121 | docstr = getattr(self, method).__doc__ 122 | doclines = inspect.cleandoc(docstr).split('\n') 123 | self.respond(" %-10s %s" % (method[len(command)+2:], 124 | doclines.pop(0))) 125 | for line in doclines: 126 | self.respond(" %-10s %s" % ("", line)) 127 | 128 | # 129 | # PyMailq methods 130 | # 131 | 132 | @property 133 | def prompt(self): 134 | """Dynamic prompt with usefull informations""" 135 | 136 | prompt = ['PyMailq'] 137 | if self.selector is not None: 138 | prompt.append(' (sel:%d)' % (len(self.selector.mails))) 139 | prompt.append('> ') 140 | 141 | return "".join(prompt) 142 | 143 | def __do(self, cmd_category, str_arg): 144 | """Generic do_* method to call cmd categories""" 145 | 146 | args = shlex.split(str_arg) 147 | if not len(args): 148 | getattr(self, "help_%s" % (cmd_category,))() 149 | return None 150 | 151 | command = args.pop(0) 152 | method = "_%s_%s" % (cmd_category, command) 153 | try: 154 | lines = getattr(self, method)(*args) 155 | if lines is not None and len(lines): 156 | self.respond('\n'.join(lines)) 157 | except AttributeError: 158 | self.respond("%s has no subcommand: %s" % (cmd_category, command)) 159 | except (SyntaxError, TypeError) as exc: 160 | # Rewording Python TypeError message for cli display 161 | msg = str(exc) 162 | if "%s()" % (method,) in msg: 163 | msg = "%s command %s" % (cmd_category, msg[len(method)+3:]) 164 | self.respond("*** Syntax error: " + msg) 165 | 166 | @staticmethod 167 | def get_modifiers(match, excludes=()): 168 | """Get modifiers from match 169 | 170 | :param str match: String to match in modifiers 171 | :param list excludes: Excluded modifiers 172 | :return: Matched modifiers as :func:`list` 173 | """ 174 | modifiers = { 175 | 'limit': [''], 176 | 'rankby': [''], 177 | 'sortby': [' [asc|desc]'] 178 | } 179 | if match in modifiers and match not in excludes: 180 | return modifiers[match] 181 | return [mod for mod in modifiers 182 | if mod not in excludes and mod.startswith(match)] 183 | 184 | def completenames(self, text, *ignored): 185 | """Complete known commands""" 186 | dotext = 'do_'+text 187 | suggests = [a[3:] for a in self.get_names() if a.startswith(dotext)] 188 | if len(suggests) == 1: 189 | # Only one suggest, return it with a space 190 | suggests[0] += " " 191 | return suggests 192 | 193 | def completedefault(self, text, line, *ignored): 194 | """Generic command completion method""" 195 | # we may consider the use of re.match for params in completion 196 | completion = { 197 | 'show': { 198 | '__allow_mods__': True, 199 | }, 200 | 'inspect': { 201 | 'mails': ['[,,...]'] 202 | }, 203 | 'select': { 204 | 'date': [''], 205 | 'error': [''], 206 | 'rmfilter': [''], 207 | 'sender': [' [exact]'], 208 | 'recipient': [' [exact]'], 209 | 'size': ['<-n|n|+n> [-n]'], 210 | 'status': [''] 211 | } 212 | } 213 | 214 | args = shlex.split(line) 215 | command = args.pop(0) 216 | sub_command = "" 217 | if len(args): 218 | sub_command = args.pop(0) 219 | 220 | match = "_%s_" % (command,) 221 | suggests = [name[len(match):] for name in dir(self) 222 | if name.startswith(match + sub_command)] 223 | 224 | # No suggests, return None 225 | if not len(suggests): 226 | return None 227 | 228 | # Return multiple suggests for sub-command 229 | if len(suggests) > 1: 230 | return suggests 231 | suggest = suggests.pop(0) 232 | 233 | exact_match = True if suggest == sub_command else False 234 | 235 | if suggest in completion.get(command, {}): 236 | if not exact_match: 237 | # Sub-command takes params, suffix it with a space 238 | return [suggest + " "] 239 | elif not len(args): 240 | # Return sub-command params 241 | return completion[command][sub_command] 242 | elif not exact_match: 243 | # Sub-command doesn't take params, return as is 244 | return [suggest] 245 | 246 | # Command allows modifiers 247 | if completion[command].get('__allow_mods__'): 248 | if len(args) or not len(text): 249 | match = args[-1] if len(args) else "" 250 | mods = self.get_modifiers(match, excludes=args[:-1]) 251 | if not len(mods): 252 | mods = self.get_modifiers("", excludes=args) 253 | if len(mods): 254 | mods[0] += " " if len(mods) == 1 else "" 255 | suggests = mods 256 | 257 | if not len(suggests): 258 | return None 259 | return suggests 260 | 261 | def _store_load(self, filename=None): 262 | """Load Postfix queue content""" 263 | try: 264 | self.pstore.load(filename=filename) 265 | # Automatic load of selector if it is empty and never used. 266 | if not len(self.selector.mails) and not len(self.selector.filters): 267 | self.selector.reset() 268 | return ["%d mails loaded from queue" % (len(self.pstore.mails))] 269 | except (OSError, IOError, CalledProcessError) as exc: 270 | return ["*** Error: unable to load store", " %s" % (exc,)] 271 | 272 | def _store_status(self): 273 | """Show store status""" 274 | if self.pstore is None or self.pstore.loaded_at is None: 275 | return ["store is not loaded"] 276 | return ["store loaded with %d mails at %s" % ( 277 | len(self.pstore.mails), self.pstore.loaded_at)] 278 | 279 | def _select_reset(self): 280 | """Reset content of selector with store content""" 281 | self.selector.reset() 282 | return ["Selector resetted with store content (%s mails)" % ( 283 | len(self.selector.mails))] 284 | 285 | def _select_replay(self): 286 | """Reset content of selector with store content and replay filters""" 287 | self.selector.replay_filters() 288 | return ["Selector resetted and filters replayed"] 289 | 290 | def _select_rmfilter(self, filterid): 291 | """ 292 | Remove filter previously applied 293 | Filters ids are used to specify filter to remove 294 | Usage: select rmfilter 295 | """ 296 | try: 297 | idx = int(filterid) 298 | self.selector.filters.pop(idx) 299 | self.selector.replay_filters() 300 | # TODO: except should be more accurate 301 | except: 302 | raise SyntaxError("invalid filter ID: %s" % filterid) 303 | 304 | def _select_qids(self, *qids): 305 | """ 306 | Select mails by ID 307 | Usage: select qids [,,...] 308 | """ 309 | self.selector.lookup_qids(qids) 310 | 311 | def _select_status(self, status): 312 | """ 313 | Select mails with specific postfix status 314 | Usage: select status 315 | """ 316 | self.selector.lookup_status(status=status) 317 | 318 | def _select_sender(self, sender, exact=False): 319 | """ 320 | Select mails from sender 321 | Usage: select sender [exact] 322 | """ 323 | if exact is not False: # received from command line 324 | if exact != "exact": 325 | raise SyntaxError("invalid keyword: %s" % exact) 326 | exact = True 327 | self.selector.lookup_sender(sender=sender, exact=exact) 328 | 329 | def _select_recipient(self, recipient, exact=False): 330 | """ 331 | Select mails to recipient 332 | Usage: select recipient [exact] 333 | """ 334 | if exact is not False: # received from command line 335 | if exact != "exact": 336 | raise SyntaxError("invalid keyword: %s" % exact) 337 | exact = True 338 | self.selector.lookup_recipient(recipient=recipient, exact=exact) 339 | 340 | def _select_size(self, size_a, size_b=None): 341 | """ 342 | Select mails by size in Bytes 343 | - and + are supported, if not specified, search for exact size 344 | Size range is allowed by using - (lesser than) and + (greater than) 345 | Usage: select size <-n|n|+n> [-n] 346 | """ 347 | smin = None 348 | smax = None 349 | exact = None 350 | try: 351 | for size in size_a, size_b: 352 | if size is None: 353 | continue 354 | if exact is not None: 355 | raise SyntaxError("exact size must be used alone") 356 | if size.startswith("-"): 357 | if smax is not None: 358 | raise SyntaxError("multiple max sizes specified") 359 | smax = int(size[1:]) 360 | elif size.startswith("+"): 361 | if smin is not None: 362 | raise SyntaxError("multiple min sizes specified") 363 | smin = int(size[1:]) 364 | else: 365 | exact = int(size) 366 | except ValueError: 367 | raise SyntaxError("specified sizes must be valid numbers") 368 | 369 | if exact is not None: 370 | smin = exact 371 | smax = exact 372 | if smax is None: 373 | smax = 0 374 | if smin is None: 375 | smin = 0 376 | 377 | if smin > smax > 0: 378 | raise SyntaxError("minimum size is greater than maximum size") 379 | 380 | self.selector.lookup_size(smin=smin, smax=smax) 381 | 382 | def _select_date(self, date_spec): 383 | """ 384 | Select mails by date. 385 | Usage: 386 | select date 387 | Where can be 388 | YYYY-MM-DD (exact date) 389 | YYYY-MM-DD..YYYY-MM-DD (within a date range (included)) 390 | +YYYY-MM-DD (after a date (included)) 391 | -YYYY-MM-DD (before a date (included)) 392 | """ 393 | try: 394 | if ".." in date_spec: 395 | (str_start, str_stop) = date_spec.split("..", 1) 396 | start = datetime.strptime(str_start, "%Y-%m-%d") 397 | stop = datetime.strptime(str_stop, "%Y-%m-%d") 398 | elif date_spec.startswith("+"): 399 | start = datetime.strptime(date_spec[1:], "%Y-%m-%d") 400 | stop = datetime.now() 401 | elif date_spec.startswith("-"): 402 | start = datetime(1970, 1, 1) 403 | stop = datetime.strptime(date_spec[1:], "%Y-%m-%d") 404 | else: 405 | start = datetime.strptime(date_spec, "%Y-%m-%d") 406 | stop = start + timedelta(1) 407 | self.selector.lookup_date(start, stop) 408 | except ValueError as exc: 409 | raise SyntaxError(str(exc)) 410 | 411 | def _select_error(self, error_msg): 412 | """ 413 | Select mails by error message 414 | Specified error message can be partial 415 | Usage: select error 416 | """ 417 | self.selector.lookup_error(str(error_msg)) 418 | 419 | def _inspect_mails(self, *qids): 420 | """ 421 | Show mails content 422 | Usage: inspect mails [qid] ... 423 | """ 424 | mails = self.selector.get_mails_by_qids(qids) 425 | if not len(mails): 426 | return ['Mail IDs not found'] 427 | response = [] 428 | for mail in mails: 429 | mail.parse() 430 | if len(mail.parse_error): 431 | return [mail.parse_error] 432 | response.append(mail.show()) 433 | return response 434 | 435 | def do_show(self, str_arg): 436 | """ 437 | Generic viewer utility 438 | Optionnal modifiers can be provided to alter output: 439 | limit display the first n entries 440 | sortby [asc|desc] sort output by field asc or desc 441 | rankby Produce mails ranking by field 442 | Known fields: 443 | qid Postqueue mail ID 444 | date Mail date 445 | sender Mail sender 446 | recipients Mail recipients (list, no sort) 447 | size Mail size 448 | errors Postqueue deferred error messages (list, no sort) 449 | """ 450 | args = shlex.split(str_arg) 451 | if not len(args): 452 | return self.help_show() 453 | 454 | sub_cmd = args.pop(0) 455 | try: 456 | lines = getattr(self, "_show_%s" % sub_cmd)(*args) 457 | except (TypeError, AttributeError): 458 | self.respond("*** Syntax error: show {0}".format(str_arg)) 459 | return self.help_show() 460 | except SyntaxError as error: 461 | # Rewording Python TypeError message for cli display 462 | msg = str(error) 463 | if "%s()" % sub_cmd in msg: 464 | msg = "show command %s" % msg 465 | self.respond("*** Syntax error: " + msg) 466 | return self.help_show() 467 | 468 | self.respond("\n".join(lines)) 469 | 470 | @utils.viewer 471 | @utils.ranker 472 | @utils.sorter 473 | def _show_selected(self): 474 | """ 475 | Show selected mails 476 | Usage: show selected [modifiers] 477 | """ 478 | return self.selector.mails 479 | 480 | def _show_filters(self): 481 | """ 482 | Show filters applied on current mails selection 483 | Usage: show filters 484 | """ 485 | if not len(self.selector.filters): 486 | return ["No filters applied on current selection"] 487 | 488 | lines = [] 489 | for idx, pqfilter in enumerate(self.selector.filters): 490 | name, _args, _kwargs = pqfilter 491 | # name should always be prefixed with lookup_ 492 | lines.append('%d: select %s:' % (idx, name[7:])) 493 | for key in sorted(_kwargs): 494 | lines.append(" %s: %s" % (key, _kwargs[key])) 495 | return lines 496 | 497 | # Postsuper generic command 498 | def __do_super(self, operation): 499 | """Postsuper generic command""" 500 | if not self.pstore.loaded_at: 501 | return ["The store is not loaded"] 502 | if not len(self.selector.mails): 503 | return ["No mail selected"] 504 | else: 505 | func = getattr(self.qcontrol, '%s_messages' % operation) 506 | try: 507 | resp = func(self.selector.mails) 508 | except RuntimeError as exc: 509 | return [str(exc)] 510 | 511 | # reloads the data 512 | self._store_load() 513 | self._select_replay() 514 | 515 | return [resp[-1]] 516 | 517 | def _super_delete(self): 518 | """Deletes the mails in current selection 519 | Usage: super delete 520 | """ 521 | return self.__do_super('delete') 522 | 523 | def _super_hold(self): 524 | """Put on hold the mails in current selection 525 | Usage: super hold 526 | """ 527 | return self.__do_super('hold') 528 | 529 | def _super_release(self): 530 | """Releases from hold the mails in current selection 531 | Usage: super release 532 | """ 533 | return self.__do_super('release') 534 | 535 | def _super_requeue(self): 536 | """requeue the mails in current selection 537 | Usage: super requeue 538 | """ 539 | return self.__do_super('requeue') 540 | -------------------------------------------------------------------------------- /pymailq/store.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # Postfix queue control python tool (pymailq) 4 | # 5 | # Copyright (C) 2014 Denis Pompilio (jawa) 6 | # 7 | # This file is part of pymailq 8 | # 9 | # This program is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU General Public License 11 | # as published by the Free Software Foundation; either version 2 12 | # of the License, or (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, see . 21 | 22 | from __future__ import unicode_literals 23 | 24 | import sys 25 | import os 26 | import gc 27 | import re 28 | import subprocess 29 | import email 30 | from email import header 31 | from collections import Counter 32 | from datetime import datetime, timedelta 33 | from pymailq import CONFIG, debug 34 | 35 | 36 | class MailHeaders(object): 37 | """ 38 | Simple object to store mail headers. 39 | 40 | Object's attributes are dynamically created when parent :class:`~store.Mail` 41 | object's method :meth:`~store.Mail.parse` is called. Those attributes are 42 | retrieved with help of :func:`~email.message_from_string` method provided 43 | by the :mod:`email` module. 44 | 45 | Standard RFC *822-style* mail headers becomes attributes including but not 46 | limited to: 47 | 48 | - :mailheader:`Received` 49 | - :mailheader:`From` 50 | - :mailheader:`To` 51 | - :mailheader:`Cc` 52 | - :mailheader:`Bcc` 53 | - :mailheader:`Sender` 54 | - :mailheader:`Reply-To` 55 | - :mailheader:`Subject` 56 | 57 | Case is kept while creating attribute and access will be made with 58 | :attr:`Mail.From` or :attr:`Mail.Received` for example. All those 59 | attributes will return *list* of values. 60 | 61 | .. seealso:: 62 | 63 | Python modules: 64 | :mod:`email` -- An email and MIME handling package 65 | 66 | :class:`email.message.Message` -- Representing an email message 67 | 68 | :rfc:`822` -- Standard for ARPA Internet Text Messages 69 | """ 70 | 71 | 72 | class Mail(object): 73 | """ 74 | Simple object to manipulate email messages. 75 | 76 | This class provides the necessary methods to load and inspect mails 77 | content. This object functionnalities are mainly based on :mod:`email` 78 | module's provided class and methods. However, 79 | :class:`email.message.Message` instance's stored informations are 80 | extracted to extend :class:`~store.Mail` instances attributes. 81 | 82 | Initialization of :class:`~store.Mail` instances are made the following 83 | way: 84 | 85 | :param str mail_id: Mail's queue ID string 86 | :param int size: Mail size in Bytes (Default: ``0``) 87 | :param datetime.datetime date: Acceptance date and time in mails queue. 88 | (Default: :data:`None`) 89 | :param str sender: Mail sender string as seen in mails queue. 90 | (Default: empty :func:`str`) 91 | 92 | The :class:`~pymailq.Mail` class defines the following attributes: 93 | 94 | .. attribute:: qid 95 | 96 | Mail Postfix queue ID string, validated by 97 | :meth:`~store.PostqueueStore._is_mail_id` method. 98 | 99 | .. attribute:: size 100 | 101 | Mail size in bytes. Expected type is :func:`int`. 102 | 103 | .. attribute:: parsed 104 | 105 | :func:`bool` value to track if mail's content has been loaded from 106 | corresponding spool file. 107 | 108 | .. attribute:: parse_error 109 | 110 | Last encountered parse error message :func:`str`. 111 | 112 | .. attribute:: date 113 | 114 | :class:`~datetime.datetime` object of acceptance date and time in 115 | mails queue. 116 | 117 | .. attribute:: status 118 | 119 | Mail's queue status :func:`str`. 120 | 121 | .. attribute:: sender 122 | 123 | Mail's sender :func:`str` as seen in mails queue. 124 | 125 | .. attribute:: recipients 126 | 127 | Recipients :func:`list` as seen in mails queue. 128 | 129 | .. attribute:: errors 130 | 131 | Mail deliver errors :func:`list` as seen in mails queue. 132 | 133 | .. attribute:: head 134 | 135 | Mail's headers :class:`~store.MailHeaders` structure. 136 | 137 | .. attribute:: postcat_cmd 138 | 139 | This property use Postfix mails content parsing command defined in 140 | :attr:`pymailq.CONFIG` attribute under the key 'cat_message'. 141 | Command and arguments list is build on call with the configuration 142 | data. 143 | 144 | .. seealso:: 145 | 146 | :ref:`pymailq-configuration` 147 | """ 148 | 149 | def __init__(self, mail_id, size=0, date=None, sender=""): 150 | """Init method""" 151 | self.parsed = False 152 | self.parse_error = "" 153 | self.qid = mail_id 154 | self.date = date 155 | self.status = "" 156 | self.size = int(size) 157 | self.sender = sender 158 | self.recipients = [] 159 | self.errors = [] 160 | self.head = MailHeaders() 161 | 162 | # Getting optionnal status from postqueue mail_id 163 | postqueue_status = {'*': "active", '!': "hold"} 164 | if mail_id[-1] in postqueue_status: 165 | self.qid = mail_id[:-1] 166 | self.status = postqueue_status.get(mail_id[-1], "deferred") 167 | 168 | @property 169 | def postcat_cmd(self): 170 | """ 171 | Get the cat_message command from configuration 172 | :return: Command as :class:`list` 173 | """ 174 | postcat_cmd = CONFIG['commands']['cat_message'] + [self.qid] 175 | if CONFIG['commands']['use_sudo']: 176 | postcat_cmd.insert(0, 'sudo') 177 | return postcat_cmd 178 | 179 | def show(self): 180 | """ 181 | Return mail detailled representation for printing 182 | 183 | :return: Representation as :class:`str` 184 | """ 185 | output = "=== Mail %s ===\n" % (self.qid,) 186 | for attr in sorted(dir(self.head)): 187 | if attr.startswith("_"): 188 | continue 189 | 190 | value = getattr(self.head, attr) 191 | if not isinstance(value, str): 192 | value = ", ".join(value) 193 | 194 | if attr == "Subject": 195 | print(attr, value) 196 | value, enc = header.decode_header(value)[0] 197 | print(enc, attr, value) 198 | if sys.version_info[0] == 2: 199 | value = value.decode(enc) if enc else unicode(value) 200 | 201 | output += "%s: %s\n" % (attr, value) 202 | return output 203 | 204 | @debug 205 | def parse(self): 206 | """ 207 | Parse message content. 208 | 209 | This method use Postfix mails content parsing command defined in 210 | :attr:`~Mail.postcat_cmd` attribute. 211 | This command is runned using :class:`subprocess.Popen` instance. 212 | 213 | Parsed headers become attributes and are retrieved with help of 214 | :func:`~email.message_from_string` function provided by the 215 | :mod:`email` module. 216 | 217 | .. seealso:: 218 | 219 | Postfix manual: 220 | `postcat`_ -- Show Postfix queue file contents 221 | 222 | """ 223 | # Reset parsing error message 224 | self.parse_error = "" 225 | 226 | child = subprocess.Popen(self.postcat_cmd, 227 | stdout=subprocess.PIPE, 228 | stderr=subprocess.PIPE) 229 | stdout, stderr = child.communicate() 230 | 231 | if not len(stdout): 232 | # Ignore first 3 line on stderr which are: 233 | # postcat: name_mask: all 234 | # postcat: inet_addr_local: configured 3 IPv4 addresses 235 | # postcat: inet_addr_local: configured 3 IPv6 addresses 236 | self.parse_error = "\n".join(stderr.decode().split('\n')[3:]) 237 | return 238 | 239 | raw_content = "" 240 | for line in stdout.decode('utf-8', errors='replace').split('\n'): 241 | if self.size == 0 and line.startswith("message_size: "): 242 | self.size = int(line[14:].strip().split()[0]) 243 | elif self.date is None and line.startswith("create_time: "): 244 | self.date = datetime.strptime(line[13:].strip(), 245 | "%a %b %d %H:%M:%S %Y") 246 | elif not len(self.sender) and line.startswith("sender: "): 247 | self.sender = line[8:].strip() 248 | elif line.startswith("regular_text: "): 249 | raw_content += "%s\n" % (line[14:],) 250 | 251 | # For python2.7 compatibility, encode unicode to str 252 | if not isinstance(raw_content, str): 253 | raw_content = raw_content.encode('utf-8') 254 | 255 | message = email.message_from_string(raw_content) 256 | 257 | for mailheader in set(message.keys()): 258 | value = message.get_all(mailheader) 259 | setattr(self.head, mailheader, value) 260 | 261 | self.parsed = True 262 | 263 | @debug 264 | def dump(self): 265 | """ 266 | Dump mail's gathered informations to a :class:`dict` object. 267 | 268 | Mails informations are splitted in two parts in dictionnary. 269 | ``postqueue`` key regroups every informations directly gathered from 270 | Postfix queue, while ``headers`` regroups :class:`~store.MailHeaders` 271 | attributes converted from mail content with the 272 | :meth:`~store.Mail.parse` method. 273 | 274 | If mail has not been parsed with the :meth:`~store.Mail.parse` method, 275 | informations under the ``headers`` key will be empty. 276 | 277 | :return: Mail gathered informations 278 | :rtype: :class:`dict` 279 | """ 280 | datas = {'postqueue': {}, 281 | 'headers': {}} 282 | 283 | for attr in self.__dict__: 284 | if attr[0] != "_" and attr != 'head': 285 | datas['postqueue'].update({attr: getattr(self, attr)}) 286 | 287 | for mailheader in self.head.__dict__: 288 | if mailheader[0] != "_": 289 | datas['headers'].update( 290 | {mailheader: getattr(self.head, mailheader)} 291 | ) 292 | 293 | return datas 294 | 295 | 296 | class PostqueueStore(object): 297 | """ 298 | Postfix mails queue informations storage. 299 | 300 | The :class:`~store.PostqueueStore` provides methods to load Postfix 301 | queued mails informations into Python structures. Thoses structures are 302 | based on :class:`~store.Mail` and :class:`~store.MailHeaders` classes 303 | which can be processed by a :class:`~selector.MailSelector` instance. 304 | 305 | The :class:`~store.PostqueueStore` class defines the following attributes: 306 | 307 | .. attribute:: mails 308 | 309 | Loaded :class:`MailClass` objects :func:`list`. 310 | 311 | .. attribute:: loaded_at 312 | 313 | :class:`datetime.datetime` instance to store load date and time 314 | informations, useful for datas deprecation tracking. Updated on 315 | :meth:`~store.PostqueueStore.load` call with 316 | :meth:`datetime.datetime.now` method. 317 | 318 | .. attribute:: postqueue_cmd 319 | 320 | :obj:`list` object to store Postfix command and arguments to view 321 | the mails queue content. This property use Postfix mails content 322 | parsing command defined in :attr:`pymailq.CONFIG` attribute under 323 | the key 'list_queue'. Command and arguments list is build on call 324 | with the configuration data. 325 | 326 | .. attribute:: spool_path 327 | 328 | Postfix spool path string. 329 | Default is ``"/var/spool/postfix"``. 330 | 331 | .. attribute:: postqueue_mailstatus 332 | 333 | Postfix known queued mail status list. 334 | Default is ``['active', 'deferred', 'hold']``. 335 | 336 | .. attribute:: mail_id_re 337 | 338 | Python compiled regular expression object (:class:`re.RegexObject`) 339 | provided by :func:`re.compile` method to match postfix IDs. 340 | Recognized IDs are either: 341 | - hexadecimals, 8 to 12 chars length (regular queue IDs) 342 | - encoded in a 52-character alphabet, 11 to 16 chars length 343 | (long queue IDs) 344 | They can be followed with ``*`` or ``!``. 345 | Default used regular expression is: 346 | ``r"^([A-F0-9]{8,12}|[B-Zb-z0-9]{11,16})[*!]?$"``. 347 | 348 | .. attribute:: mail_addr_re 349 | 350 | Python compiled regular expression object (:class:`re.RegexObject`) 351 | provided by :func:`re.compile` method to match email addresses. 352 | Default used regular expression is: 353 | ``r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]+$"`` 354 | 355 | .. attribute:: MailClass 356 | 357 | The class used to manipulate/parse mails individually. 358 | Default is :class:`~store.Mail`. 359 | 360 | .. seealso:: 361 | 362 | Python modules: 363 | :mod:`datetime` -- Basic date and time types 364 | 365 | :mod:`re` -- Regular expression operations 366 | 367 | Postfix manual: 368 | `postqueue`_ -- Postfix queue control 369 | 370 | :rfc:`3696` -- Checking and Transformation of Names 371 | """ 372 | postqueue_cmd = None 373 | spool_path = None 374 | postqueue_mailstatus = ['active', 'deferred', 'hold'] 375 | mail_id_re = re.compile(r"^([A-F0-9]{8,12}|[B-Zb-z0-9]{11,16})[*!]?$") 376 | mail_addr_re = re.compile(r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]+$") 377 | MailClass = Mail 378 | 379 | def __init__(self): 380 | """Init method""" 381 | self.spool_path = CONFIG['core']['postfix_spool'] 382 | self.postqueue_cmd = CONFIG['commands']['list_queue'] 383 | if CONFIG['commands']['use_sudo']: 384 | self.postqueue_cmd.insert(0, 'sudo') 385 | 386 | self.loaded_at = None 387 | self.mails = [] 388 | 389 | @property 390 | @debug 391 | def known_headers(self): 392 | """Return known headers from loaded mails 393 | 394 | :return: headers as :func:`set` 395 | """ 396 | headers = set() 397 | for mail in self.mails: 398 | for mailheader in dir(mail.head): 399 | if not mailheader.startswith("_"): 400 | headers.add(mailheader) 401 | return headers 402 | 403 | @debug 404 | def _get_postqueue_output(self): 405 | """ 406 | Get Postfix postqueue command output. 407 | 408 | This method used the postfix command defined in 409 | :attr:`~PostqueueStore.postqueue_cmd` attribute to view the mails queue 410 | content. 411 | 412 | Command defined in :attr:`~PostqueueStore.postqueue_cmd` attribute is 413 | runned using a :class:`subprocess.Popen` instance. 414 | 415 | :return: Command's output lines. 416 | :rtype: :func:`list` 417 | 418 | .. seealso:: 419 | 420 | Python module: 421 | :mod:`subprocess` -- Subprocess management 422 | """ 423 | child = subprocess.Popen(self.postqueue_cmd, 424 | stdout=subprocess.PIPE) 425 | stdout = child.communicate()[0] 426 | 427 | # return lines list without the headers and footers 428 | return [line.strip() for line in stdout.decode().split('\n')][1:-2] 429 | 430 | def _is_mail_id(self, mail_id): 431 | """ 432 | Check mail_id for a valid postfix queued mail ID. 433 | 434 | Validation is made using a :class:`re.RegexObject` stored in 435 | the :attr:`~PostqueueStore.mail_id_re` attribute of the 436 | :class:`~store.PostqueueStore` instance. 437 | 438 | :param str mail_id: Mail Postfix queue ID string 439 | :return: True or false 440 | :rtype: :func:`bool` 441 | """ 442 | 443 | if self.mail_id_re.match(mail_id) is None: 444 | return False 445 | return True 446 | 447 | @debug 448 | def _load_from_postqueue(self, filename=None, parse=False): 449 | """ 450 | Load content from postfix queue using postqueue command output. 451 | 452 | Output lines from :attr:`~store.PostqueueStore._get_postqueue_output` 453 | are parsed to build :class:`~store.Mail` objects. Sample Postfix queue 454 | control tool (`postqueue`_) output:: 455 | 456 | C0004979687 4769 Tue Apr 29 06:35:05 sender@domain.com 457 | (error message from mx.remote1.org with parenthesis) 458 | first.rcpt@remote1.org 459 | (error message from mx.remote2.org with parenthesis) 460 | second.rcpt@remote2.org 461 | third.rcpt@remote2.org 462 | 463 | Parsing rules are pretty simple: 464 | 465 | - Line starts with a valid :attr:`Mail.qid`: create new 466 | :class:`~store.Mail` object with :attr:`~Mail.qid`, 467 | :attr:`~Mail.size`, :attr:`~Mail.date` and :attr:`~Mail.sender` 468 | informations from line. 469 | 470 | +-------------+------+---------------------------+-----------------+ 471 | | Queue ID | Size | Reception date and time | Sender | 472 | +-------------+------+-----+-----+----+----------+-----------------+ 473 | | C0004979687 | 4769 | Tue | Apr | 29 | 06:35:05 | user@domain.com | 474 | +-------------+------+-----+-----+----+----------+-----------------+ 475 | 476 | - Line starts with a parenthesis: store error messages to last created 477 | :class:`~store.Mail` object's :attr:`~Mail.errors` attribute. 478 | 479 | - Any other matches: add new recipient to the :attr:`~Mail.recipients` 480 | attribute of the last created :class:`~store.Mail` object. 481 | 482 | Optionnal argument ``filename`` can be set with a file containing 483 | output of the `postqueue`_ command. In this case, output lines of 484 | `postqueue`_ command are directly read from ``filename`` and parsed, 485 | the `postqueue`_ command is never used. 486 | 487 | Optionnal argument ``parse`` controls whether mails are parsed or not. 488 | This is useful to load every known mail headers for later filtering. 489 | 490 | :param str filename: File to load mails from 491 | :param bool parse: Controls whether loaded mails are parsed or not. 492 | """ 493 | if filename is None: 494 | postqueue_output = self._get_postqueue_output() 495 | else: 496 | postqueue_output = open(filename).readlines() 497 | 498 | mail = None 499 | for line in postqueue_output: 500 | line = line.strip() 501 | 502 | # Headers and footers start with dash (-) 503 | if line.startswith('-'): 504 | continue 505 | # Mails are blank line separated 506 | if not len(line): 507 | continue 508 | 509 | fields = line.split() 510 | if "(" == fields[0][0]: 511 | # Store error message without parenthesis: [1:-1] 512 | # gathered errors must be associated with specific recipients 513 | # TODO: change recipients or errors structures to link these 514 | # objects together. 515 | mail.errors.append(" ".join(fields)[1:-1]) 516 | else: 517 | if self._is_mail_id(fields[0]): 518 | # postfix does not precise year in mails timestamps so 519 | # we consider mails have been sent this year. 520 | # If gathered date is in the future: 521 | # mail has been received last year (or NTP problem). 522 | now = datetime.now() 523 | datestr = "{0} {1}".format(" ".join(fields[2:-1]), now.year) 524 | date = datetime.strptime(datestr, "%a %b %d %H:%M:%S %Y") 525 | if date > now: 526 | date = date - timedelta(days=365) 527 | 528 | mail = self.MailClass(fields[0], size=fields[1], 529 | date=date, 530 | sender=fields[-1]) 531 | self.mails.append(mail) 532 | else: 533 | # Email address validity check can be tricky. RFC3696 talks 534 | # about. Fow now, we use a simple regular expression to 535 | # match most of email addresses. 536 | rcpt_email_addr = " ".join(fields) 537 | if self.mail_addr_re.match(rcpt_email_addr): 538 | mail.recipients.append(rcpt_email_addr) 539 | 540 | if parse: 541 | print("parsing mails") 542 | [mail.parse() for mail in self.mails] 543 | 544 | @debug 545 | def _load_from_spool(self, parse=True): 546 | """ 547 | Load content from postfix queue using files from spool. 548 | 549 | Mails are loaded using the command defined in 550 | :attr:`~PostqueueStore.postqueue_cmd` attribute. Some informations may 551 | be missing using the :meth:`~store.PostqueueStore._load_from_spool` 552 | method, including at least :attr:`Mail.status` field. 553 | 554 | Optionnal argument ``parse`` controls whether mails are parsed or not. 555 | This is useful to load every known mail headers for later filtering. 556 | 557 | Loaded mails are stored as :class:`~store.Mail` objects in 558 | :attr:`~PostqueueStore.mails` attribute. 559 | 560 | :param bool parse: Controls whether loaded mails are parsed or not. 561 | 562 | .. warning:: 563 | 564 | Be aware that parsing mails on disk is slow and can lead to 565 | high load usage on system with large mails queue. 566 | """ 567 | for status in self.postqueue_mailstatus: 568 | for fs_data in os.walk("%s/%s" % (self.spool_path, status)): 569 | for mail_id in fs_data[2]: 570 | mail = self.MailClass(mail_id) 571 | mail.status = status 572 | 573 | mail.parse() 574 | 575 | self.mails.append(mail) 576 | 577 | @debug 578 | def _load_from_file(self, filename): 579 | """Unimplemented method""" 580 | 581 | @debug 582 | def load(self, method="postqueue", filename=None, parse=False): 583 | """ 584 | Load content from postfix mails queue. 585 | 586 | Mails are loaded using postqueue command line tool or reading directly 587 | from spool. The optionnal argument, if present, is a method string and 588 | specifies the method used to gather mails informations. By default, 589 | method is set to ``"postqueue"`` and the standard Postfix queue 590 | control tool: `postqueue`_ is used. 591 | 592 | Optionnal argument ``parse`` controls whether mails are parsed or not. 593 | This is useful to load every known mail headers for later filtering. 594 | 595 | :param str method: Method used to load mails from Postfix queue 596 | :param str filename: File to load mails from 597 | :param bool parse: Controls whether loaded mails are parsed or not. 598 | 599 | Provided method :func:`str` name is directly used with :func:`getattr` 600 | to find a *self._load_from_* method. 601 | """ 602 | # releasing memory 603 | del self.mails 604 | gc.collect() 605 | 606 | self.mails = [] 607 | if filename is None: 608 | getattr(self, "_load_from_{0}".format(method))(parse=parse) 609 | else: 610 | getattr(self, "_load_from_{0}".format(method))(filename, parse) 611 | self.loaded_at = datetime.now() 612 | 613 | @debug 614 | def summary(self): 615 | """ 616 | Summarize the mails queue content. 617 | 618 | :return: Mail queue summary as :class:`dict` 619 | 620 | Sizes are in bytes. 621 | 622 | Example response:: 623 | 624 | { 625 | 'total_mails': 500, 626 | 'total_mails_size': 709750, 627 | 'average_mail_size': 1419.5, 628 | 'max_mail_size': 2414, 629 | 'min_mail_size': 423, 630 | 'top_errors': [ 631 | ('mail transport unavailable', 484), 632 | ('Test error message', 16) 633 | ], 634 | 'top_recipient_domains': [ 635 | ('test-domain.tld', 500) 636 | ], 637 | 'top_recipients': [ 638 | ('user-3@test-domain.tld', 200), 639 | ('user-2@test-domain.tld', 200), 640 | ('user-1@test-domain.tld', 100) 641 | ], 642 | 'top_sender_domains': [ 643 | ('test-domain.tld', 500) 644 | ], 645 | 'top_senders': [ 646 | ('sender-1@test-domain.tld', 100), 647 | ('sender-2@test-domain.tld', 100), 648 | ('sender-7@test-domain.tld', 50), 649 | ('sender-4@test-domain.tld', 50), 650 | ('sender-5@test-domain.tld', 50) 651 | ], 652 | 'top_status': [ 653 | ('deferred', 500), 654 | ('active', 0), 655 | ('hold', 0) 656 | ], 657 | 'unique_recipient_domains': 1, 658 | 'unique_recipients': 3, 659 | 'unique_sender_domains': 1, 660 | 'unique_senders': 8 661 | } 662 | """ 663 | senders = Counter() 664 | sender_domains = Counter() 665 | recipients = Counter() 666 | recipient_domains = Counter() 667 | status = Counter(active=0, hold=0, deferred=0) 668 | errors = Counter() 669 | total_mails_size = 0 670 | average_mail_size = 0 671 | max_mail_size = 0 672 | min_mail_size = 0 673 | mails_by_age = { 674 | 'last_24h': 0, 675 | '1_to_4_days_ago': 0, 676 | 'older_than_4_days': 0 677 | } 678 | 679 | for mail in self.mails: 680 | status[mail.status] += 1 681 | senders[mail.sender] += 1 682 | if '@' in mail.sender: 683 | sender_domains[mail.sender.split('@', 1)[1]] += 1 684 | for recipient in mail.recipients: 685 | recipients[recipient] += 1 686 | if '@' in recipient: 687 | recipient_domains[recipient.split('@', 1)[1]] += 1 688 | for error in mail.errors: 689 | errors[error] += 1 690 | total_mails_size += mail.size 691 | if mail.size > max_mail_size: 692 | max_mail_size = mail.size 693 | if min_mail_size == 0: 694 | min_mail_size = mail.size 695 | elif mail.size < min_mail_size: 696 | min_mail_size = mail.size 697 | 698 | mail_age = datetime.now() - mail.date 699 | if mail_age.days >= 4: 700 | mails_by_age['older_than_4_days'] += 1 701 | elif mail_age.days == 1: 702 | mails_by_age['1_to_4_days_ago'] += 1 703 | elif mail_age.days == 0: 704 | mails_by_age['last_24h'] += 1 705 | 706 | if len(self.mails): 707 | average_mail_size = total_mails_size / len(self.mails) 708 | 709 | summary = { 710 | 'total_mails': len(self.mails), 711 | 'mails_by_age': mails_by_age, 712 | 'total_mails_size': total_mails_size, 713 | 'average_mail_size': average_mail_size, 714 | 'max_mail_size': max_mail_size, 715 | 'min_mail_size': min_mail_size, 716 | 'top_status': status.most_common()[:5], 717 | 'unique_senders': len(list(senders)), 718 | 'unique_sender_domains': len(list(sender_domains)), 719 | 'unique_recipients': len(list(recipients)), 720 | 'unique_recipient_domains': len(list(recipient_domains)), 721 | 'top_senders': senders.most_common()[:5], 722 | 'top_sender_domains': sender_domains.most_common()[:5], 723 | 'top_recipients': recipients.most_common()[:5], 724 | 'top_recipient_domains': recipient_domains.most_common()[:5], 725 | 'top_errors': errors.most_common()[:5] 726 | } 727 | return summary 728 | -------------------------------------------------------------------------------- /pymailq/utils.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # 3 | # Postfix queue control python tool (pymailq) 4 | # 5 | # Copyright (C) 2014 Denis Pompilio (jawa) 6 | # 7 | # This file is part of pymailq 8 | # 9 | # This program is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU General Public License 11 | # as published by the Free Software Foundation; either version 2 12 | # of the License, or (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, see . 21 | 22 | import re 23 | from functools import wraps 24 | from collections import Counter 25 | 26 | 27 | FORMAT_PARSER = re.compile(r'\{[^{}]+\}') 28 | FORMATS = { 29 | 'brief': "{date} {qid} [{status}] {sender} ({size}B)", 30 | 'long': ("{date} {qid} [{status}] {sender} ({size}B)\n" 31 | " Rcpt: {recipients}\n" 32 | " Err: {errors}") 33 | } 34 | 35 | 36 | def viewer(function): 37 | """Result viewer decorator 38 | 39 | :param func function: Function to decorate 40 | """ 41 | def wrapper(*args, **kwargs): 42 | args = list(args) # conversion need for arguments cleaning 43 | limit = None 44 | overhead = 0 45 | try: 46 | if "limit" in args: 47 | limit_idx = args.index('limit') 48 | args.pop(limit_idx) # pop option, next arg is value 49 | limit = int(args.pop(limit_idx)) 50 | except (IndexError, TypeError, ValueError): 51 | raise SyntaxError("limit modifier needs a valid number") 52 | 53 | output = "brief" 54 | for known in FORMATS: 55 | if known in args: 56 | output = args.pop(args.index(known)) 57 | break 58 | out_format = FORMATS[output] 59 | 60 | elements = function(*args, **kwargs) 61 | 62 | total_elements = len(elements) 63 | if not total_elements: 64 | return ["No element to display"] 65 | 66 | # Check for headers and increase limit accordingly 67 | headers = 0 68 | if total_elements > 1 and "========" in str(elements[1]): 69 | headers = 2 70 | 71 | if limit is not None: 72 | if total_elements > (limit + headers): 73 | overhead = total_elements - (limit + headers) 74 | else: 75 | limit = total_elements 76 | else: 77 | limit = total_elements 78 | 79 | out_format_attrs = FORMAT_PARSER.findall(out_format) 80 | formatted = [] 81 | for element in elements[:limit + headers]: 82 | # if attr qid exists, assume this is a mail 83 | if hasattr(element, "qid"): 84 | attrs = {} 85 | for att in out_format_attrs: 86 | if att == "{recipients}": 87 | rcpts = getattr(element, att[1:-1], ["-"]) 88 | attrs[att[1:-1]] = ", ".join(rcpts) 89 | elif att == "{errors}": 90 | errors = getattr(element, att[1:-1], ["-"]) 91 | attrs[att[1:-1]] = "\n".join(errors) 92 | else: 93 | attrs[att[1:-1]] = getattr(element, att[1:-1], "-") 94 | formatted.append(out_format.format(**attrs)) 95 | else: 96 | formatted.append(element) 97 | 98 | if overhead > 0: 99 | msg = "...Preview of first %d (%d more)..." % (limit, overhead) 100 | formatted.append(msg) 101 | 102 | return formatted 103 | wrapper.__doc__ = function.__doc__ 104 | return wrapper 105 | 106 | 107 | def sorter(function): 108 | """Result sorter decorator. 109 | 110 | This decorator inspect decorated function arguments and search for 111 | known keyword to sort decorated function result. 112 | """ 113 | @wraps(function) 114 | def wrapper(*args, **kwargs): 115 | args = list(args) # conversion need for arguments cleaning 116 | sortkey = "date" # default sort by date 117 | reverse = True # default sorting is desc 118 | if "sortby" in args: 119 | sortby_idx = args.index('sortby') 120 | args.pop(sortby_idx) # pop option, next arg is value 121 | 122 | try: 123 | sortkey = args.pop(sortby_idx) 124 | except IndexError: 125 | raise SyntaxError("sortby requires a field") 126 | 127 | # third param may be asc or desc, ignore unknown values 128 | try: 129 | if "asc" == args[sortby_idx]: 130 | args.pop(sortby_idx) 131 | reverse = False 132 | elif "desc" == args[sortby_idx]: 133 | args.pop(sortby_idx) 134 | except IndexError: 135 | pass 136 | 137 | elements = function(*args, **kwargs) 138 | 139 | try: 140 | sorted_elements = sorted(elements, 141 | key=lambda x: getattr(x, sortkey), 142 | reverse=reverse) 143 | except AttributeError: 144 | msg = "elements cannot be sorted by %s" % sortkey 145 | raise SyntaxError(msg) 146 | 147 | return sorted_elements 148 | wrapper.__doc__ = function.__doc__ 149 | return wrapper 150 | 151 | 152 | def ranker(function): 153 | """Result ranker decorator 154 | """ 155 | @wraps(function) 156 | def wrapper(*args, **kwargs): 157 | args = list(args) # conversion need for arguments cleaning 158 | rankkey = None 159 | if "rankby" in args: 160 | rankby_idx = args.index('rankby') 161 | args.pop(rankby_idx) # pop option, next arg is value 162 | 163 | try: 164 | rankkey = args.pop(rankby_idx) 165 | except IndexError: 166 | raise SyntaxError("rankby requires a field") 167 | 168 | elements = function(*args, **kwargs) 169 | 170 | if rankkey is not None: 171 | try: 172 | rank = Counter() 173 | for element in elements: 174 | rank[getattr(element, rankkey)] += 1 175 | 176 | # XXX: headers are taken in elements display limit :( 177 | ranked_elements = ['%-40s count' % rankkey, '='*48] 178 | for entry in rank.most_common(): 179 | key, value = entry 180 | ranked_elements.append('%-40s %s' % (key, value)) 181 | return ranked_elements 182 | 183 | except AttributeError: 184 | msg = "elements cannot be ranked by %s" % rankkey 185 | raise SyntaxError(msg) 186 | 187 | return elements 188 | wrapper.__doc__ = function.__doc__ 189 | return wrapper 190 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # 2 | # Postfix queue control python tool (pymailq) 3 | # 4 | # Copyright (C) 2014 Denis Pompilio (jawa) 5 | # 6 | # This file is part of pymailq 7 | # 8 | # This program is free software; you can redistribute it and/or 9 | # modify it under the terms of the GNU General Public License 10 | # as published by the Free Software Foundation; either version 2 11 | # of the License, or (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program; if not, see . 20 | 21 | import os 22 | from distutils.core import setup 23 | 24 | if __name__ == '__main__': 25 | readme_file = os.path.join(os.path.dirname(__file__), 'README.rst') 26 | release = "0.9.0" 27 | setup( 28 | name="pymailq", 29 | version=release, 30 | url="https://github.com/outini/pymailq", 31 | author="Denis Pompilio (jawa)", 32 | author_email="denis.pompilio@gmail.com", 33 | maintainer="Denis Pompilio (jawa)", 34 | maintainer_email="denis.pompilio@gmail.com", 35 | description="Postfix queue control python tool", 36 | long_description=open(readme_file).read(), 37 | license="GPLv2", 38 | platforms=['UNIX'], 39 | scripts=['bin/pqshell'], 40 | packages=['pymailq'], 41 | package_dir={'pymailq': 'pymailq'}, 42 | data_files=[('share/doc/pymailq', ['README.rst', 'LICENSE', 'CHANGES']), 43 | ('share/doc/pymailq/examples', [ 44 | 'share/doc/examples/pymailq.ini' 45 | ]), 46 | ('share/man/man1', ['man/pqshell.1'])], 47 | keywords=['postfix', 'shell', 'mailq', 'python', 'pqshell', 'postqueue'], 48 | classifiers=[ 49 | 'Development Status :: 5 - Production/Stable', 50 | 'Operating System :: POSIX :: BSD', 51 | 'Operating System :: POSIX :: Linux', 52 | 'License :: OSI Approved :: GNU General Public License v2 (GPLv2)', 53 | 'Programming Language :: Python', 54 | 'Environment :: Console', 55 | 'Topic :: Utilities', 56 | 'Topic :: Communications :: Email', 57 | 'Topic :: System :: Systems Administration', 58 | 'Topic :: System :: Shells' 59 | ] 60 | ) 61 | -------------------------------------------------------------------------------- /share/doc/examples/pymailq.ini: -------------------------------------------------------------------------------- 1 | [core] 2 | postfix_spool = /var/spool/postfix 3 | 4 | [commands] 5 | use_sudo = yes 6 | list_queue = mailq 7 | cat_message = postcat -qv 8 | hold_message = postsuper -h 9 | release_message = postsuper -H 10 | requeue_message = postsuper -r 11 | delete_message = postsuper -d 12 | -------------------------------------------------------------------------------- /tests/README.md: -------------------------------------------------------------------------------- 1 | # Testing PyMailq module 2 | 3 | Tests require a local installation of postfix and a set of generated mails. Those tests cover both pymailq core module and shell features. 4 | 5 | ## Preparing local system 6 | 7 | Install postfix and configure it to block any mail deliveries (mails are immediatly set as deferred) with a deferred transport. Then inject some mails samples, below is an example of postfix installation and configuration. 8 | 9 | ```sh 10 | apt-get update -qq 11 | sudo apt-get install -qq postfix 12 | echo "default_transport = hold" >> /etc/postfix/main.cf && 13 | /etc/init.d/postfix restart 14 | ``` 15 | 16 | A [sample generator](https://github.com/outini/pymailq/blob/master/tests/generate_samples.sh) is provided to fill postfix queue with proper mails for testing. This script will also check your postfix installation and ensure no mail is deliver by adding the _default_transport_ directive if necessary. It should run as root (read it first). 17 | 18 | ```sh 19 | ./tests/generate_samples.sh 20 | ``` 21 | 22 | ## Preparing python virtualenvs 23 | 24 | Tests require some external python module, you may use the provided [requirements file](https://github.com/outini/pymailq/blob/master/tests/tests.requirements.txt) to prepare your environnment. Pymailq supports both Python 2.7 and Python 3+ versions, don't forget to create multiple environnement to properly test it. 25 | 26 | ```sh 27 | pip install -r tests/tests.requirements.txt 28 | ``` 29 | 30 | Quick method for those using [pew](https://pypi.python.org/pypi/pew/): 31 | ```sh 32 | pew new -ppython2.7 -i "ipython<6" -d pymailq_27 33 | pew in pymailq_27 pip install -r tests/tests.requirements.txt 34 | ``` 35 | ```sh 36 | pew new -ppython3 -d pymailq_3 37 | pew in pymailq_3 pip install -r tests/tests.requirements.txt 38 | ``` 39 | 40 | ## Running tests 41 | 42 | Tests are based on the [py.test](https://docs.pytest.org) module. Run those in your virtualenv. Don't forget to generate samples before each run (tests end with mails deletion). 43 | 44 | ```sh 45 | PYTHONPATH=. pytest tests/ 46 | ``` 47 | 48 | For those using [pew](https://pypi.python.org/pypi/pew/): 49 | 50 | ```sh 51 | sudo ./tests/generate_samples.sh && 52 | PYTHONPATH=. pew in pymailq_27 pytest tests/ 53 | ``` 54 | ```sh 55 | sudo ./tests/generate_samples.sh && 56 | PYTHONPATH=. pew in pymailq_3 pytest tests/ 57 | ``` 58 | 59 | Tests are also used for code coverage. The python module [coverage](https://coverage.readthedocs.io) is already declared in the [requirements file](https://github.com/outini/pymailq/blob/master/tests/tests.requirements.txt) and should be installed in your virtualenv. 60 | 61 | ```sh 62 | PYTHONPATH=. coverage run --source=pymailq/ -m py.test tests/ 63 | coverage xml 64 | coverage html 65 | ``` 66 | 67 | The _xml_ file is mostly used to upload your coverage date to some sites like [Codacy](https://www.codacy.com) and its creation may be skipped. Coverage produces an html report to help you check your uncovered code. 68 | 69 | ```sh 70 | xdg-open htmlcov/index.html 71 | ``` 72 | -------------------------------------------------------------------------------- /tests/commands.txt: -------------------------------------------------------------------------------- 1 | show selected 2 | store load 3 | show selected 4 | select sender foo@bar.lol -------------------------------------------------------------------------------- /tests/generate_samples.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script need some root privileges to cleanup mails queue 4 | # or reconfigure and restart mail service. 5 | 6 | # You may use this script followed by pytest to generate a bunch of test mails. 7 | # Wait few seconds after the sample generation to let postfix stabilize his 8 | # queue, this is useful when generating thousand of mails. 9 | # sudo ./tests/generate_samples.sh 10 | # PYTHONPATH=. pytest -v tests/ 11 | 12 | # Cleanup all queued mails 13 | echo "cleaning queued messages." 14 | postsuper -d ALL 15 | 16 | # Sleep few seconds to let postfix cleanup his queue. 17 | # sleep 3 18 | 19 | # Lock postfix transports 20 | echo "checking postfix configuration." 21 | grep -q "default_transport" /etc/postfix/main.cf || { 22 | echo "reconfiguration of postfix." 23 | echo "default_transport = hold" >> /etc/postfix/main.cf && 24 | /etc/init.d/postfix restart 25 | } 26 | 27 | # Generate mails 28 | msg() { printf "This is test.\n%$1s\n"; } 29 | gen_mail() { 30 | sendmail -f "sender-$1@test-domain.tld" \ 31 | -r "sender-$1@test-domain.tld" \ 32 | "user-$2@test-domain.tld" < 37 | To: User $2 38 | Cc: Carbon User 39 | Subject: Test email from sender-$1 40 | 41 | é $3 42 | EOF 43 | } 44 | 45 | echo -n "injecting test mails " 46 | (for i in `seq 50`; do gen_mail 1 1 "`msg 2000`"; done; echo -n ".") & 47 | (for i in `seq 50`; do gen_mail 1 2 "`msg 10`"; done; echo -n ".") & 48 | (for i in `seq 50`; do gen_mail 2 1 "`msg 2000`"; done; echo -n ".") & 49 | (for i in `seq 50`; do gen_mail 2 3 "`msg 20`"; done; echo -n ".") & 50 | (for i in `seq 50`; do gen_mail 3 2 "`msg 2000`"; done; echo -n ".") & 51 | (for i in `seq 50`; do gen_mail 4 3 "`msg 10`"; done; echo -n ".") & 52 | (for i in `seq 50`; do gen_mail 5 2 "`msg 2000`"; done; echo -n ".") & 53 | (for i in `seq 50`; do gen_mail 6 3 "`msg 10`"; done; echo -n ".") & 54 | (for i in `seq 50`; do gen_mail 7 2 "`msg 2000`"; done; echo -n ".") & 55 | (for i in `seq 50`; do gen_mail 8 3 "`msg 10`"; done; echo -n ".") & 56 | wait 57 | echo " done." 58 | 59 | # Checks that mails queue is stabilized before proceeding further 60 | echo "waiting for queue stabilization." 61 | while [ `sendmail -bp | grep -c '^[A-F0-9]'` -ne 500 ]; do 62 | sleep 1 63 | done 64 | echo "generated `sendmail -bp | grep -c '^[A-F0-9]'` emails." 65 | 66 | # Modify error messages 67 | echo "setting error messages." 68 | i=0 69 | for mail in `find /var/spool/postfix/defer/ -type f` ; do 70 | if [ $i -le 15 ]; then 71 | sed -i "s/reason=.*/reason=Test error message/" "$mail" 72 | else 73 | break 74 | fi 75 | ((i++)) 76 | done 77 | -------------------------------------------------------------------------------- /tests/run_tests.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | 3 | usage() { 4 | cat < 5 | # 6 | # This file is part of pymailq 7 | # 8 | # This program is free software; you can redistribute it and/or 9 | # modify it under the terms of the GNU General Public License 10 | # as published by the Free Software Foundation; either version 2 11 | # of the License, or (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program; if not, see . 20 | 21 | import sys 22 | import pytest 23 | import pymailq 24 | from datetime import datetime 25 | from pymailq import store, control, selector 26 | 27 | try: 28 | from unittest.mock import Mock, patch 29 | except ImportError: 30 | from mock import Mock, patch 31 | 32 | 33 | pymailq.CONFIG['commands']['use_sudo'] = True 34 | 35 | PSTORE = store.PostqueueStore() 36 | SELECTOR = selector.MailSelector(PSTORE) 37 | QCONTROL = control.QueueControl() 38 | 39 | 40 | @patch('sys.stderr', new_callable=Mock()) 41 | def test_debug_decorator(stderr): 42 | """Test pymailq.debug decorator""" 43 | pymailq.DEBUG = True 44 | 45 | @pymailq.debug 46 | def test(): 47 | stderr.write("test\n") 48 | 49 | test() 50 | pymailq.DEBUG = False 51 | 52 | 53 | def test_load_config(): 54 | """Test pymailq.load_config method""" 55 | pymailq.CONFIG.update({"core": {}, "commands": {}}) 56 | pymailq.load_config("tests/samples/pymailq.ini") 57 | assert 'postfix_spool' in pymailq.CONFIG['core'] 58 | assert pymailq.CONFIG['commands']['use_sudo'] is True 59 | 60 | 61 | def test_store_load_from_spool(): 62 | """Test PostqueueStore load from spool""" 63 | PSTORE.load(method="spool") 64 | assert PSTORE.loaded_at is not None 65 | 66 | 67 | def test_store_load_from_filename(): 68 | """Test PostqueueStore load from file""" 69 | PSTORE.load(method="postqueue", filename="tests/samples/mailq.sample") 70 | assert PSTORE.loaded_at is not None 71 | 72 | 73 | def test_store_load_from_postqueue(): 74 | """Test PostqueueStore load from postqueue""" 75 | PSTORE.load() 76 | assert PSTORE.loaded_at is not None 77 | 78 | 79 | def test_store_summary(): 80 | """Test PostqueueStore.summary method""" 81 | summary = PSTORE.summary() 82 | assert 'top_senders' in summary 83 | assert 'top_recipients' in summary 84 | 85 | 86 | def test_mail_parse_and_dump(): 87 | """Test Mail.parse method""" 88 | pymailq.CONFIG['commands']['use_sudo'] = True 89 | mail = store.Mail(PSTORE.mails[0].qid) 90 | assert mail.parsed is False 91 | mail.parse() 92 | assert mail.parsed is True 93 | datas = mail.dump() 94 | assert "headers" in datas 95 | assert "postqueue" in datas 96 | 97 | 98 | def test_selector_get_mails_by_qids(): 99 | """Test MailSelector.get_mails_by_qids method""" 100 | pymailq.CONFIG['commands']['use_sudo'] = True 101 | SELECTOR.reset() 102 | qids = [mail.qid for mail in SELECTOR.mails[:2]] 103 | mails = SELECTOR.get_mails_by_qids(qids) 104 | assert len(mails) == 2 105 | assert qids[0] in mails[0].show() 106 | assert qids[1] in mails[1].show() 107 | 108 | 109 | def test_selector_qids(): 110 | """Test MailSelector.lookup_qids method""" 111 | SELECTOR.reset() 112 | mails = SELECTOR.lookup_qids([mail.qid for mail in PSTORE.mails[:2]]) 113 | assert type(mails) == list 114 | assert len(mails) == 2 115 | 116 | 117 | def test_selector_status(): 118 | """Test MailSelector.lookup_status method""" 119 | SELECTOR.reset() 120 | mails = SELECTOR.lookup_status(["deferred"]) 121 | assert type(mails) == list 122 | assert len(mails) == 500 123 | 124 | 125 | def test_selector_sender(): 126 | """Test MailSelector.lookup_sender method""" 127 | SELECTOR.reset() 128 | mails = SELECTOR.lookup_sender("sender-1", exact=False) 129 | assert type(mails) == list 130 | assert len(mails) == 100 131 | SELECTOR.reset() 132 | mails = SELECTOR.lookup_sender("sender-2@test-domain.tld") 133 | assert type(mails) == list 134 | assert len(mails) == 100 135 | 136 | 137 | def test_selector_recipient(): 138 | """Test MailSelector.lookup_recipient method""" 139 | SELECTOR.reset() 140 | mails = SELECTOR.lookup_recipient("user-1", exact=False) 141 | assert type(mails) == list 142 | assert len(mails) == 100 143 | SELECTOR.reset() 144 | mails = SELECTOR.lookup_recipient("user-2@test-domain.tld") 145 | assert type(mails) == list 146 | assert len(mails) == 200 147 | 148 | 149 | def test_selector_error(): 150 | """Test MailSelector.lookup_error method""" 151 | SELECTOR.reset() 152 | mails = SELECTOR.lookup_error("Test error message") 153 | assert type(mails) == list 154 | assert len(mails) == 16 155 | 156 | 157 | def test_selector_date(): 158 | """Test MailSelector.lookup_date method""" 159 | SELECTOR.reset() 160 | mails = SELECTOR.lookup_date(start=datetime(1970, 1, 1)) 161 | assert type(mails) == list 162 | assert len(mails) == 500 163 | SELECTOR.reset() 164 | mails = SELECTOR.lookup_date(stop=datetime.now()) 165 | assert type(mails) == list 166 | assert len(mails) == 500 167 | 168 | 169 | def test_selector_size(): 170 | """Test MailSelector.lookup_size method""" 171 | SELECTOR.reset() 172 | mails = SELECTOR.lookup_size() 173 | assert type(mails) == list 174 | assert len(mails) == 500 175 | SELECTOR.reset() 176 | mails = SELECTOR.lookup_size(smax=1000) 177 | assert type(mails) == list 178 | assert len(mails) == 250 179 | SELECTOR.reset() 180 | mails = SELECTOR.lookup_size(smin=1000) 181 | assert type(mails) == list 182 | assert len(mails) == 250 183 | 184 | 185 | def test_selector_replay_filters(): 186 | """Test MailSelector.replay_filters method""" 187 | SELECTOR.replay_filters() 188 | return True 189 | 190 | 191 | def test_selector_reset(): 192 | """Test MailSelector.reset method""" 193 | assert SELECTOR.mails != PSTORE.mails 194 | SELECTOR.reset() 195 | assert not len(SELECTOR.filters) 196 | assert SELECTOR.mails == PSTORE.mails 197 | 198 | 199 | def test_control_unknown_command(): 200 | """Test QueueControl._operate with unknown command""" 201 | orig_command = pymailq.CONFIG['commands']['hold_message'] 202 | pymailq.CONFIG['commands']['use_sudo'] = False 203 | pymailq.CONFIG['commands']['hold_message'] = ["invalid-cmd"] 204 | with pytest.raises(RuntimeError) as exc: 205 | QCONTROL.hold_messages([store.Mail('XXXXXXXXX')]) 206 | assert "Unable to call" in str(exc.value) 207 | pymailq.CONFIG['commands']['hold_message'] = orig_command 208 | 209 | 210 | def test_control_nothing_done(): 211 | """Test QueueControl on unexistent mail ID""" 212 | pymailq.CONFIG['commands']['use_sudo'] = True 213 | result = QCONTROL.hold_messages([store.Mail('XXXXXXXXX')]) 214 | assert type(result) == list 215 | assert len(result) == 1 216 | assert not len(result[0]) 217 | 218 | 219 | def test_control_hold(): 220 | """Test QueueControl.hold_messages""" 221 | pymailq.CONFIG['commands']['use_sudo'] = True 222 | result = QCONTROL.hold_messages(PSTORE.mails[-2:]) 223 | assert type(result) == list 224 | assert "postsuper: Placed on hold: 2 messages" in result 225 | 226 | 227 | def test_control_release(): 228 | """Test QueueControl.release_messages""" 229 | pymailq.CONFIG['commands']['use_sudo'] = True 230 | result = QCONTROL.release_messages(PSTORE.mails[-2:]) 231 | assert type(result) == list 232 | assert "postsuper: Released from hold: 2 messages" in result 233 | 234 | 235 | def test_control_requeue(): 236 | """Test QueueControl.requeue_messages""" 237 | pymailq.CONFIG['commands']['use_sudo'] = True 238 | result = QCONTROL.requeue_messages(PSTORE.mails[-2:]) 239 | assert "postsuper: Requeued: 2 messages" in result 240 | 241 | 242 | def test_control_delete(): 243 | """Test QueueControl.delete_messages""" 244 | pymailq.CONFIG['commands']['use_sudo'] = True 245 | # We don't really delete messages to keep queue consistence for next tests 246 | result = QCONTROL.delete_messages([]) 247 | assert type(result) == list 248 | assert not len(result[0]) 249 | -------------------------------------------------------------------------------- /tests/test_pymailq_shell.py: -------------------------------------------------------------------------------- 1 | # 2 | # Postfix queue control python tool (pymailq) 3 | # 4 | # Copyright (C) 2014 Denis Pompilio (jawa) 5 | # 6 | # This file is part of pymailq 7 | # 8 | # This program is free software; you can redistribute it and/or 9 | # modify it under the terms of the GNU General Public License 10 | # as published by the Free Software Foundation; either version 2 11 | # of the License, or (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program; if not, see . 20 | 21 | import sys 22 | from datetime import datetime, timedelta 23 | from pymailq import CONFIG, shell 24 | 25 | try: 26 | from unittest.mock import create_autospec 27 | except ImportError: 28 | from mock import create_autospec 29 | 30 | 31 | MOCK_STDOUT = create_autospec(sys.stdout) 32 | PQSHELL = shell.PyMailqShell(stdout=MOCK_STDOUT) 33 | PQSHELL.qcontrol.use_sudo = True 34 | 35 | 36 | def answer(): 37 | """Get shell response 38 | 39 | :return: Output lines as :func:`str` 40 | """ 41 | res = "" 42 | for call in MOCK_STDOUT.write.call_args_list: 43 | res += call[0][0] 44 | MOCK_STDOUT.reset_mock() 45 | return res.strip() 46 | 47 | 48 | def run_cmd(command): 49 | """Run command in shell 50 | 51 | :param str command: Shell command 52 | :return: Shell response as :func:`str` 53 | """ 54 | PQSHELL.onecmd(command) 55 | return answer() 56 | 57 | 58 | def test_shell_init(): 59 | """Test shell.PyMailqShell object""" 60 | assert hasattr(PQSHELL, "cmdloop_nointerrupt") 61 | assert PQSHELL.prompt == "PyMailq (sel:0)> " 62 | 63 | 64 | def test_shell_exit(): 65 | """Test shell.PyMailqShell object""" 66 | PQSHELL.cmdqueue = ['exit'] 67 | PQSHELL.cmdloop_nointerrupt() 68 | assert "Exiting shell... Bye." in answer() 69 | 70 | 71 | def test_shell_empty_line(): 72 | """Test empty line""" 73 | resp = run_cmd("") 74 | assert not len(resp) 75 | 76 | 77 | def test_shell_completion(): 78 | """Test shell completion""" 79 | resp = PQSHELL.completenames("sho") 80 | assert ["show "] == resp 81 | resp = PQSHELL.completedefault("invalid", "invalid") 82 | assert resp is None 83 | resp = PQSHELL.completedefault("re", "select re") 84 | assert ["recipient", "replay", "reset"] == sorted(resp) 85 | resp = PQSHELL.completedefault("sel", "show sel") 86 | assert ["selected"] == resp 87 | resp = PQSHELL.completedefault("", "show selected ") 88 | assert ["limit", "rankby", "sortby"] == sorted(resp) 89 | resp = PQSHELL.completedefault("", 90 | "show selected limit x rankby x sortby x") 91 | assert resp is None 92 | resp = PQSHELL.completedefault("", "show selected limit ") 93 | assert [" "] == resp 94 | resp = PQSHELL.completedefault("", "show selected limit 5 ") 95 | assert ["rankby", "sortby"] == sorted(resp) 96 | resp = PQSHELL.completedefault("sen", "select sen") 97 | assert ["sender "] == resp 98 | resp = PQSHELL.completedefault("", "select sender ") 99 | assert [" [exact]"] == resp 100 | 101 | 102 | def test_shell_help(): 103 | """Test 'help' command""" 104 | resp = run_cmd("help") 105 | assert "Documented commands" in resp 106 | 107 | 108 | def test_shell_help_help(): 109 | """Test 'help help' command""" 110 | resp = run_cmd("help help") 111 | assert "Show available commands" in resp 112 | 113 | 114 | def test_shell_help_exit(): 115 | """Test 'help exit' command""" 116 | resp = run_cmd("help exit") 117 | assert "Exit PyMailq shell" in resp 118 | 119 | 120 | def test_shell_help_show(): 121 | """Test 'help show' command""" 122 | resp = run_cmd("help show") 123 | assert "Generic viewer utility" in resp 124 | 125 | 126 | def test_shell_help_store(): 127 | """Test 'help store' command""" 128 | resp = run_cmd("help store") 129 | assert "Control of Postfix queue content storage" in resp 130 | 131 | 132 | def test_shell_help_select(): 133 | """Test 'help select' command""" 134 | resp = run_cmd("help select") 135 | assert "Select mails from Postfix queue content" in resp 136 | 137 | 138 | def test_shell_help_inspect(): 139 | """Test 'help inspect' command""" 140 | resp = run_cmd("help inspect") 141 | assert "Mail content inspector" in resp 142 | 143 | 144 | def test_shell_help_super(): 145 | """Test 'help super' command""" 146 | resp = run_cmd("help super") 147 | assert "Call postsuper commands" in resp 148 | 149 | 150 | def test_shell_store_status_unloaded(): 151 | """Test 'store status' command with unloaded store""" 152 | resp = run_cmd("store status") 153 | assert "store is not loaded" in resp 154 | 155 | 156 | def test_shell_store_load_error(): 157 | """Test 'store load' command""" 158 | resp = run_cmd("store load notfound.txt") 159 | assert "*** Error: unable to load store" in resp 160 | 161 | 162 | def test_shell_store_load(): 163 | """Test 'store load' command""" 164 | resp = run_cmd("store load") 165 | assert "mails loaded from queue" in resp 166 | 167 | 168 | def test_shell_store_status_loaded(): 169 | """Test 'store status' command with loaded store""" 170 | resp = run_cmd("store status") 171 | assert "store loaded with " in resp 172 | 173 | 174 | def test_shell_inspect_mails_not_found(): 175 | resp = run_cmd("inspect mails XXXXXXXX") 176 | assert 'Mail IDs not found' in resp 177 | 178 | 179 | def test_shell_inspect_mails(): 180 | """Test 'inspect mails' command""" 181 | CONFIG['commands']['use_sudo'] = True 182 | qids = [mail.qid for mail in PQSHELL.pstore.mails[0:2]] 183 | resp = run_cmd("inspect mails %s %s" % (qids[0], qids[1])) 184 | assert qids[0] in resp 185 | assert qids[1] in resp 186 | 187 | 188 | def test_shell_show(): 189 | """Test 'show' command without arguments""" 190 | resp = run_cmd("show") 191 | assert "Generic viewer utility" in resp 192 | 193 | 194 | def test_shell_show_invalid(): 195 | """Test 'show invalid' command""" 196 | resp = run_cmd("show invalid") 197 | assert "*** Syntax error: show invalid" in resp 198 | resp = run_cmd("show selected limit invalid") 199 | assert "*** Syntax error: limit modifier needs a valid number" in resp 200 | resp = run_cmd("show selected rankby") 201 | assert "*** Syntax error: rankby requires a field" in resp 202 | resp = run_cmd("show selected rankby invalid") 203 | assert "*** Syntax error: elements cannot be ranked by" in resp 204 | resp = run_cmd("show selected sortby") 205 | assert "*** Syntax error: sortby requires a field" in resp 206 | resp = run_cmd("show selected sortby invalid") 207 | assert "*** Syntax error: elements cannot be sorted by" in resp 208 | 209 | 210 | def test_shell_show_selected_limit(): 211 | """Test 'show selected limit 2' command""" 212 | resp = run_cmd("show selected limit 2") 213 | assert "Preview of first 2" in resp 214 | assert len(resp.split('\n')) == 3 215 | resp = run_cmd("show selected limit 10000") 216 | assert "Preview of first 10000" not in resp 217 | assert len(resp.split('\n')) == 500 218 | 219 | 220 | def test_shell_show_selected_sorted(): 221 | """Test 'show selected sortby sender limit 2' command""" 222 | resp = run_cmd("show selected sortby sender asc limit 2") 223 | assert "Preview of first 2" in resp 224 | assert len(resp.split('\n')) == 3 225 | resp = run_cmd("show selected sortby sender desc limit 2") 226 | assert "Preview of first 2" in resp 227 | assert len(resp.split('\n')) == 3 228 | 229 | 230 | def test_shell_show_selected_rankby(): 231 | """Test 'show selected rankby' command""" 232 | resp = run_cmd("show selected rankby sender limit 2") 233 | assert "sender" in resp 234 | assert len(resp.split('\n')) == 5 235 | 236 | 237 | def test_shell_show_selected_long_format(): 238 | """Test 'show selected format' command""" 239 | resp = run_cmd("show selected limit 2 long") 240 | assert len(resp.split('\n')) == 7 241 | 242 | 243 | def test_shell_show_filters_empty(): 244 | """Test 'show filters' command without registered filters""" 245 | resp = run_cmd("show filters") 246 | assert "No filters applied on current selection" in resp 247 | 248 | 249 | def test_shell_select(): 250 | """Test 'select' command""" 251 | resp = run_cmd("select") 252 | assert "Select mails from Postfix queue content" in resp 253 | 254 | 255 | def test_shell_select_sender(): 256 | """Test 'select sender' command""" 257 | assert 'Selector resetted' in run_cmd("select reset") 258 | assert not len(run_cmd("select sender sender-1")) 259 | resp = run_cmd("show selected") 260 | assert "sender-1@" in resp 261 | assert len(resp.split('\n')) == 100 262 | assert 'Selector resetted' in run_cmd("select reset") 263 | assert not len(run_cmd("select sender sender-1 exact")) 264 | resp = run_cmd("show selected") 265 | assert "No element to display" in resp 266 | resp = run_cmd("select sender sender-1 invalid") 267 | assert "invalid keyword: invalid" in resp 268 | 269 | 270 | def test_shell_select_recipient(): 271 | """Test 'select recipient' command""" 272 | assert 'Selector resetted' in run_cmd("select reset") 273 | assert not len(run_cmd("select recipient user-1")) 274 | resp = run_cmd("show selected") 275 | assert len(resp.split('\n')) == 100 276 | assert 'Selector resetted' in run_cmd("select reset") 277 | assert not len(run_cmd("select recipient user-1 exact")) 278 | resp = run_cmd("show selected") 279 | assert "No element to display" in resp 280 | resp = run_cmd("select recipient user-1 invalid") 281 | assert "invalid keyword: invalid" in resp 282 | 283 | 284 | def test_shell_select_invalid(): 285 | """Test 'select invalid' command""" 286 | resp = run_cmd("select invalid") 287 | assert "has no subcommand:" in resp 288 | 289 | 290 | def test_shell_select_qids(): 291 | """Test 'select qids' command""" 292 | assert 'mails loaded from queue' in run_cmd("store load") 293 | assert 'Selector resetted with store content' in run_cmd("select reset") 294 | qids = [mail.qid for mail in PQSHELL.pstore.mails[0:2]] 295 | resp = run_cmd("select qids %s %s" % (qids[0], qids[1])) 296 | assert not len(resp) 297 | assert len(PQSHELL.selector.mails) == 2 298 | 299 | 300 | def test_shell_select_status(): 301 | """Test 'select status' command""" 302 | resp = run_cmd("select status deferred") 303 | assert not len(resp) 304 | 305 | 306 | def test_shell_select_size(): 307 | """Test 'select size' command""" 308 | resp = run_cmd("select size XXX") 309 | assert "specified sizes must be valid numbers" in resp 310 | resp = run_cmd("select size 262 262") 311 | assert "exact size must be used alone" in resp 312 | resp = run_cmd("select size +262 +262") 313 | assert "multiple min sizes specified" in resp 314 | resp = run_cmd("select size -262 -262") 315 | assert "multiple max sizes specified" in resp 316 | resp = run_cmd("select size -263 +266") 317 | assert "minimum size is greater than maximum size" in resp 318 | assert 'mails loaded from queue' in run_cmd("store load") 319 | assert 'Selector resetted with store content' in run_cmd("select reset") 320 | assert not len(run_cmd("select size 1000")) 321 | resp = run_cmd("show selected") 322 | assert "No element to display" in resp 323 | assert 'Selector resetted with store content' in run_cmd("select reset") 324 | assert not len(run_cmd("select size +200")) 325 | resp = run_cmd("show selected") 326 | assert len(resp.split("\n")) == 500 327 | assert 'Selector resetted with store content' in run_cmd("select reset") 328 | assert not len(run_cmd("select size -1000")) 329 | resp = run_cmd("show selected") 330 | assert len(resp.split("\n")) == 250 331 | assert 'Selector resetted with store content' in run_cmd("select reset") 332 | assert not len(run_cmd("select size +200 -1000")) 333 | resp = run_cmd("show selected") 334 | assert len(resp.split("\n")) == 250 335 | 336 | 337 | def test_shell_select_date(): 338 | """Test 'select date' command""" 339 | five_days = timedelta(5) 340 | now = datetime.now().strftime('%Y-%m-%d') 341 | five_days_ago = (datetime.now() - five_days).strftime('%Y-%m-%d') 342 | in_five_days = (datetime.now() + five_days).strftime('%Y-%m-%d') 343 | assert 'mails loaded from queue' in run_cmd("store load") 344 | assert 'Selector resetted with store content' in run_cmd("select reset") 345 | assert not len(run_cmd("select date %s" % now)) 346 | resp = run_cmd("show selected") 347 | assert len(resp.split("\n")) == 500 348 | assert 'Selector resetted with store content' in run_cmd("select reset") 349 | assert not len(run_cmd("select date %s" % five_days_ago)) 350 | resp = run_cmd("show selected") 351 | assert "No element to display" in resp 352 | assert 'Selector resetted with store content' in run_cmd("select reset") 353 | assert not len(run_cmd("select date +%s" % five_days_ago)) 354 | resp = run_cmd("show selected") 355 | assert len(resp.split("\n")) == 500 356 | assert 'Selector resetted with store content' in run_cmd("select reset") 357 | assert not len(run_cmd("select date +%s" % in_five_days)) 358 | resp = run_cmd("show selected") 359 | assert "No element to display" in resp 360 | assert 'Selector resetted with store content' in run_cmd("select reset") 361 | assert not len(run_cmd("select date %s..%s" % (five_days_ago, 362 | in_five_days))) 363 | resp = run_cmd("show selected") 364 | assert len(resp.split("\n")) == 500 365 | assert 'Selector resetted with store content' in run_cmd("select reset") 366 | assert not len(run_cmd("select date -%s" % in_five_days)) 367 | resp = run_cmd("show selected") 368 | assert len(resp.split("\n")) == 500 369 | resp = run_cmd("select date XXXX-XX-XX") 370 | assert "'XXXX-XX-XX' does not match format '%Y-%m-%d'" in resp 371 | 372 | 373 | def test_shell_select_error(): 374 | """Test 'select date' command""" 375 | assert 'mails loaded from queue' in run_cmd("store load") 376 | assert 'Selector resetted with store content' in run_cmd("select reset") 377 | assert not len(run_cmd("select error 'Test error message'")) 378 | resp = run_cmd("show selected") 379 | assert len(resp.split("\n")) == 16 380 | 381 | 382 | def test_shell_show_filters(): 383 | """Test 'show filters' command with registered filters""" 384 | assert 'Selector resetted with store content' in run_cmd("select reset") 385 | assert not len(run_cmd("select status deferred")) 386 | expected = ("0: select status:\n" 387 | " status: deferred") 388 | resp = run_cmd("show filters") 389 | assert expected == resp 390 | 391 | 392 | def test_shell_select_replay(): 393 | """Test 'select replay' command""" 394 | resp = run_cmd("select replay") 395 | assert "Selector resetted and filters replayed" in resp 396 | 397 | 398 | def test_shell_select_rmfilter(): 399 | """Test 'select rmfilter' command""" 400 | resp = run_cmd("select rmfilter 0") 401 | assert not len(resp) 402 | resp = run_cmd("select rmfilter 666") 403 | assert "invalid filter ID: 666" in resp 404 | 405 | 406 | def test_shell_select_reset(): 407 | """Test 'select reset' command""" 408 | resp = run_cmd("select reset") 409 | assert "Selector resetted with store content" in resp 410 | 411 | 412 | def test_shell_super_unloaded_or_no_selection(): 413 | """Test QueueControl with an unloaded store""" 414 | loaded_at = PQSHELL.pstore.loaded_at 415 | setattr(PQSHELL.pstore, 'loaded_at', None) 416 | resp = run_cmd("super hold") 417 | assert 'The store is not loaded' in resp 418 | setattr(PQSHELL.pstore, 'loaded_at', loaded_at) 419 | setattr(PQSHELL.selector, 'mails', []) 420 | resp = run_cmd("super hold") 421 | assert 'No mail selected' in resp 422 | run_cmd("select reset") 423 | 424 | 425 | def test_shell_super_hold(): 426 | """Test 'select reset' command""" 427 | CONFIG['commands']['use_sudo'] = True 428 | resp = run_cmd("super hold") 429 | assert "postsuper: Placed on hold" in resp 430 | 431 | 432 | def test_shell_super_release(): 433 | """Test 'select reset' command""" 434 | CONFIG['commands']['use_sudo'] = True 435 | resp = run_cmd("super release") 436 | assert "postsuper: Released" in resp 437 | 438 | 439 | def test_shell_super_requeue(): 440 | """Test 'super requeue' command""" 441 | CONFIG['commands']['use_sudo'] = True 442 | resp = run_cmd("super requeue") 443 | assert "postsuper: Requeued" in resp 444 | 445 | 446 | def test_shell_super_delete(): 447 | """Test 'select reset' command""" 448 | CONFIG['commands']['use_sudo'] = True 449 | resp = run_cmd("super delete") 450 | assert "postsuper: Deleted" in resp 451 | -------------------------------------------------------------------------------- /tests/tests.requirements.txt: -------------------------------------------------------------------------------- 1 | ipython 2 | pytest 3 | mock 4 | coverage 5 | codacy-coverage 6 | --------------------------------------------------------------------------------