├── .gitignore ├── .travis.yml ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.rst ├── VERSION ├── docs ├── Makefile └── source │ ├── conf.py │ └── index.rst ├── livetest_settings.py.sample ├── mailbot ├── __init__.py ├── callback.py ├── compat.py ├── exceptions.py ├── livetests │ ├── __init__.py │ └── test_mail_received.py ├── mailbot.py └── tests │ ├── __init__.py │ ├── mails │ ├── mail_encoded_headers.txt │ └── mail_with_attachment.txt │ ├── test_callback.py │ ├── test_init.py │ └── test_mailbot.py ├── setup.cfg ├── setup.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # Local binaries (they are generated). 2 | bin/ 3 | include/ 4 | lib/ 5 | 6 | # sphinx 7 | docs/build/ 8 | 9 | # auto-generated 10 | *.pyc 11 | *.pyo 12 | *.egg-info 13 | .coverage 14 | .tox/ 15 | build/ 16 | share/ 17 | 18 | # local live test settings 19 | livetest_settings.py 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 2.7 3 | env: 4 | - TOX_ENV=py27 5 | - TOX_ENV=py33 6 | install: 7 | - pip install tox 8 | script: 9 | - tox -e $TOX_ENV 10 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.4 (unreleased) 5 | ---------------- 6 | 7 | 0.3 (2013-03-28) 8 | ---------------- 9 | 10 | - compatible python 3.3 11 | 12 | 13 | 0.2 (2013-03-28) 14 | ---------------- 15 | 16 | - automatically decode headers, allowing for unicode subjects, senders, 17 | recipients, CCs and body 18 | - new timeout parameter: mails in the processing state for longer than this 19 | timeout will be reprocessed 20 | - use SEEN and FLAGGED to manage mail states (not processed, processing, 21 | processed) 22 | - captures from the rules' regexps available in Callback.matches (previously, 23 | this was storing MatchObjects) 24 | 25 | 26 | 0.1 (2013-03-20) 27 | ---------------- 28 | 29 | - first version 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ####### 2 | License 3 | ####### 4 | 5 | Copyright (c) 2013 Mathieu Agopian 6 | 7 | All rights reserved. 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are 10 | met: 11 | 12 | * Redistributions of source code must retain the above copyright 13 | notice, this list of conditions and the following disclaimer. 14 | 15 | * Redistributions in binary form must reproduce the above copyright 16 | notice, this list of conditions and the following disclaimer in the 17 | documentation and/or other materials provided with the distribution. 18 | 19 | * Neither the name of the copyright holder nor the names of its 20 | contributors may be used to endorse or promote products derived from 21 | this software without specific prior written permission. 22 | 23 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 24 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 25 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 26 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 27 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 28 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 29 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 30 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 31 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 32 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 33 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include mailbot * 2 | global-exclude *.pyc 3 | include VERSION README.rst CHANGELOG LICENSE 4 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs test clean 2 | 3 | bin/python: 4 | virtualenv . --python python2 5 | bin/pip install -U setuptools 6 | bin/python setup.py develop 7 | 8 | test: bin/python 9 | bin/pip install tox 10 | bin/tox 11 | 12 | livetest: bin/python 13 | bin/pip install tox 14 | bin/tox -e py27-live,py33-live 15 | 16 | docs: 17 | bin/pip install sphinx 18 | SPHINXBUILD=../bin/sphinx-build $(MAKE) -C docs html $^ 19 | 20 | clean: 21 | rm -rf bin .tox include/ lib/ man/ mailbot.egg-info/ build/ 22 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ####### 2 | MailBot 3 | ####### 4 | 5 | .. image:: https://secure.travis-ci.org/magopian/mailbot.png?branch=master 6 | :alt: Build Status 7 | :target: https://travis-ci.org/magopian/mailbot 8 | 9 | MailBot: register callbacks to be executed on mail reception. 10 | 11 | * Authors: Mathieu Agopian and `contributors 12 | `_ 13 | * Licence: BSD 14 | * Compatibility: Python 2.7 and Python 3.3 15 | * Project URL: https://github.com/magopian/mailbot 16 | * Documentation: http://mailbot.rtfd.org/ 17 | 18 | 19 | Hacking 20 | ======= 21 | 22 | Setup your environment: 23 | 24 | :: 25 | 26 | git clone https://github.com/magopian/mailbot.git 27 | cd mailbot 28 | 29 | Hack and run the tests using `Tox `_ to test 30 | on all the supported python versions: 31 | 32 | :: 33 | 34 | make test 35 | 36 | There's also a live test suite, that you may run using the following command: 37 | 38 | :: 39 | 40 | make livetest 41 | 42 | Please note that to run live tests, you need to create a 43 | ``livetest_settings.py`` file with the following content: 44 | 45 | :: 46 | 47 | # mandatory 48 | HOST = 'your host here' 49 | USERNAME = 'your username here' 50 | PASSWORD = 'your password here' 51 | 52 | # optional 53 | # check http://imapclient.readthedocs.org/en/latest/#imapclient.IMAPClient) 54 | PORT = 143 # port number, usually 143 or 993 if ssl is enabled 55 | USE_UID = True 56 | SSL = False 57 | STREAM = False 58 | 59 | For convenience, you can copy the provided sample, and modify it: 60 | 61 | :: 62 | 63 | $ cp livetest_settings.py.sample livetest_settings.py 64 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.4dev 2 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | ifndef SPHINXBUILD 7 | SPHINXBUILD = sphinx-build 8 | endif 9 | PAPER = 10 | BUILDDIR = build 11 | 12 | # Internal variables. 13 | PAPEROPT_a4 = -D latex_paper_size=a4 14 | PAPEROPT_letter = -D latex_paper_size=letter 15 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 16 | # the i18n builder cannot share the environment and doctrees with the others 17 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 18 | 19 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 20 | 21 | help: 22 | @echo "Please use \`make ' where is one of" 23 | @echo " html to make standalone HTML files" 24 | @echo " dirhtml to make HTML files named index.html in directories" 25 | @echo " singlehtml to make a single large HTML file" 26 | @echo " pickle to make pickle files" 27 | @echo " json to make JSON files" 28 | @echo " htmlhelp to make HTML files and a HTML help project" 29 | @echo " qthelp to make HTML files and a qthelp project" 30 | @echo " devhelp to make HTML files and a Devhelp project" 31 | @echo " epub to make an epub" 32 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 33 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " linkcheck to check all external links for integrity" 41 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 42 | 43 | clean: 44 | -rm -rf $(BUILDDIR)/* 45 | 46 | html: 47 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 48 | @echo 49 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 50 | 51 | dirhtml: 52 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 53 | @echo 54 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 55 | 56 | singlehtml: 57 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 58 | @echo 59 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 60 | 61 | pickle: 62 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 63 | @echo 64 | @echo "Build finished; now you can process the pickle files." 65 | 66 | json: 67 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 68 | @echo 69 | @echo "Build finished; now you can process the JSON files." 70 | 71 | htmlhelp: 72 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 73 | @echo 74 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 75 | ".hhp project file in $(BUILDDIR)/htmlhelp." 76 | 77 | qthelp: 78 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 79 | @echo 80 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 81 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 82 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/mailbot.qhcp" 83 | @echo "To view the help file:" 84 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/mailbot.qhc" 85 | 86 | devhelp: 87 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 88 | @echo 89 | @echo "Build finished." 90 | @echo "To view the help file:" 91 | @echo "# mkdir -p $$HOME/.local/share/devhelp/mailbot" 92 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/mailbot" 93 | @echo "# devhelp" 94 | 95 | epub: 96 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 97 | @echo 98 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 99 | 100 | latex: 101 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 102 | @echo 103 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 104 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 105 | "(use \`make latexpdf' here to do that automatically)." 106 | 107 | latexpdf: 108 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 109 | @echo "Running LaTeX files through pdflatex..." 110 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 111 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 112 | 113 | text: 114 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 115 | @echo 116 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 117 | 118 | man: 119 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 120 | @echo 121 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 122 | 123 | texinfo: 124 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 125 | @echo 126 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 127 | @echo "Run \`make' in that directory to run these through makeinfo" \ 128 | "(use \`make info' here to do that automatically)." 129 | 130 | info: 131 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 132 | @echo "Running Texinfo files through makeinfo..." 133 | make -C $(BUILDDIR)/texinfo info 134 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 135 | 136 | gettext: 137 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 138 | @echo 139 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 140 | 141 | changes: 142 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 143 | @echo 144 | @echo "The overview file is in $(BUILDDIR)/changes." 145 | 146 | linkcheck: 147 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 148 | @echo 149 | @echo "Link check complete; look for any errors in the above output " \ 150 | "or in $(BUILDDIR)/linkcheck/output.txt." 151 | 152 | doctest: 153 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 154 | @echo "Testing of doctests in the sources finished, look at the " \ 155 | "results in $(BUILDDIR)/doctest/output.txt." 156 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # mailbot documentation build configuration file, created by 4 | # sphinx-quickstart on Wed Jan 23 17:31:52 2013. 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, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'mailbot' 44 | copyright = u'2013, Mathieu Agopian' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | 51 | # read the VERSION file which is three level up 52 | from os.path import join, dirname, abspath 53 | with open(join(dirname(dirname(dirname(abspath(__file__)))), 'VERSION')) as f: 54 | release = version = f.read() 55 | 56 | # The language for content autogenerated by Sphinx. Refer to documentation 57 | # for a list of supported languages. 58 | #language = None 59 | 60 | # There are two options for replacing |today|: either, you set today to some 61 | # non-false value, then it is used: 62 | #today = '' 63 | # Else, today_fmt is used as the format for a strftime call. 64 | #today_fmt = '%B %d, %Y' 65 | 66 | # List of patterns, relative to source directory, that match files and 67 | # directories to ignore when looking for source files. 68 | exclude_patterns = [] 69 | 70 | # The reST default role (used for this markup: `text`) to use for all documents. 71 | #default_role = None 72 | 73 | # If true, '()' will be appended to :func: etc. cross-reference text. 74 | #add_function_parentheses = True 75 | 76 | # If true, the current module name will be prepended to all description 77 | # unit titles (such as .. function::). 78 | #add_module_names = True 79 | 80 | # If true, sectionauthor and moduleauthor directives will be shown in the 81 | # output. They are ignored by default. 82 | #show_authors = False 83 | 84 | # The name of the Pygments (syntax highlighting) style to use. 85 | pygments_style = 'sphinx' 86 | 87 | # A list of ignored prefixes for module index sorting. 88 | #modindex_common_prefix = [] 89 | 90 | 91 | # -- Options for HTML output --------------------------------------------------- 92 | 93 | # The theme to use for HTML and HTML Help pages. See the documentation for 94 | # a list of builtin themes. 95 | html_theme = 'default' 96 | 97 | # Theme options are theme-specific and customize the look and feel of a theme 98 | # further. For a list of options available for each theme, see the 99 | # documentation. 100 | #html_theme_options = {} 101 | 102 | # Add any paths that contain custom themes here, relative to this directory. 103 | #html_theme_path = [] 104 | 105 | # The name for this set of Sphinx documents. If None, it defaults to 106 | # " v documentation". 107 | #html_title = None 108 | 109 | # A shorter title for the navigation bar. Default is the same as html_title. 110 | #html_short_title = None 111 | 112 | # The name of an image file (relative to this directory) to place at the top 113 | # of the sidebar. 114 | #html_logo = None 115 | 116 | # The name of an image file (within the static path) to use as favicon of the 117 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 118 | # pixels large. 119 | #html_favicon = None 120 | 121 | # Add any paths that contain custom static files (such as style sheets) here, 122 | # relative to this directory. They are copied after the builtin static files, 123 | # so a file named "default.css" will overwrite the builtin "default.css". 124 | html_static_path = ['_static'] 125 | 126 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 127 | # using the given strftime format. 128 | #html_last_updated_fmt = '%b %d, %Y' 129 | 130 | # If true, SmartyPants will be used to convert quotes and dashes to 131 | # typographically correct entities. 132 | #html_use_smartypants = True 133 | 134 | # Custom sidebar templates, maps document names to template names. 135 | #html_sidebars = {} 136 | 137 | # Additional templates that should be rendered to pages, maps page names to 138 | # template names. 139 | #html_additional_pages = {} 140 | 141 | # If false, no module index is generated. 142 | #html_domain_indices = True 143 | 144 | # If false, no index is generated. 145 | #html_use_index = True 146 | 147 | # If true, the index is split into individual pages for each letter. 148 | #html_split_index = False 149 | 150 | # If true, links to the reST sources are added to the pages. 151 | #html_show_sourcelink = True 152 | 153 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 154 | #html_show_sphinx = True 155 | 156 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 157 | #html_show_copyright = True 158 | 159 | # If true, an OpenSearch description file will be output, and all pages will 160 | # contain a tag referring to it. The value of this option must be the 161 | # base URL from which the finished HTML is served. 162 | #html_use_opensearch = '' 163 | 164 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 165 | #html_file_suffix = None 166 | 167 | # Output file base name for HTML help builder. 168 | htmlhelp_basename = 'mailbotdoc' 169 | 170 | 171 | # -- Options for LaTeX output -------------------------------------------------- 172 | 173 | latex_elements = { 174 | # The paper size ('letterpaper' or 'a4paper'). 175 | #'papersize': 'letterpaper', 176 | 177 | # The font size ('10pt', '11pt' or '12pt'). 178 | #'pointsize': '10pt', 179 | 180 | # Additional stuff for the LaTeX preamble. 181 | #'preamble': '', 182 | } 183 | 184 | # Grouping the document tree into LaTeX files. List of tuples 185 | # (source start file, target name, title, author, documentclass [howto/manual]). 186 | latex_documents = [ 187 | ('index', 'mailbot.tex', u'mailbot Documentation', 188 | u'Mathieu Agopian', 'manual'), 189 | ] 190 | 191 | # The name of an image file (relative to this directory) to place at the top of 192 | # the title page. 193 | #latex_logo = None 194 | 195 | # For "manual" documents, if this is true, then toplevel headings are parts, 196 | # not chapters. 197 | #latex_use_parts = False 198 | 199 | # If true, show page references after internal links. 200 | #latex_show_pagerefs = False 201 | 202 | # If true, show URL addresses after external links. 203 | #latex_show_urls = False 204 | 205 | # Documents to append as an appendix to all manuals. 206 | #latex_appendices = [] 207 | 208 | # If false, no module index is generated. 209 | #latex_domain_indices = True 210 | 211 | 212 | # -- Options for manual page output -------------------------------------------- 213 | 214 | # One entry per manual page. List of tuples 215 | # (source start file, name, description, authors, manual section). 216 | man_pages = [ 217 | ('index', 'mailbot', u'mailbot Documentation', 218 | [u'Mathieu Agopian'], 1) 219 | ] 220 | 221 | # If true, show URL addresses after external links. 222 | #man_show_urls = False 223 | 224 | 225 | # -- Options for Texinfo output ------------------------------------------------ 226 | 227 | # Grouping the document tree into Texinfo files. List of tuples 228 | # (source start file, target name, title, author, 229 | # dir menu entry, description, category) 230 | texinfo_documents = [ 231 | ('index', 'mailbot', u'mailbot Documentation', 232 | u'Mathieu Agopian', 'mailbot', 'One line description of project.', 233 | 'Miscellaneous'), 234 | ] 235 | 236 | # Documents to append as an appendix to all manuals. 237 | #texinfo_appendices = [] 238 | 239 | # If false, no module index is generated. 240 | #texinfo_domain_indices = True 241 | 242 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 243 | #texinfo_show_urls = 'footnote' 244 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Welcome to MailBot's documentation! 2 | ======================================= 3 | 4 | MailBot is a little python library that let's you execute previously registered 5 | callbacks on reception of emails. 6 | 7 | This allows you to do fancy things like doing API calls, running scripts, 8 | sending notifications, ... 9 | 10 | 11 | Features 12 | -------- 13 | 14 | MailBot does its best to: 15 | 16 | * be fully tested 17 | * apply the pep8 recommendations 18 | * be lightweight, concise and readable 19 | 20 | MailBot connects to a mail server using the IMAP protocol, thanks to the 21 | excellent `IMAPClient from Menno Smits 22 | `_. 23 | 24 | 25 | Other resources 26 | --------------- 27 | 28 | Fork it on: http://github.com/magopian/mailbot/ 29 | 30 | Documentation: http://mailbot.rtfd.org/ 31 | 32 | 33 | Installing 34 | ---------- 35 | 36 | From PyPI:: 37 | 38 | pip install mailbot 39 | 40 | From github:: 41 | 42 | pip install -e http://github.com/magopian/mailbot/ 43 | 44 | 45 | Usage 46 | ----- 47 | 48 | You first need to instantiate MailBot, giving it informations to connect to 49 | your IMAP server. MailBot uses `IMAPClient 50 | `_, and as such 51 | takes the same parameters. 52 | 53 | You also need to provide the username and password. Here's an simple example: 54 | 55 | .. code-block:: python 56 | 57 | from mailbot import MailBot, register 58 | 59 | from mycallbacks import MyCallback 60 | 61 | 62 | mailbot = MailBot('imap.myserver.com', 'username', 'password') 63 | 64 | # register your callback 65 | register(MyCallback) 66 | 67 | # check the unprocessed messages and trigger the callback 68 | mailbot.process_messages() 69 | 70 | You may want to place the ``process_messages`` in a loop, a celery task, or in 71 | a cron job, tu regularly check new messages and process them. 72 | 73 | 74 | Registering callbacks 75 | --------------------- 76 | 77 | :file:`callbacks.py`: 78 | 79 | .. code-block:: python 80 | 81 | from mailbot import register, Callback 82 | 83 | 84 | class MyCallback(Callback): 85 | 86 | def trigger(self): 87 | print("Mail received: {O}".format(self.subject)) 88 | 89 | register(MyCallback) 90 | 91 | By default, callbacks will be executed on each and every mail received, unless 92 | you specify it differently, either using the 'rules' attribute on the callback 93 | class, or by registering with those rules: 94 | 95 | 96 | Providing the rules as a parameter 97 | ---------------------------------- 98 | 99 | Here's a callback that will only be triggered if the subject matches the 100 | pattern 'Hello ' followed by a word, anywhere in the subject (it uses 101 | ``re.findall``): 102 | 103 | .. code-block:: python 104 | 105 | from mailbot import register, Callback 106 | 107 | 108 | class MyCallback(Callback): 109 | rules = {'subject': [r'Hello (\w)']} 110 | 111 | def trigger(self): 112 | print("Mail received for {0}".format(self.matches['subject'][0])) 113 | 114 | register(MyCallback) 115 | 116 | This callback will be triggered on a mail received with the subject "Hello 117 | Bryan", but won't if the subject is "Bye Bryan". 118 | 119 | 120 | Providing the rules when registering 121 | ------------------------------------ 122 | 123 | The similar functionality can be achieved using a set of rules when 124 | registering: 125 | 126 | .. code-block:: python 127 | 128 | from mailbot import register, Callback 129 | 130 | 131 | class MyCallback(Callback): 132 | 133 | def trigger(self): 134 | print("Mail received for %s!" self.matches['subject'][0]) 135 | 136 | register(MyCallback, rules={'subject': [r'Hello (\w)']}) 137 | 138 | 139 | How does it work? 140 | ----------------- 141 | 142 | When an email is received on the mail server the MailBot is connected to 143 | (using the IMAP protocol), it'll check all the registered callbacks and their 144 | rules. 145 | 146 | If each provided rule (either as a class parameter or using the register) 147 | matches the mail's subject, from, to, cc and body, the callback will be 148 | triggered. 149 | 150 | Mails are flagged according to their state, in the ``process_messages`` method: 151 | 152 | * unread (unseen): mail to be processed by MailBot 153 | * read (seen): 154 | 155 | - starred (flagged): MailBot is checking callbacks, and triggering them if 156 | needed, the mail is being processed 157 | - not starred (unflagged): MailBot is done with this mail, and won't process 158 | it anymore 159 | 160 | 161 | Specifying a timeout 162 | ~~~~~~~~~~~~~~~~~~~~ 163 | 164 | To avoid a mail from staying in the "processing" state for too long (for 165 | example because a previous ``process_message`` started processing it, but then 166 | failed), you may specify a ``timeout`` parameter (in seconds) when 167 | instantiating MailBot: 168 | 169 | .. code-block:: python 170 | 171 | from mailbot import MailBot 172 | 173 | 174 | mailbot = MailBot('imap.myserver.com', 'username', 'password', timeout=180) 175 | 176 | This doesn't mean that the mail will be reset after 3 minutes, but that when 177 | ``process_messages`` is called, it'll first reset mails that are in the 178 | processing state and older than 3 minutes. 179 | 180 | Specifying rules 181 | ---------------- 182 | 183 | Rules are regular expressions that will be tested against the various email 184 | data: 185 | 186 | * ``subject``: tested against the subject 187 | * ``from``: tested against the mail sender 188 | * ``to``: tested against each of the recipients in the "to" field 189 | * ``cc``: tested against each of the recipients in the "cc" field 190 | * ``body``: tested against the (text/plain) body of the mail 191 | 192 | If no rule are provided, for example for the "from" field, then no rule will be 193 | applied, and emails from any sender will potentially trigger the callback. 194 | 195 | For each piece of data (subject, from, to, cc, body), the callback class, 196 | once instantiated with the mail, and the ``check_rules`` method called, will 197 | have the attribute ``self.matches[item]`` set with all the captures from the 198 | given patterns, if any, or the full match. 199 | 200 | Here are example subjects for the subject rules: 201 | [``r'Hello (\w+), (.*)'``, ``r'[Hh]i (\w+)``] 202 | 203 | For each of the following examples, ``self.matches['subject']`` will be a list 204 | of all the captures for all the regular expressions. 205 | 206 | If a regular expression doesn't match, then it'll return an empty list. 207 | 208 | * 'Hello Bryan, how are you?': [('Bryan', 'how are you?')] 209 | * 'Hi Bryan, how are you?': ['Bryan'] 210 | * 'aloha, hi Bryan!': ['Bryan'] 211 | * 'aloha Bryan': rules not respected, callback not triggered, [] 212 | 213 | Here are example subjects for the subject rules (no captures): 214 | [``r'Hello \w+'``, ``r'[Hh]i \w+``] 215 | 216 | * 'Hello Bryan, how are you?': ['Hello Bryan'] 217 | * 'Hi Bryan, how are you?': ['Hi Bryan'] 218 | * 'aloha, hi Bryan!': ['hi Bryan'] 219 | * 'aloha Bryan': rules not respected, callback not triggered, [] 220 | 221 | 222 | Rules checking 223 | -------------- 224 | 225 | A callback will be triggered if the following applies: 226 | 227 | * for each item/rule, **any** of the provided regular expressions matches 228 | * **all** the rules (for all the provided items) are respected 229 | 230 | Notice the "any" and the "all" there: 231 | 232 | * for each rule, there may be several regular expressions. If any of those 233 | match, then the rule is respected. 234 | * if one rule doesn't match, the callback won't be triggered. Non existent 235 | rules don't count, so you could have a single rule on the subject, and none 236 | on the other items (from, to, cc, body). 237 | 238 | As an example, let's take an email with the subject "Hello Bryan", from 239 | "John@doe.com": 240 | 241 | .. code-block:: python 242 | 243 | from mailbot import register, Callback 244 | 245 | 246 | class MyCallback(Callback): 247 | rules = {'subject': [r'Hello (\w)', 'Hi!'], 'from': ['@doe.com']} 248 | 249 | def trigger(self): 250 | print("Mail received for {0}".format(self.matches['subject'][0])) 251 | 252 | register(MyCallback) 253 | 254 | All the rules are respected, and the callback will be triggered 255 | 256 | * subject: even though 'Hi!' isn't found anywhere in the subject, the other 257 | regular expression matches 258 | * from: the regular expression matches 259 | * to, cc, body: no rules provided, so they aren't taken into account 260 | 261 | The last bullet point also means that if register a callback with no rules at 262 | all, it'll be triggered on each and every email, making it a "catchall 263 | callback". 264 | -------------------------------------------------------------------------------- /livetest_settings.py.sample: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | """Settings used by the live tests to connect and login to an IMAP server.""" 4 | 5 | # mandatory 6 | HOST = 'your host here' 7 | USERNAME = 'your username here' 8 | PASSWORD = 'your password here' 9 | 10 | # optional 11 | # check http://imapclient.readthedocs.org/en/latest/#imapclient.IMAPClient 12 | PORT = None 13 | USE_UID = True 14 | SSL = False 15 | STREAM = False 16 | -------------------------------------------------------------------------------- /mailbot/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | pkg_resources = __import__('pkg_resources') 4 | distribution = pkg_resources.get_distribution('mailbot') 5 | 6 | __version__ = distribution.version 7 | 8 | 9 | from .callback import Callback # noqa 10 | from .exceptions import RegisterException # noqa 11 | from .mailbot import MailBot # noqa 12 | 13 | 14 | CALLBACKS_MAP = {} 15 | 16 | 17 | def register(callback_class, rules=None): 18 | """Register a callback class, optionnally with rules.""" 19 | if callback_class in CALLBACKS_MAP: 20 | raise RegisterException('%s is already registered' % callback_class) 21 | 22 | apply_rules = getattr(callback_class, 'rules', {}) 23 | if rules: 24 | apply_rules.update(rules) 25 | CALLBACKS_MAP[callback_class] = apply_rules 26 | return apply_rules 27 | -------------------------------------------------------------------------------- /mailbot/callback.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import absolute_import 4 | 5 | from collections import defaultdict 6 | from email.header import decode_header 7 | from re import findall 8 | 9 | from .compat import text_type, encoded_padding 10 | 11 | 12 | class Callback(object): 13 | """Base class for callbacks.""" 14 | 15 | def __init__(self, message, rules): 16 | self.matches = defaultdict(list) 17 | self.message = message 18 | self.rules = rules 19 | 20 | def check_rules(self, rules=None): 21 | """Does this message conform to the all the rules provided? 22 | 23 | For each item in the rules dictionnary (item, [regexp1, regexp2...]), 24 | call ``self.check_item``. 25 | 26 | """ 27 | if rules is None: 28 | rules = self.rules 29 | 30 | if not rules: # if no (or empty) rules, it's a catchall callback 31 | return True 32 | 33 | rules_tests = [self.check_item(item, regexps) 34 | for item, regexps in rules.items()] 35 | 36 | return all(rules_tests) # True only if at least one value is 37 | 38 | def check_item(self, item, regexps, message=None): 39 | """Search the email's item using the given regular expressions. 40 | 41 | Item is one of subject, from, to, cc, body. 42 | 43 | Store the result of searching the item with the regular expressions in 44 | self.matches[item]. If the search doesn't match anything, this will 45 | result in a None, otherwise it'll be a ``re.MatchObject``. 46 | 47 | """ 48 | if message is None: 49 | message = self.message 50 | 51 | if item not in message and item != 'body': # bad item, not found 52 | return None 53 | 54 | # if item is not in header, then item == 'body' 55 | if item == 'body': 56 | value = self.get_email_body(message) 57 | else: 58 | value = message[item] 59 | # decode header (might be encoded as latin-1, utf-8... 60 | value = encoded_padding.join( 61 | chunk.decode(encoding or 'ASCII') 62 | if not isinstance(chunk, text_type) else chunk 63 | for chunk, encoding in decode_header(value)) 64 | 65 | for regexp in regexps: # store all captures for easy access 66 | self.matches[item] += findall(regexp, value) 67 | 68 | return any(self.matches[item]) 69 | 70 | def get_email_body(self, message=None): 71 | """Return the message text body. 72 | 73 | Return the first 'text/plain' part of the email.Message that doesn't 74 | have a filename. 75 | 76 | """ 77 | if message is None: 78 | message = self.message 79 | 80 | if not hasattr(message, 'walk'): # not an email.Message instance? 81 | return None 82 | 83 | for part in message.walk(): 84 | content_type = part.get_content_type() 85 | filename = part.get_filename() 86 | if content_type == 'text/plain' and filename is None: 87 | # text body of the mail, not an attachment 88 | encoding = part.get_content_charset() or 'ASCII' 89 | content = part.get_payload() 90 | if not isinstance(content, text_type): 91 | content = part.get_payload(decode=True).decode(encoding) 92 | return content 93 | 94 | return '' 95 | 96 | def trigger(self): 97 | """Called when a mail matching the registered rules is received.""" 98 | raise NotImplementedError("Must be implemented in a child class.") 99 | -------------------------------------------------------------------------------- /mailbot/compat.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | 5 | # encoded padding is to cope with this ugly bug: 6 | # http://bugs.python.org/issue1467619 7 | # For some reason, in py27, the whitespace separating encoded pieces is eaten 8 | # up 9 | encoded_padding = '' 10 | text_type = str 11 | if sys.version < '3': 12 | encoded_padding = ' ' 13 | text_type = unicode # noqa 14 | -------------------------------------------------------------------------------- /mailbot/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | 4 | class RegisterException(Exception): 5 | """Exception raised on a registration error.""" 6 | -------------------------------------------------------------------------------- /mailbot/livetests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magopian/mailbot/642200dce8b34cfcce6276d76952b9454155f8b3/mailbot/livetests/__init__.py -------------------------------------------------------------------------------- /mailbot/livetests/test_mail_received.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from email import message_from_string 4 | from os.path import dirname, join 5 | 6 | from .. import register, MailBot, Callback 7 | from ..tests import MailBotTestCase 8 | 9 | try: 10 | import livetest_settings as settings 11 | except ImportError: 12 | raise ImportError('Please create a livetest_settings.py file following ' 13 | 'the example given here in the README ') 14 | 15 | 16 | class MailReceivedTest(MailBotTestCase): 17 | 18 | def setUp(self): 19 | super(MailReceivedTest, self).setUp() 20 | self.mb = MailBot(settings.HOST, settings.USERNAME, settings.PASSWORD, 21 | port=settings.PORT, use_uid=settings.USE_UID, 22 | ssl=settings.SSL, stream=settings.STREAM) 23 | self.home_folder = '__mailbot' 24 | self._delete_folder() 25 | self.mb.client.create_folder(self.home_folder) 26 | self.mb.client.select_folder(self.home_folder) 27 | 28 | def tearDown(self): 29 | super(MailReceivedTest, self).tearDown() 30 | self._delete_folder() 31 | 32 | def _delete_folder(self): 33 | """Delete an IMAP folder, if it exists.""" 34 | if self.mb.client.folder_exists(self.home_folder): 35 | self.mb.client.select_folder(self.home_folder) 36 | messages = self.mb.client.search('ALL') 37 | if messages: 38 | self.mb.client.remove_flags(messages, 39 | ['\\Seen', '\\Flagged']) 40 | self.mb.client.delete_messages(messages) 41 | self.mb.client.expunge() 42 | self.mb.client.delete_folder(self.home_folder) 43 | 44 | def test_get_message_ids(self): 45 | self.assertEqual(self.mb.get_message_ids(), []) 46 | 47 | self.mb.client.append(self.home_folder, 48 | message_from_string('').as_string()) 49 | self.assertEqual(self.mb.get_message_ids(), [1]) 50 | 51 | self.mb.client.append(self.home_folder, 52 | message_from_string('').as_string()) 53 | self.assertEqual(self.mb.get_message_ids(), [1, 2]) 54 | 55 | def test_get_messages(self): 56 | self.assertEqual(self.mb.get_messages(), {}) 57 | 58 | self.mb.client.append(self.home_folder, 59 | message_from_string('').as_string()) 60 | self.assertEqual( 61 | self.mb.get_messages(), 62 | {1: {'FLAGS': ('\\Seen',), 'SEQ': 1, 'RFC822': '\r\n'}}) 63 | 64 | self.mb.client.append(self.home_folder, 65 | message_from_string('').as_string()) 66 | self.assertEqual( 67 | self.mb.get_messages(), 68 | {2: {'FLAGS': ('\\Seen',), 'SEQ': 2, 'RFC822': '\r\n'}}) 69 | 70 | def test_mark_processing(self): 71 | self.mb.client.append(self.home_folder, 72 | message_from_string('').as_string()) 73 | ids = self.mb.client.search(['Unseen']) 74 | self.assertEqual(ids, [1]) 75 | 76 | self.mb.mark_processing(1) 77 | 78 | self.assertEquals(self.mb.client.get_flags([1]), 79 | {1: ('\\Flagged', '\\Seen')}) 80 | ids = self.mb.client.search(['Flagged', 'Seen']) 81 | self.assertEqual(ids, [1]) 82 | 83 | ids = self.mb.client.search(['Unseen']) 84 | self.assertEqual(ids, []) 85 | ids = self.mb.client.search(['Unflagged']) 86 | self.assertEqual(ids, []) 87 | 88 | def test_mark_processed(self): 89 | self.mb.client.append(self.home_folder, 90 | message_from_string('').as_string()) 91 | ids = self.mb.client.search(['Unseen']) 92 | self.assertEqual(ids, [1]) 93 | 94 | self.mb.mark_processed(1) 95 | 96 | self.assertEquals(self.mb.client.get_flags([1]), {1: ('\\Seen',)}) 97 | ids = self.mb.client.search(['Seen']) 98 | self.assertEqual(ids, [1]) 99 | 100 | ids = self.mb.client.search(['Flagged']) 101 | self.assertEqual(ids, []) 102 | 103 | def test_reset_timeout_messages(self): 104 | self.mb.timeout = -180 # 3 minutes in the future! 105 | self.mb.client.append(self.home_folder, 106 | message_from_string('').as_string()) 107 | ids = self.mb.client.search(['Unseen']) 108 | self.assertEqual(ids, [1]) 109 | 110 | self.mb.mark_processing(1) 111 | self.mb.reset_timeout_messages() 112 | 113 | self.assertEquals(self.mb.client.get_flags([1]), {1: ()}) 114 | 115 | def test_reset_timeout_messages_no_old_message(self): 116 | self.mb.timeout = 180 # 3 minutes ago 117 | self.mb.client.append(self.home_folder, 118 | message_from_string('').as_string()) 119 | ids = self.mb.client.search(['Unseen']) 120 | self.assertEqual(ids, [1]) 121 | 122 | self.mb.mark_processing(1) 123 | self.mb.reset_timeout_messages() 124 | 125 | # reset_timeout_messages didn't reset the message 126 | self.assertEquals(self.mb.client.get_flags([1]), 127 | {1: ('\\Flagged', '\\Seen')}) 128 | 129 | def test_process_messages(self): 130 | # real mail 131 | email_file = join(dirname(dirname(__file__)), 132 | 'tests', 'mails', 'mail_with_attachment.txt') 133 | email = open(email_file, 'r').read() 134 | self.mb.client.append(self.home_folder, email) 135 | 136 | # Callback with each rule matching the test mail 137 | # Each rule contains a non matching regexp, which shouldn't prevent the 138 | # callback from being triggered 139 | matching_rules = { 140 | 'subject': [r'Task name \w+', r'Task name (\w+)', 'NOMATCH'], 141 | 'to': [r'\w+\+\w+@example.com', r'(\w+)\+(\w+)@example.com', 142 | 'NOMATCH'], 143 | 'cc': [r'\w+@example.com', r'(\w+)@example.com', 'NOMATCH'], 144 | 'from': [r'\w+\.\w+@example.com', r'(\w+)\.(\w+)@example.com', 145 | 'NOMATCH'], 146 | 'body': [r'Mail content \w+', r'Mail content (\w+)', 147 | 'NOMATCH']} 148 | 149 | # Callback with each rule but one matching the test mail. 150 | # To prevent the callback from being triggered, at least one rule must 151 | # completely fail (have 0 regexp that matches). 152 | failing_rules = { 153 | 'subject': [r'Task name \w+', r'Task name (\w+)', 'NOMATCH'], 154 | 'to': [r'\w+\+\w+@example.com', r'(\w+)\+(\w+)@example.com', 155 | 'NOMATCH'], 156 | 'cc': [r'\w+@example.com', r'(\w+)@example.com', 'NOMATCH'], 157 | 'from': [r'\w+\.\w+@example.com', r'(\w+)\.(\w+)@example.com', 158 | 'NOMATCH'], 159 | 'body': ['NOMATCH', 'DOESNT MATCH EITHER']} # this rule fails 160 | 161 | class TestCallback(Callback): 162 | 163 | def __init__(self, message, rules): 164 | super(TestCallback, self).__init__(message, rules) 165 | self.called = False 166 | self.check_rules_result = False 167 | self.triggered = False 168 | 169 | def check_rules(self): 170 | res = super(TestCallback, self).check_rules() 171 | self.called = True 172 | self.check_rules_result = res 173 | return res 174 | 175 | def trigger(self): 176 | self.triggered = True 177 | 178 | matching_callback = TestCallback(message_from_string(email), 179 | matching_rules) 180 | 181 | def make_matching_callback(email, rules): 182 | return matching_callback 183 | 184 | failing_callback = TestCallback(message_from_string(email), 185 | failing_rules) 186 | 187 | def make_failing_callback(email, rules): 188 | return failing_callback 189 | 190 | register(make_matching_callback, matching_rules) 191 | register(make_failing_callback, failing_rules) 192 | 193 | self.mb.process_messages() 194 | 195 | self.assertTrue(matching_callback.called) 196 | self.assertTrue(matching_callback.check_rules_result) 197 | self.assertTrue(matching_callback.triggered) 198 | self.assertEqual(matching_callback.matches['subject'], 199 | ['Task name here', 'here']) 200 | self.assertEqual(matching_callback.matches['from'], 201 | ['foo.bar@example.com', ('foo', 'bar')]) 202 | self.assertEqual(matching_callback.matches['to'], 203 | ['foo+RANDOM_KEY@example.com', 204 | 'bar+RANDOM_KEY_2@example.com', 205 | ('foo', 'RANDOM_KEY'), 206 | ('bar', 'RANDOM_KEY_2')]) 207 | self.assertEqual(matching_callback.matches['cc'], 208 | ['foo@example.com', 209 | 'bar@example.com', 210 | 'foo', 'bar']) 211 | self.assertEqual(matching_callback.matches['body'], 212 | ['Mail content here', 'here']) 213 | 214 | self.assertTrue(failing_callback.called) 215 | self.assertFalse(failing_callback.check_rules_result) 216 | self.assertFalse(failing_callback.triggered) 217 | self.assertEqual(failing_callback.matches['body'], []) 218 | -------------------------------------------------------------------------------- /mailbot/mailbot.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from datetime import datetime, timedelta 4 | from email import message_from_string 5 | 6 | from imapclient import IMAPClient 7 | 8 | 9 | class MailBot(object): 10 | """MailBot mail class, where the magic is happening. 11 | 12 | Connect to the SMTP server using the IMAP protocol, for each unflagged 13 | message check which callbacks should be triggered, if any, but testing 14 | against the registered rules for each of them. 15 | 16 | """ 17 | home_folder = 'INBOX' 18 | imapclient = IMAPClient 19 | 20 | def __init__(self, host, username, password, port=None, use_uid=True, 21 | ssl=False, stream=False, timeout=None): 22 | """Create, connect and login the MailBot. 23 | 24 | All parameters except from ``timeout`` are used by IMAPClient. 25 | 26 | The timeout parameter is the number of seconds a mail is allowed to 27 | stay in the processing state. Mails older than this timeout will have 28 | their processing flag removed on the next ``process_messages`` run, 29 | allowing MailBot to try processing them again. 30 | 31 | """ 32 | self.client = self.imapclient(host, port=port, use_uid=use_uid, 33 | ssl=ssl, stream=stream) 34 | self.client.login(username, password) 35 | self.client.select_folder(self.home_folder) 36 | self.client.normalise_times = False # deal with UTC everywhere 37 | self.timeout = timeout 38 | 39 | def get_message_ids(self): 40 | """Return the list of IDs of messages to process.""" 41 | return self.client.search(['Unseen', 'Unflagged']) 42 | 43 | def get_messages(self): 44 | """Return the list of messages to process.""" 45 | ids = self.get_message_ids() 46 | return self.client.fetch(ids, ['RFC822']) 47 | 48 | def process_message(self, message, callback_class, rules): 49 | """Check if callback matches rules, and if so, trigger.""" 50 | callback = callback_class(message, rules) 51 | if callback.check_rules(): 52 | return callback.trigger() 53 | 54 | def process_messages(self): 55 | """Process messages: check which callbacks should be triggered.""" 56 | from . import CALLBACKS_MAP 57 | self.reset_timeout_messages() 58 | messages = self.get_messages() 59 | 60 | for uid, msg in messages.items(): 61 | self.mark_processing(uid) 62 | message = message_from_string(msg['RFC822']) 63 | for callback_class, rules in CALLBACKS_MAP.items(): 64 | self.process_message(message, callback_class, rules) 65 | self.mark_processed(uid) 66 | 67 | def reset_timeout_messages(self): 68 | """Remove the \\Flagged and \\Seen flags from mails that are too old. 69 | 70 | This makes sure that no mail stays in a processing state without 71 | actually being processed. This could happen if a callback timeouts, 72 | fails, if MailBot is killed before having finished the processing... 73 | 74 | """ 75 | if self.timeout is None: 76 | return 77 | 78 | ids = self.client.search(['Flagged', 'Seen']) 79 | messages = self.client.fetch(ids, ['INTERNALDATE']) 80 | 81 | # compare datetimes without tzinfo, as UTC 82 | date_pivot = datetime.utcnow() - timedelta(seconds=self.timeout) 83 | to_reset = [msg_id for msg_id, data in messages.items() 84 | if data['INTERNALDATE'].replace(tzinfo=None) < date_pivot] 85 | 86 | if to_reset: 87 | self.client.remove_flags(to_reset, ['\\Flagged', '\\Seen']) 88 | 89 | def mark_processing(self, uid): 90 | """Mark the message corresponding to uid as being processed.""" 91 | self.client.add_flags([uid], ['\\Flagged', '\\Seen']) 92 | 93 | def mark_processed(self, uid): 94 | """Mark the message corresponding to uid as processed.""" 95 | self.client.remove_flags([uid], ['\\Flagged']) 96 | self.client.add_flags([uid], ['\\Seen']) 97 | -------------------------------------------------------------------------------- /mailbot/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from unittest2 import TestCase 4 | 5 | from .. import CALLBACKS_MAP 6 | 7 | 8 | class MailBotTestCase(TestCase): 9 | """TestCase that restores the CALLBACKS_MAP after each test run.""" 10 | 11 | def setUp(self): 12 | self.callbacks_map_save = CALLBACKS_MAP.copy() 13 | 14 | def tearDown(self): 15 | CALLBACKS_MAP = self.callbacks_map_save # noqa 16 | -------------------------------------------------------------------------------- /mailbot/tests/mails/mail_encoded_headers.txt: -------------------------------------------------------------------------------- 1 | Delivered-To: foo+RANDOM_KEY@example.com 2 | Received: by 10.194.34.7 with SMTP id v7csp101053wji; 3 | Fri, 15 Mar 2013 02:28:52 -0700 (PDT) 4 | Return-Path: 5 | Received-SPF: pass (example.com: domain of foo+RANDOM_KEY@example.com designates 1.2.3.4 as permitted sender) client-ip=1.2.3.4 6 | Authentication-Results: mr.google.com; 7 | spf=pass (example.com: domain of foo+RANDOM_KEY@example.com designates 1.2.3.4 as permitted sender) smtp.mail=foo+RANDOM_KEY@example.com; 8 | dkim=pass header.i=@example.com 9 | X-Received: from mr.google.com ([10.182.31.109]) 10 | by 10.182.31.109 with SMTP id z13mr2632031obh.37.1363339731787 (num_hops = 1); 11 | Fri, 15 Mar 2013 02:28:51 -0700 (PDT) 12 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 13 | d=example.com; s=20120113; 14 | h=mime-version:x-received:date:message-id:subject:from:to 15 | :content-type; 16 | bh=EDdIiN1bkSUqRxA5ZGCbAxWo/K7ayqdf9ZDEQqAGvDU=; 17 | b=nAVPcbc78q8Uyq8ENfiLD4R1x0Oi7kw5nMAI+eppmCqPxzeM2FITiyyz8M2WQ8rnJl 18 | 28ONzknzAEXl6Hm09EDmwgrVLXxM+x2fbNQ8DWkXtFx+3GlOP0OlE2KC2ObWZK2BxVo0 19 | FIEsAZpt/mH4KikhOsHR6J868f/vB/0W6M7JtQGzFhbd6xjEbETDIVlPloYfmZBHs4Rp 20 | nO7fP/VBRvWLFV/VK/OlYVXdS0FhptdCV7Zd4UKTIg5kd6rlAaZuW0KhGe6RXr0ou+aU 21 | nqq0vSoMVK7BeKKGsA61f4YJ5qTAx4eSbOw8mYhQtnLI7qoNrS4h8iiXLWoNnxCEW9UI 22 | YBXA== 23 | MIME-Version: 1.0 24 | X-Received: by 10.182.31.109 with SMTP id z13mr2632031obh.37.1363339731783; 25 | Fri, 15 Mar 2013 02:28:51 -0700 (PDT) 26 | Received: by 10.182.98.129 with HTTP; Fri, 15 Mar 2013 02:28:51 -0700 (PDT) 27 | Date: Fri, 15 Mar 2013 10:28:51 +0100 28 | Message-ID: 29 | Subject: =?UTF-8?Q?test_cr=C3=A9ation_bannette?= 30 | From: Foo Bar 31 | To: =?ISO-8859-1?Q?test_cr=E9ation?= 32 | Cc: foo@example.com, bar@example.com 33 | Content-Type: multipart/mixed; boundary=14dae93b5c806bd71504d7f3442a 34 | 35 | --14dae93b5c806bd71504d7f3442a 36 | Content-Type: multipart/alternative; boundary=14dae93b5c806bd71204d7f34428 37 | 38 | --14dae93b5c806bd71204d7f34428 39 | Content-Type: text/plain; charset=UTF-8 40 | 41 | Test de création de bannette 42 | 43 | --14dae93b5c806bd71204d7f34428 44 | Content-Type: text/html; charset=UTF-8 45 | 46 |
Test de création de bannette
47 | 48 | --14dae93b5c806bd71204d7f34428-- 49 | --14dae93b5c806bd71504d7f3442a 50 | Content-Type: text/plain; charset=US-ASCII; name="test.txt" 51 | Content-Disposition: attachment; filename="test.txt" 52 | Content-Transfer-Encoding: base64 53 | X-Attachment-Id: f_heb58ogq0 54 | 55 | dGVzdCBmaWxlCg== 56 | --14dae93b5c806bd71504d7f3442a-- 57 | -------------------------------------------------------------------------------- /mailbot/tests/mails/mail_with_attachment.txt: -------------------------------------------------------------------------------- 1 | Delivered-To: foo+RANDOM_KEY@example.com 2 | Received: by 10.194.34.7 with SMTP id v7csp101053wji; 3 | Fri, 15 Mar 2013 02:28:52 -0700 (PDT) 4 | Return-Path: 5 | Received-SPF: pass (example.com: domain of foo+RANDOM_KEY@example.com designates 1.2.3.4 as permitted sender) client-ip=1.2.3.4 6 | Authentication-Results: mr.google.com; 7 | spf=pass (example.com: domain of foo+RANDOM_KEY@example.com designates 1.2.3.4 as permitted sender) smtp.mail=foo+RANDOM_KEY@example.com; 8 | dkim=pass header.i=@example.com 9 | X-Received: from mr.google.com ([10.182.31.109]) 10 | by 10.182.31.109 with SMTP id z13mr2632031obh.37.1363339731787 (num_hops = 1); 11 | Fri, 15 Mar 2013 02:28:51 -0700 (PDT) 12 | DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; 13 | d=example.com; s=20120113; 14 | h=mime-version:x-received:date:message-id:subject:from:to 15 | :content-type; 16 | bh=EDdIiN1bkSUqRxA5ZGCbAxWo/K7ayqdf9ZDEQqAGvDU=; 17 | b=nAVPcbc78q8Uyq8ENfiLD4R1x0Oi7kw5nMAI+eppmCqPxzeM2FITiyyz8M2WQ8rnJl 18 | 28ONzknzAEXl6Hm09EDmwgrVLXxM+x2fbNQ8DWkXtFx+3GlOP0OlE2KC2ObWZK2BxVo0 19 | FIEsAZpt/mH4KikhOsHR6J868f/vB/0W6M7JtQGzFhbd6xjEbETDIVlPloYfmZBHs4Rp 20 | nO7fP/VBRvWLFV/VK/OlYVXdS0FhptdCV7Zd4UKTIg5kd6rlAaZuW0KhGe6RXr0ou+aU 21 | nqq0vSoMVK7BeKKGsA61f4YJ5qTAx4eSbOw8mYhQtnLI7qoNrS4h8iiXLWoNnxCEW9UI 22 | YBXA== 23 | MIME-Version: 1.0 24 | X-Received: by 10.182.31.109 with SMTP id z13mr2632031obh.37.1363339731783; 25 | Fri, 15 Mar 2013 02:28:51 -0700 (PDT) 26 | Received: by 10.182.98.129 with HTTP; Fri, 15 Mar 2013 02:28:51 -0700 (PDT) 27 | Date: Fri, 15 Mar 2013 10:28:51 +0100 28 | Message-ID: 29 | Subject: Task name here 30 | From: Foo Bar 31 | To: foo+RANDOM_KEY@example.com, bar+RANDOM_KEY_2@example.com 32 | Cc: foo@example.com, bar@example.com 33 | Content-Type: multipart/mixed; boundary=14dae93b5c806bd71504d7f3442a 34 | 35 | --14dae93b5c806bd71504d7f3442a 36 | Content-Type: multipart/alternative; boundary=14dae93b5c806bd71204d7f34428 37 | 38 | --14dae93b5c806bd71204d7f34428 39 | Content-Type: text/plain; charset=UTF-8 40 | 41 | Mail content here 42 | 43 | --14dae93b5c806bd71204d7f34428 44 | Content-Type: text/html; charset=UTF-8 45 | 46 |
Mail content here
47 | 48 | --14dae93b5c806bd71204d7f34428-- 49 | --14dae93b5c806bd71504d7f3442a 50 | Content-Type: text/plain; charset=US-ASCII; name="test.txt" 51 | Content-Disposition: attachment; filename="test.txt" 52 | Content-Transfer-Encoding: base64 53 | X-Attachment-Id: f_heb58ogq0 54 | 55 | dGVzdCBmaWxlCg== 56 | --14dae93b5c806bd71504d7f3442a-- 57 | -------------------------------------------------------------------------------- /mailbot/tests/test_callback.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from email import message_from_file, message_from_string 4 | from os.path import dirname, join 5 | 6 | from mock import Mock 7 | 8 | from . import MailBotTestCase 9 | from .. import Callback 10 | 11 | 12 | class CallbackTest(MailBotTestCase): 13 | 14 | def test_init(self): 15 | callback = Callback('foo', 'bar') 16 | callback.get_email_body = lambda x: None # mock 17 | 18 | self.assertEqual(callback.message, 'foo') 19 | self.assertEqual(callback.rules, 'bar') 20 | 21 | def test_check_rules(self): 22 | callback = Callback('foo', 'bar') 23 | # naive mock: return "regexps". This means that callback.matches should 24 | # always be the same as callback.rules 25 | callback.check_item = lambda x, y: y 26 | 27 | # no rules registered: catchall callback 28 | callback.rules = {} 29 | self.assertEqual(callback.check_rules(), True) 30 | self.assertEqual(callback.check_rules({}), True) 31 | 32 | # no rules respected 33 | callback.rules = {'foo': False, 'bar': [], 'baz': None} 34 | self.assertEqual(callback.check_rules(), False) 35 | 36 | # not all rules respected 37 | callback.rules = {'foo': True, 'bar': [], 'baz': None} 38 | self.assertEqual(callback.check_rules(), False) 39 | 40 | # all rules respected 41 | callback.rules = {'foo': True, 'bar': ['test'], 'baz': 'barf'} 42 | self.assertEqual(callback.check_rules(), True) 43 | 44 | def test_check_item_non_existent(self): 45 | empty = message_from_string('') 46 | callback = Callback(empty, {}) 47 | 48 | # item does not exist 49 | self.assertEqual(callback.check_item('foobar', ['.*'], empty), None) 50 | self.assertEqual(callback.check_item('foobar', ['(.*)']), None) 51 | 52 | def test_check_item_subject(self): 53 | email_file = join(dirname(__file__), 'mails/mail_with_attachment.txt') 54 | email = message_from_file(open(email_file, 'r')) 55 | callback = Callback(email, {}) 56 | 57 | self.assertFalse(callback.check_item('subject', [])) 58 | self.assertEqual(callback.matches['subject'], []) 59 | 60 | self.assertFalse(callback.check_item('subject', ['foo'])) 61 | self.assertEqual(callback.matches['subject'], []) 62 | 63 | self.assertTrue(callback.check_item('subject', ['Task name (.*)'])) 64 | self.assertEqual(callback.matches['subject'], ['here']) 65 | 66 | def test_check_item_to(self): 67 | # "to" may be a list of several emails 68 | email_file = join(dirname(__file__), 'mails/mail_with_attachment.txt') 69 | email = message_from_file(open(email_file, 'r')) 70 | callback = Callback(email, {}) 71 | 72 | self.assertTrue(callback.check_item('to', [r'\+([^@]+)@'])) 73 | self.assertEqual(callback.matches['to'], 74 | ['RANDOM_KEY', 'RANDOM_KEY_2']) 75 | 76 | def test_check_item_to_encoded(self): 77 | # "to" may be a list of several emails 78 | email_file = join(dirname(__file__), 'mails/mail_encoded_headers.txt') 79 | email = message_from_file(open(email_file, 'r')) 80 | callback = Callback(email, {}) 81 | 82 | self.assertTrue(callback.check_item('to', [r'(.*)