wip: callbacks, rules checking

This commit is contained in:
Mathieu Agopian
2013-03-18 17:40:39 +01:00
parent 56e0511490
commit 3560463908
4 changed files with 253 additions and 24 deletions

View File

@@ -60,7 +60,7 @@ Registering callbacks
register(MyCallback)
By default, callbacks will be executed on each and every mail received, unless
you specify it differently, either using the 'rules' parameter on the callback
you specify it differently, either using the 'rules' attribute on the callback
class, or by registering with those rules:
@@ -68,7 +68,8 @@ Providing the rules as a parameter
----------------------------------
Here's a callback that will only be triggered if the subject matches the
pattern 'Hello ' followed by a word:
pattern 'Hello ' followed by a word, anywhere in the subject (it uses
``re.search``):
.. code-block:: python
@@ -76,10 +77,10 @@ pattern 'Hello ' followed by a word:
class MyCallback(Callback):
rules = {'subject_patterns': [r'Hello (\w)']}
rules = {'subject': [r'Hello (\w)']}
def callback(self):
print("Mail received for {0}".format(self.subject_matches[0]))
print("Mail received for {0}".format(self.matches['subject'][0]))
register(MyCallback)
@@ -101,9 +102,21 @@ registering:
class MyCallback(Callback):
def callback(self):
print("Mail received for %s!" self.subject_matches[0])
print("Mail received for %s!" self.matches['subject'][0])
register(MyCallback, rules={'subject_patterns': [r'Hello (\w)']})
register(MyCallback, rules={'subject': [r'Hello (\w)']})
How does it work?
-----------------
When an email is received on the mail server the MailBot is connected to
(using the IMAP protocol), it'll check all the registered callbacks and their
rules.
If each provided rule (either as a class parameter or using the register)
matches the mail's subject, from, to, cc and body, the callback will be
triggered.
Specifying rules
@@ -116,34 +129,68 @@ data:
* ``from``: tested against the mail sender
* ``to``: tested against each of the recipients in the "to" field
* ``cc``: tested against each of the recipients in the "cc" field
* ``body``: tested against the (text) body of the mail
* ``body``: tested against the (text/plain) body of the mail
If no rule are provided, for example for the "from" field, then no rule will be
applied, and emails from any sender will potentially trigger the callback.
For each piece of data (subject, from, to, cc, body), the callback class,
once instantiated with the mail, will have a corresponding parameter
``FOO_matches`` with all the matches from the given patterns.
once instantiated with the mail, and the ``check_rules`` method called, will
have the attribute ``self.matches[item]`` set with all the matches from the
given patterns, if any
Here are example subjects for the subject rules:
[``r'^Hello (\w), (.*)'``, ``r'[Hh]i (\w)!``]
* 'Hello Bryan, how are you?': ``subject_matches`` == ['Bryan', 'how are you?']
* 'Hi Bryan, how are you?': ``subject_matches`` == ['Bryan']
* 'aloha, hi Bryan!': ``subject_matches`` == ['Bryan']
* 'aloha Bryan': rules not respected, callback not triggered
* 'Hello Bryan, how are you?': self.matches['subject'] == ['Bryan', 'how are you?']
* 'Hi Bryan, how are you?': self.matches['subject'] == ['Bryan']
* 'aloha, hi Bryan!': self.matches['subject'] == ['Bryan']
* 'aloha Bryan': rules not respected, callback not triggered,
self.matches['subject'] == None
How does it work?
-----------------
Rules checking
--------------
When an email is received on the mail server the MailBot is connected to
(using the IMAP protocol), it'll check all the registered callback classes and
their rules.
A callback will be triggered if the following applies:
If each provided rule (either as a class parameter or using the register)
matches the mail's subject, from, to, cc and body, the callback class will
be instantiated, and its callback will be triggered.
* for each item/rule, **any** of the provided regular expressions matches
* **all** the rules (for all the provided items) are respected
Notice the "any" and the "all" there:
* for each rule, there may be several regular expressions. If any of those
match, then the rule is respected.
* if one rule doesn't match, the callback won't be triggered. Non existent
rules don't count, so you could have a single rule on the subject, and none
on the other items (from, to, cc, body).
As an example, let's take an email with the subject "Hello Bryan", from
"John@doe.com":
.. code-block:: python
from mailbot import register, Callback
class MyCallback(Callback):
rules = {'subject': [r'Hello (\w)', 'Hi!'], 'from': ['@doe.com']}
def callback(self):
print("Mail received for {0}".format(self.matches['subject'][0]))
register(MyCallback)
All the rules are respected, and the callback will be triggered
* subject: even though 'Hi!' isn't found anywhere in the subject, the other
regular expression matches
* from: the regular expression matches
* to, cc, body: no rules provided, so they aren't taken into account
The last bullet point also means that if register a callback with no rules at
all, it'll be triggered on each and every email, making it a "catchall
callback".
Contents

View File

@@ -1,13 +1,67 @@
# -*- coding: utf-8 -*-
from re import search
class Callback(object):
"""Base class for callbacks."""
matches = {}
def __init__(self, message, rules):
self.message = message
self.rules = rules
def check_rules(self):
"""Does this message conform to the rules provided?"""
raise NotImplementedError
def check_rules(self, rules=None):
"""Does this message conform to the all the rules provided?
For each item in the rules dictionnary (item, [regexp1, regexp2...]),
call ``self.check_item``.
"""
if rules is None:
rules = self.rules
if not rules: # if no (or empty) rules, it's a catchall callback
return True
rules_tests = [self.check_item(item, regexps)
for item, regexps in rules.iteritems()]
return all(rules_tests) # True only if at least one value is
def check_item(self, item, regexps, message=None):
"""Search the email's item using the given regular expressions.
Item is one of subject, from, to, cc, body.
Store the result of searching the item with the regular expressions in
self.matches[item]. If the search doesn't match anything, this will
result in a None, otherwise it'll be a ``re.MatchObject``.
"""
if message is None:
message = self.message
if item not in message and item != 'body': # bad item, not found
return None
# if item is not in header, then item == 'body'
value = message.get(item, self.get_email_body(message))
self.matches[item] = [search(regexp, value) for regexp in regexps]
return any(self.matches[item])
def get_email_body(self, message=None):
if message is None:
message = self.message
if not hasattr(message, 'walk'): # not an email.Message instance?
return None
for part in message.walk():
content_type = part.get_content_type()
filename = part.get_filename()
if content_type == 'text/plain' and filename is None:
# text body of the mail, not an attachment
return part.get_payload()

View File

@@ -0,0 +1,55 @@
Delivered-To: testmagopian+random_key@gmail.com
Received: by 10.194.34.7 with SMTP id v7csp101053wji;
Fri, 15 Mar 2013 02:28:52 -0700 (PDT)
Return-Path: <mathieu.agopian@gmail.com>
Received-SPF: pass (google.com: domain of mathieu.agopian@gmail.com designates 10.182.31.109 as permitted sender) client-ip=10.182.31.109
Authentication-Results: mr.google.com;
spf=pass (google.com: domain of mathieu.agopian@gmail.com designates 10.182.31.109 as permitted sender) smtp.mail=mathieu.agopian@gmail.com;
dkim=pass header.i=@gmail.com
X-Received: from mr.google.com ([10.182.31.109])
by 10.182.31.109 with SMTP id z13mr2632031obh.37.1363339731787 (num_hops = 1);
Fri, 15 Mar 2013 02:28:51 -0700 (PDT)
DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed;
d=gmail.com; s=20120113;
h=mime-version:x-received:date:message-id:subject:from:to
:content-type;
bh=EDdIiN1bkSUqRxA5ZGCbAxWo/K7ayqdf9ZDEQqAGvDU=;
b=nAVPcbc78q8Uyq8ENfiLD4R1x0Oi7kw5nMAI+eppmCqPxzeM2FITiyyz8M2WQ8rnJl
28ONzknzAEXl6Hm09EDmwgrVLXxM+x2fbNQ8DWkXtFx+3GlOP0OlE2KC2ObWZK2BxVo0
FIEsAZpt/mH4KikhOsHR6J868f/vB/0W6M7JtQGzFhbd6xjEbETDIVlPloYfmZBHs4Rp
nO7fP/VBRvWLFV/VK/OlYVXdS0FhptdCV7Zd4UKTIg5kd6rlAaZuW0KhGe6RXr0ou+aU
nqq0vSoMVK7BeKKGsA61f4YJ5qTAx4eSbOw8mYhQtnLI7qoNrS4h8iiXLWoNnxCEW9UI
YBXA==
MIME-Version: 1.0
X-Received: by 10.182.31.109 with SMTP id z13mr2632031obh.37.1363339731783;
Fri, 15 Mar 2013 02:28:51 -0700 (PDT)
Received: by 10.182.98.129 with HTTP; Fri, 15 Mar 2013 02:28:51 -0700 (PDT)
Date: Fri, 15 Mar 2013 10:28:51 +0100
Message-ID: <CAB-JLVBXqYpS1GzujSAopk3cS1Xo8C8A+bQew0_jkOpAJu1pFw@mail.gmail.com>
Subject: Task name here
From: Mathieu AGOPIAN <mathieu.agopian@gmail.com>
To: testmagopian+RANDOM_KEY@gmail.com
Content-Type: multipart/mixed; boundary=14dae93b5c806bd71504d7f3442a
--14dae93b5c806bd71504d7f3442a
Content-Type: multipart/alternative; boundary=14dae93b5c806bd71204d7f34428
--14dae93b5c806bd71204d7f34428
Content-Type: text/plain; charset=UTF-8
Mail content here
--14dae93b5c806bd71204d7f34428
Content-Type: text/html; charset=UTF-8
<div dir="ltr">Mail content here<br></div>
--14dae93b5c806bd71204d7f34428--
--14dae93b5c806bd71504d7f3442a
Content-Type: text/plain; charset=US-ASCII; name="test.txt"
Content-Disposition: attachment; filename="test.txt"
Content-Transfer-Encoding: base64
X-Attachment-Id: f_heb58ogq0
dGVzdCBmaWxlCg==
--14dae93b5c806bd71504d7f3442a--

View File

@@ -1,5 +1,11 @@
# -*- coding: utf-8 -*-
from email import message_from_file, message_from_string
from os import path
from re import search
from mock import sentinel, Mock
from . import MailBotTestCase
from .. import Callback
@@ -8,9 +14,76 @@ class CallbackTest(MailBotTestCase):
def test_init(self):
callback = Callback('foo', 'bar')
callback.get_email_body = lambda x: None # mock
self.assertEqual(callback.message, 'foo')
self.assertEqual(callback.rules, 'bar')
def test_check_rules(self):
callback = Callback('foo', 'bar')
# naive mock: return "regexps". This means that callback.matches should
# always be the same as callback.rules
callback.check_item = lambda x, y: y
# no rules registered: catchall callback
callback.rules = {}
self.assertEqual(callback.check_rules(), True)
self.assertEqual(callback.check_rules({}), True)
# no rules respected
callback.rules = {'foo': False, 'bar': [], 'baz': None}
self.assertEqual(callback.check_rules(), False)
# not all rules respected
callback.rules = {'foo': True, 'bar': [], 'baz': None}
self.assertEqual(callback.check_rules(), False)
# all rules respected
callback.rules = {'foo': True, 'bar': ['test'], 'baz': 'barf'}
self.assertEqual(callback.check_rules(), True)
def test_check_item(self):
empty = message_from_string('')
callback = Callback(empty, {})
# item does not exist
self.assertEqual(callback.check_item('foobar', ['.*'], empty), None)
self.assertEqual(callback.check_item('foobar', ['(.*)']), None)
# test on real mail
email_file = path.join(path.dirname(__file__),
'mails/mail_with_attachment.txt')
email = message_from_file(open(email_file, 'r'))
callback = Callback(email, {})
# subject
self.assertFalse(callback.check_item('subject', []))
self.assertEqual(callback.matches['subject'], [])
self.assertTrue(callback.check_item('subject', ['(.*)']))
self.assertEqual(callback.matches['subject'][0].groups(),
search('(.*)', 'Task name here').groups())
# body
callback.get_email_body = Mock(return_value='some mail body')
self.assertFalse(callback.check_item('body', []))
self.assertEqual(callback.matches['body'], [])
callback.get_email_body.assert_called_once_with(email)
self.assertTrue(callback.check_item('body', ['(.*)']))
self.assertEqual(callback.matches['body'][0].groups(),
search('(.*)', 'some mail body').groups())
def test_get_email_body(self):
callback = Callback('foo', 'bar')
self.assertEqual(callback.get_email_body(), None) # not a Message
self.assertEqual(callback.get_email_body('foo'), None) # not a Message
empty_message = message_from_string('')
self.assertEqual(callback.get_email_body(empty_message), '')
email_file = path.join(path.dirname(__file__),
'mails/mail_with_attachment.txt')
email = message_from_file(open(email_file, 'r'))
self.assertEqual(callback.get_email_body(email), 'Mail content here\n')