wip: callbacks, rules checking
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
55
mailbot/tests/mails/mail_with_attachment.txt
Normal file
55
mailbot/tests/mails/mail_with_attachment.txt
Normal 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--
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user