diff --git a/docs/source/index.rst b/docs/source/index.rst index 7b37644..e99dfd3 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -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 diff --git a/mailbot/callback.py b/mailbot/callback.py index 78ce8f9..4cfdc05 100644 --- a/mailbot/callback.py +++ b/mailbot/callback.py @@ -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() diff --git a/mailbot/tests/mails/mail_with_attachment.txt b/mailbot/tests/mails/mail_with_attachment.txt new file mode 100644 index 0000000..f8a52be --- /dev/null +++ b/mailbot/tests/mails/mail_with_attachment.txt @@ -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: +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: +Subject: Task name here +From: Mathieu AGOPIAN +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 + +
Mail content here
+ +--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-- diff --git a/mailbot/tests/test_callback.py b/mailbot/tests/test_callback.py index 6408450..e31536f 100644 --- a/mailbot/tests/test_callback.py +++ b/mailbot/tests/test_callback.py @@ -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')