├── .gitignore ├── README.md ├── setup.py └── slackmail ├── __init__.py ├── db_server.py ├── simple_server.py ├── smtp_util.py └── test_send.py /.gitignore: -------------------------------------------------------------------------------- 1 | slackmail.egg-info 2 | *.pyc 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slackmail 2 | 3 | Slackmail is a simple email-to-slack proxy. 4 | 5 | ## Why 6 | 7 | You've got a service that supports email notifications for unexpected/interesting 8 | events. That's great, but you check Slack way more than email (and/or you want to 9 | share the news with a team). Instead of badgering service XYZ to add support for 10 | slack, just run this server and have them "email" you: `ping@slackmail.mydomain.com`. 11 | 12 | ## Installation 13 | 14 | ``` 15 | pip install [--user] git+https://github.com/iodine/slackmail 16 | ``` 17 | 18 | # Running 19 | 20 | By default, the servers listen on localhost, port 5025. This is to simplify testing 21 | locally. But feel free to run on port 25 and just add an MX record to have it 22 | operate as a "real" email server! 23 | 24 | ## Simple single hook server 25 | ``` 26 | slackmail-local\ 27 | --webhook-url='https://mydomain.slack.com...&token=123'\ 28 | [--listen-address=host:port]\ 29 | [--authorization_token=secureME] 30 | ``` 31 | 32 | If you specify the `authorization_token` flag, only messages containing the token 33 | somewhere in the subject or message body will be forwarded to Slack. 34 | 35 | ## Database enabled server 36 | ``` 37 | slackmail-db [--listen-address=host:port] 38 | ``` 39 | 40 | The default database used is just a SQLite database called `mail.db`. It will 41 | be created in whatever directory you run the slackmail-db command. 42 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | import setuptools 4 | 5 | setuptools.setup( 6 | name='slackmail', 7 | version='0.01', 8 | url='http://github.com/iodine/slackmail', 9 | install_requires=[ 10 | 'click', 11 | 'requests', 12 | 'sqlalchemy', 13 | ], 14 | description=('Email-to-slack bridge.'), 15 | packages=['slackmail'], 16 | zip_safe=False, 17 | test_suite = 'nose.collector', 18 | entry_points=''' 19 | [console_scripts] 20 | slackmail-db=slackmail.db_server:db_server 21 | slackmail-local=slackmail.simple_server:simple_server 22 | ''' 23 | ) 24 | -------------------------------------------------------------------------------- /slackmail/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iodine/slackmail/82c4bd7382451aff579c87a5059353a430802503/slackmail/__init__.py -------------------------------------------------------------------------------- /slackmail/db_server.py: -------------------------------------------------------------------------------- 1 | import click 2 | import smtpd 3 | import sqlalchemy 4 | import sys 5 | import traceback 6 | 7 | from email.parser import Parser 8 | 9 | from sqlalchemy.ext.declarative import declarative_base 10 | from sqlalchemy import Column, Integer, String 11 | 12 | from smtp_util import forward_message, echo, run_server, error, SMTPError 13 | 14 | Base = declarative_base() 15 | 16 | class Hook(Base): 17 | __tablename__ = 'hooks' 18 | url = Column(String, primary_key=True) 19 | email = Column(String, unique=True) 20 | auth_token = Column(String) 21 | 22 | def __repr__(self): 23 | return '' % (self.hook_url, self.email) 24 | 25 | def _create_schema(engine): 26 | Base.metadata.create_all(engine) 27 | 28 | def _contains(lst, test_fn): 29 | for item in lst: 30 | if test_fn(item): 31 | return True 32 | 33 | return False 34 | 35 | 36 | class DBServer(smtpd.SMTPServer): 37 | ''' 38 | A server that supports adding and removing hooks dynamically. 39 | 40 | Hooks are stored using the passed in SQLAlchemy model. 41 | ''' 42 | def __init__(self, listen_address, engine): 43 | smtpd.SMTPServer.__init__(self, listen_address, None) 44 | self._engine = engine 45 | 46 | def _session(self): 47 | from sqlalchemy.orm import sessionmaker 48 | return sessionmaker(bind=self._engine)() 49 | 50 | def _parse_message(self, msg): 51 | 'Parse colon delimited lines from a mail message.' 52 | result = {} 53 | 54 | text = msg.text() 55 | lines = [l.strip() for l in text.split('\n')] 56 | for line in lines: 57 | if not ':' in line: 58 | continue 59 | key, value = line.split(':', 1) 60 | key = key.lower().strip() 61 | value = value.strip() 62 | result[key] = value 63 | return result 64 | 65 | def _add_hook(self, mailfrom, mailto, msg): 66 | config = self._parse_message(msg) 67 | try: 68 | email = config['target_email'] 69 | webhook_url = config['webhook_url'] 70 | auth_token = config.get('auth', None) 71 | 72 | hook = Hook(url=webhook_url, auth_token=auth_token, email=email) 73 | session = self._session() 74 | session.add(hook) 75 | session.commit() 76 | except KeyError, e: 77 | raise SMTPError(510, 'Missing required field: %s' % e.message) 78 | 79 | def _remove_hook(self, mailfrom, mailto, msg): 80 | config = self._parse_message(msg) 81 | try: 82 | email = config['target_email'] 83 | webhook_url = config['webhook_url'] 84 | auth_token = config.get('auth', None) 85 | 86 | session = self._session() 87 | match = session.query(Hook).filter( 88 | Hook.url == webhook_url, 89 | Hook.email == email, 90 | Hook.auth_token == auth_token 91 | ).one() 92 | session.delete(match) 93 | session.commit() 94 | except KeyError, e: 95 | raise SMTPError(510, 'Missing required field: %s' % e.message) 96 | 97 | def _forward(self, mailfrom, rcpttos, msg): 98 | session = self._session() 99 | hook = session.query(Hook).filter(Hook.email == rcpttos[0]).one() 100 | forward_message(mailfrom, rcpttos, msg, hook.url, hook.auth_token) 101 | 102 | def process_message(self, peer, mailfrom, rcpttos, data): 103 | try: 104 | echo('Processing message... %s, %s, %s' % (peer, mailfrom, rcpttos)) 105 | 106 | add_request = _contains(rcpttos, lambda address: 'add-hook@' in address) 107 | remove_request = _contains(rcpttos, lambda address: 'remove-hook@' in address) 108 | msg = Parser().parsestr(data) 109 | if add_request: 110 | self._add_hook(mailfrom, rcpttos, msg) 111 | elif remove_request: 112 | self._remove_hook(mailfrom, rcpttos, msg) 113 | else: 114 | self._forward(mailfrom, rcpttos, msg) 115 | except SMTPError as e: 116 | error('Failed to process message from %s.\n%s' % (mailfrom, e)) 117 | return repr(e) 118 | except sqlalchemy.exc.IntegrityError as e: 119 | error('Ignoring request to add an existing webhook') 120 | return '554 Hook already exists.' 121 | except: 122 | e = sys.exc_info()[0] 123 | error('Failed to process message from %s' % mailfrom) 124 | error(traceback.format_exc()) 125 | return '554 Error while processing message. %s' % e 126 | 127 | @click.command() 128 | @click.option('--listen-address', default='localhost:5025', 129 | help='Address to listen on.') 130 | def db_server(listen_address): 131 | host, port = listen_address.split(':') 132 | port = int(port) 133 | 134 | from sqlalchemy import create_engine 135 | engine = create_engine('sqlite:///email.db') 136 | _create_schema(engine) 137 | 138 | run_server(DBServer((host, port), engine)) 139 | 140 | if __name__ == '__main__': 141 | db_server() 142 | -------------------------------------------------------------------------------- /slackmail/simple_server.py: -------------------------------------------------------------------------------- 1 | import click 2 | import smtpd 3 | import traceback 4 | 5 | from email.parser import Parser 6 | from smtp_util import forward_message, echo, run_server, error 7 | 8 | class SimpleServer(smtpd.SMTPServer): 9 | '''A basic forwarding server for a specific webhook.''' 10 | def __init__(self, listen_address, webhook_url, authorization_token): 11 | smtpd.SMTPServer.__init__(self, listen_address, None) 12 | self.webhook_url = webhook_url 13 | self.authorization_token = authorization_token 14 | 15 | def process_message(self, peer, mailfrom, rcpttos, data): 16 | msg = Parser().parsestr(data) 17 | try: 18 | echo('Processing message... %s, %s, %s' % (peer, mailfrom, rcpttos)) 19 | forward_message(mailfrom, rcpttos, msg, self.webhook_url, 20 | self.authorization_token) 21 | except Exception, e: 22 | error('Failed to process message from %s' % mailfrom) 23 | error(traceback.format_exc()) 24 | 25 | @click.command() 26 | @click.option('--webhook-url', help='URL for your webhook integration', required=True) 27 | @click.option('--authorization-token', default=None, 28 | help='Authorization token. No messages will be forwarded if they do not include this token.') 29 | @click.option('--listen-address', default='localhost:5025', 30 | help='Address to listen on.') 31 | def simple_server(webhook_url, authorization_token, listen_address): 32 | host, port = listen_address.split(':') 33 | port = int(port) 34 | server = SimpleServer((host, port), webhook_url, authorization_token) 35 | run_server(server) 36 | 37 | -------------------------------------------------------------------------------- /slackmail/smtp_util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import asyncore 4 | import json 5 | 6 | import click 7 | import requests 8 | 9 | from email.message import Message 10 | 11 | def echo(msg, fg=None): 12 | if not fg: 13 | click.echo(msg) 14 | else: 15 | click.echo(click.style(msg, fg=fg)) 16 | 17 | def warn(msg): 18 | return echo(msg, fg='yellow') 19 | 20 | def error(msg): 21 | return echo(msg, fg='red') 22 | 23 | 24 | def _msg_text(msg): 25 | if msg.is_multipart(): 26 | return msg.get_payload(0).as_string() 27 | else: 28 | return msg.get_payload() 29 | 30 | Message.text = _msg_text 31 | 32 | class SMTPError(Exception): 33 | def __init__(self, code, msg): 34 | self.code = code 35 | self.message = msg 36 | 37 | def __repr__(self): 38 | return '%d %s' % (self.code, self.message) 39 | 40 | 41 | def forward_message(mailfrom, rcpttos, msg, webhook_url, authorization_token=None): 42 | if authorization_token and not authorization_token in msg.as_string(): 43 | raise SMTPError(554, 'Rejecting message: missing or invalid authorization token') 44 | 45 | try: 46 | r = requests.post(webhook_url, data=json.dumps({ 47 | 'text' : msg.text(), 48 | 'username': msg['from'] 49 | })) 50 | r.raise_for_status() 51 | except Exception, e: 52 | error('Slack reported an error: %s' % e) 53 | raise SMTPError(554, 'Error posting to webhook') 54 | 55 | 56 | def run_server(server): 57 | echo('Starting SMTP server on %s' % (server._localaddr,), fg='green') 58 | try: 59 | asyncore.loop() 60 | except KeyboardInterrupt: 61 | pass 62 | -------------------------------------------------------------------------------- /slackmail/test_send.py: -------------------------------------------------------------------------------- 1 | import smtplib 2 | from email.mime.text import MIMEText 3 | 4 | def _send(data, from_email, to_email): 5 | msg = MIMEText(data) 6 | msg['Subject'] = 'This is a test message.' 7 | s = smtplib.SMTP('localhost', 5025) 8 | s.sendmail(from_email, [to_email], msg.as_string()) 9 | s.quit() 10 | 11 | def test_forward(): 12 | _send('Woop woop woop woop woop...', 'you@domain.com', 'my-test-hook@rjp.io') 13 | 14 | def test_add_hook(): 15 | _send(''' 16 | target_email: my-test-hook@rjp.io 17 | webhook_url: https://awdawldiawd.slack.com/webhook?token=123 18 | auth_token: elderberries 19 | ''', 'you@domain.com', 'add-hook@slackmail.com') 20 | 21 | def test_remove_hook(): 22 | _send(''' 23 | target_email: my-test-hook@rjp.io 24 | webhook_url: https://awdawldiawd.slack.com/webhook?token=123 25 | auth_token: elderberries 26 | ''', 'you@domain.com', 'remove-hook@slackmail.com') 27 | 28 | def main(): 29 | try: 30 | test_remove_hook() 31 | except: 32 | pass 33 | 34 | # test that we can add and remove hooks properly 35 | test_add_hook() 36 | test_remove_hook() 37 | test_add_hook() 38 | test_remove_hook() 39 | test_add_hook() 40 | test_remove_hook() 41 | 42 | # try to forward our message. 43 | test_add_hook() 44 | test_forward() 45 | 46 | if __name__ == '__main__': 47 | main() 48 | --------------------------------------------------------------------------------