├── .coveragerc ├── .gitignore ├── .travis.yml ├── CHANGELOG.rst ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.rst ├── lathermail.conf.example ├── lathermail ├── __init__.py ├── api.py ├── compat.py ├── db.py ├── default_settings.py ├── mail.py ├── representations.py ├── run_all.py ├── smtp.py ├── storage │ ├── __init__.py │ ├── alchemy.py │ └── mongo.py ├── utils.py ├── validators.py └── web │ ├── __init__.py │ └── static │ ├── index.html │ └── js │ └── lathermail.js ├── setup.cfg ├── setup.py ├── tests ├── __init__.py ├── __main__.py ├── test_api.py └── utils.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = */run_all.py,*/web/* 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .coverage 2 | *.py[co] 3 | .idea 4 | *.sqlite 5 | .tox 6 | .cache 7 | dist 8 | build 9 | _build 10 | MANIFEST 11 | *.egg-info/ 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: false 3 | 4 | python: 5 | - "2.7" 6 | - "3.5" 7 | - "3.6" 8 | 9 | services: 10 | - mongodb 11 | 12 | env: 13 | - LATHERMAIL_TEST_DB_TYPE=sqlite 14 | - LATHERMAIL_TEST_DB_TYPE=mongo 15 | 16 | install: 17 | - python setup.py develop 18 | - pip install 'pytest>=2.8,<3' 'pytest-cov>=2.0,<2.2' 19 | - pip install coveralls 20 | 21 | script: py.test -s -v tests --cov lathermail 22 | 23 | after_success: 24 | coveralls 25 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | .. :changelog: 2 | 3 | Changelog 4 | --------- 5 | 6 | 0.4.1 (2017-02-06) 7 | ++++++++++++++++++ 8 | * [UI] Add button to delete single or all messages 9 | 10 | 11 | 0.4.0 (2017-02-06) 12 | ++++++++++++++++++ 13 | * Add ``_contains`` filters 14 | * Fix error with simple text format emails 15 | 16 | 17 | 0.3.1 (2016-09-02) 18 | ++++++++++++++++++ 19 | * Proper fix for ``message_from_string`` on Python 2 20 | 21 | 0.3.0 (2016-09-01) 22 | ++++++++++++++++++ 23 | * HTML multipart support (#2, #3) 24 | * Fix multipart binary messages 25 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Dockerfile for Lathermail 2 | # For Docker-related questions please contact Sergey Arkhipov 3 | # Please contact Roman Haritonov for the rest of questions. 4 | # 5 | # To build image please do 6 | # $ docker build -t lathermail . 7 | 8 | FROM ubuntu:14.04 9 | MAINTAINER Sergey Arkhipov 10 | 11 | ENV HOME /root 12 | ENV DEBIAN_FRONTEND noninteractive 13 | ENV TERM linux 14 | 15 | ADD . . 16 | 17 | RUN apt-get update 18 | RUN apt-get -y upgrade 19 | RUN apt-get -y install gcc 20 | RUN apt-get -y install g++ 21 | RUN apt-get -y install python-dev 22 | RUN apt-get -y install python-pip 23 | 24 | RUN pip install -e . 25 | 26 | EXPOSE 5000 27 | EXPOSE 2525 28 | 29 | ENTRYPOINT lathermail 30 | CMD ["--api-host", "0.0.0.0", "--smtp-host", "0.0.0.0"] 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014-2015 Roman Haritonov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.rst CHANGELOG.rst lathermail.conf.example 2 | include *.py 3 | recursive-include lathermail/web/static * 4 | recursive-include tests *.py 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://travis-ci.org/reclosedev/lathermail.svg?branch=master 2 | :target: https://travis-ci.org/reclosedev/lathermail 3 | 4 | .. image:: https://coveralls.io/repos/reclosedev/lathermail/badge.svg?branch=master&service=github 5 | :target: https://coveralls.io/github/reclosedev/lathermail?branch=master 6 | 7 | lathermail 8 | ========== 9 | 10 | SMTP Server with API for email testing inspired by `mailtrap `_ and 11 | `maildump `_ 12 | 13 | Can store messages in MongoDB or any SQLAlchemy supported DB (e.g., sqlite). Supports Python 2.7, 3.4, 3.5, pypy. 14 | 15 | Contains simple UI interface (AngularJS) to navigate and manage received messages. 16 | 17 | Available API clients: 18 | 19 | * Python API client `lathermail_client `_ 20 | (`PyPI `_). 21 | 22 | Usage:: 23 | 24 | $ virtualenv venv # or mkvirutalenv lathermail 25 | $ . venv/bin/activate 26 | $ pip install lathermail 27 | $ lathermail --help 28 | 29 | usage: lathermail [-h] [--db-uri DB_URI] [--api-host API_HOST] 30 | [--api-port API_PORT] [--smtp-host SMTP_HOST] 31 | [--smtp-port SMTP_PORT] 32 | 33 | optional arguments: 34 | -h, --help show this help message and exit 35 | --db-uri DB_URI DB URI, e.g. mongodb://localhost/lathermail, 36 | sqlite:////tmp/my.db (default: 37 | sqlite:///~/.lathermail.db) 38 | --api-host API_HOST API Host (default: 127.0.0.1) 39 | --api-port API_PORT API port (default: 5000) 40 | --smtp-host SMTP_HOST 41 | SMTP host (default: 127.0.0.1) 42 | --smtp-port SMTP_PORT 43 | SMTP port (default: 2525) 44 | 45 | 46 | It will start SMTP server and API server in single process. 47 | Also UI interface is available at API port (http://127.0.0.1:5000 by default) 48 | 49 | Inboxes are identified by SMTP user/password pairs. lathermail intended to be used in single project environment. 50 | 51 | To send email, just use SMTP client with auth support. 52 | 53 | 54 | API 55 | --- 56 | 57 | To request API, you must provide headers: 58 | 59 | * ``X-Mail-Password`` - same as SMTP password 60 | * ``X-Mail-Inbox`` - same as SMTP user. Optional, work with all inboxes if not specified 61 | 62 | **GET /api/0/inboxes/** 63 | 64 | Returns list of inboxes for passed ``X-Mail-Password``:: 65 | 66 | { 67 | "inbox_list": [ 68 | "first", 69 | "second", 70 | "third" 71 | ], 72 | "inbox_count": 3 73 | } 74 | 75 | 76 | **GET /api/0/messages/** 77 | 78 | Returns single message. Example:: 79 | 80 | { 81 | "message_info": { 82 | "message_raw": "Content-Type: multipart/mixed; boundary=\"===============3928630509694630745==...", 83 | "password": "password", 84 | "sender": { 85 | "name": "Me", 86 | "address": "asdf@exmapl.com" 87 | }, 88 | "recipients": [ 89 | { 90 | "name": "Rcpt1", 91 | "address": "rcpt1@example.com" 92 | }, 93 | { 94 | "name": "Rcpt2", 95 | "address": "rcpt2@example.com" 96 | }, 97 | { 98 | "name": "", 99 | "address": "rcpt3@example.com" 100 | } 101 | ], 102 | "recipients_raw": "=?utf-8?q?Rcpt1?= ,\n =?utf-8?q?Rcpt2?= , rcpt3@example.com", 103 | "created_at": "2014-06-24T15:28:35.045000+00:00", 104 | "sender_raw": "Me ", 105 | "parts": [ 106 | { 107 | "index": 0, 108 | "body": "you you \u043f\u0440\u0438\u0432\u0435\u0442 2", 109 | "is_attachment": false, 110 | "charset": "utf-8", 111 | "filename": null, 112 | "type": "text/plain", 113 | "size": 16 114 | }, 115 | { 116 | "index": 1, 117 | "body": null, 118 | "is_attachment": true, 119 | "charset": null, 120 | "filename": "t\u0430\u0441\u0434est.txt", 121 | "type": "application/octet-stream", 122 | "size": 12 123 | } 124 | ], 125 | "inbox": "inbox", 126 | "_id": "53a960e3312f9156b7c92c5b", 127 | "subject": "Test subject \u0445\u044d\u043b\u043b\u043e\u0443 2", 128 | "read": false 129 | } 130 | } 131 | 132 | Attachments in message have ``body`` = null. To download file, use following method. 133 | 134 | 135 | **GET /api/0/messages//attachments/** 136 | 137 | Returns file from message. Works in browsers. 138 | 139 | 140 | **GET /api/0/messages/** 141 | 142 | Returns messages according to optional filters: 143 | 144 | * ``sender.name`` - Name of sender 145 | * ``sender.address`` - Email of sender 146 | * ``recipients.name`` - Name of any of recipients 147 | * ``recipients.address`` - Email of any of recipients 148 | * ``subject`` - Message subject 149 | * Add ``_contains`` suffix to any field above to search substring match, 150 | e.g.: ``subject_contains``, ``recipients.address_contains`` 151 | * ``created_at_lt`` - Filter messages created before this ISO formatted datetime 152 | * ``created_at_gt`` - Filter messages created after this ISO formatted datetime 153 | * ``read`` - Return only read emails when `True` or unread when `False`. All emails returned by default 154 | 155 | Example:: 156 | 157 | { 158 | "message_count": 3, 159 | "message_list": [ 160 | {"_id": ..., "parts": [...], ...}, // same as single message 161 | {...}, 162 | {...} 163 | ] 164 | } 165 | 166 | **DELETE /api/0/messages/** 167 | 168 | Deletes single message 169 | 170 | **DELETE /api/0/messages/** 171 | 172 | Deletes all messages in inbox. Also, you can filter deletable messages like in **GET /api/0/** 173 | 174 | 175 | Configuration 176 | ------------- 177 | Copy lathermail.conf.example, modify it, export environment variable before starting:: 178 | 179 | $ export LATHERMAIL_SETTINGS=/path/to/lathermail.conf 180 | $ lathermail 181 | 182 | 183 | To run tests:: 184 | 185 | $ python -m tests 186 | -------------------------------------------------------------------------------- /lathermail.conf.example: -------------------------------------------------------------------------------- 1 | # Defaults: 2 | # SMTP_HOST = "127.0.0.1" 3 | # SMTP_PORT = 2525 4 | # API_HOST = "127.0.0.1" 5 | # API_PORT = 5000 6 | 7 | # Optional parameters 8 | # By default sqlite is used, to override use DB_URI setting, e.g. 9 | # DB_URI = mongo:///localhost/lathermail 10 | 11 | # Or change sql file location (default is ~/.lathermail.db) 12 | # DB_URI = sqlite:////tmp/lathermail.db 13 | -------------------------------------------------------------------------------- /lathermail/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | from flask import Flask 4 | 5 | 6 | app = Flask(__name__) 7 | 8 | app.config.from_object("lathermail.default_settings") 9 | app.config.from_envvar("LATHERMAIL_SETTINGS", silent=True) 10 | app.config.from_pyfile("lathermail.conf", silent=True) 11 | 12 | logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)-16s:%(asctime)-s:%(message)s") 13 | 14 | from . import db 15 | from .api import api_bp 16 | from .web import static_bp 17 | 18 | 19 | def init_app(): 20 | db.init(app) 21 | app.register_blueprint(api_bp) 22 | app.register_blueprint(static_bp) 23 | -------------------------------------------------------------------------------- /lathermail/api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from flask import Blueprint, Response, request 5 | from flask.ext import restful 6 | from flask.ext.restful import Resource 7 | 8 | from . import db 9 | from .validators import parser 10 | from .representations import output_json, content_disposition 11 | 12 | 13 | api_bp = Blueprint("api", __name__) 14 | api = restful.Api(app=api_bp, prefix="/api/0") 15 | api.representations.update({"application/json": output_json}) 16 | 17 | 18 | class MessageList(Resource): 19 | def get(self): 20 | args = parser.parse_args() 21 | messages = list(db.engine.find_messages(args.password, args.inbox, args)) 22 | return {'message_list': messages, 'message_count': len(messages)} 23 | 24 | def delete(self): 25 | args = parser.parse_args() 26 | db.engine.remove_messages(args.password, args.inbox, args) 27 | return '', 204 28 | 29 | 30 | class Message(Resource): 31 | def get(self, message_id): 32 | args = parser.parse_args() 33 | args["_id"] = message_id 34 | messages = list(db.engine.find_messages(args.password, args.inbox, args, limit=1)) 35 | if not messages: 36 | return {"error": "Message not found"}, 404 37 | return {"message_info": messages[0]} 38 | 39 | def delete(self, message_id): 40 | args = parser.parse_args() 41 | args["_id"] = message_id 42 | if db.engine.remove_messages(args.password, args.inbox, args): 43 | return '', 204 44 | return {"error": "Message not found"}, 404 45 | 46 | 47 | class Attachment(Resource): 48 | def get(self, message_id, attachment_index): 49 | args = {"_id": message_id} 50 | messages = list(db.engine.find_messages(None, fields=args, limit=1, include_attachment_bodies=True)) 51 | if not messages: 52 | return {"error": "Message not found"}, 404 53 | 54 | try: 55 | part = messages[0]["parts"][attachment_index] 56 | except IndexError: 57 | pass 58 | else: 59 | if part["filename"]: 60 | return Response( 61 | part["body"], mimetype=part["type"], 62 | headers={"Content-Disposition": content_disposition(part["filename"], 63 | request.environ.get('HTTP_USER_AGENT'))} 64 | ) 65 | 66 | return {"error": "Attachment not found"}, 404 67 | 68 | 69 | class InboxList(Resource): 70 | def get(self): 71 | args = parser.parse_args() 72 | inboxes = db.engine.get_inboxes(args.password) 73 | return {'inbox_list': inboxes, 'inbox_count': len(inboxes)} 74 | 75 | 76 | api.add_resource(MessageList, '/messages/') 77 | api.add_resource(Message, '/messages/') 78 | api.add_resource(Attachment, '/messages//attachments/') 79 | api.add_resource(InboxList, '/inboxes/') 80 | -------------------------------------------------------------------------------- /lathermail/compat.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | IS_PY3 = sys.version_info[0] == 3 4 | 5 | if IS_PY3: 6 | from http.client import NO_CONTENT 7 | from email import encoders as Encoders 8 | from urllib.parse import quote, urlencode 9 | unicode = str 10 | bytes = bytes 11 | else: 12 | from email import Encoders 13 | from httplib import NO_CONTENT 14 | from urllib import quote, urlencode 15 | unicode = unicode 16 | _orig_bytes = bytes 17 | bytes = lambda s, *a: _orig_bytes(s) 18 | -------------------------------------------------------------------------------- /lathermail/db.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import logging 4 | 5 | 6 | engine = None 7 | 8 | 9 | def init(app): 10 | global engine 11 | 12 | db_uri = app.config["DB_URI"] 13 | if db_uri.startswith("mongodb:/"): 14 | app.config["MONGO_URI"] = db_uri 15 | from .storage import mongo 16 | engine = mongo 17 | else: 18 | app.config['SQLALCHEMY_DATABASE_URI'] = db_uri 19 | from .storage import alchemy 20 | engine = alchemy 21 | 22 | logging.info("Using '%s' DB engine. URI: '%s'", engine.__name__, db_uri) 23 | engine.init_app_for_db(app) 24 | -------------------------------------------------------------------------------- /lathermail/default_settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | DB_URI = "sqlite:///" + os.path.expanduser("~/.lathermail.db") 5 | DEBUG_MODE = False 6 | SMTP_HOST = "127.0.0.1" 7 | SMTP_PORT = 2525 8 | API_HOST = "127.0.0.1" 9 | API_PORT = 5000 10 | SQLITE_FAST_SAVE = True 11 | SQLALCHEMY_TRACK_MODIFICATIONS = False 12 | -------------------------------------------------------------------------------- /lathermail/mail.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals 4 | 5 | import email 6 | from email.header import decode_header 7 | from email.utils import getaddresses 8 | 9 | from .compat import bytes, IS_PY3, unicode 10 | 11 | 12 | def convert_addresses(raw_header): 13 | result = [] 14 | name_addr_pairs = getaddresses([raw_header]) 15 | for name, addr in name_addr_pairs: 16 | 17 | result.append({"name": _header_to_unicode(name), "address": addr}) 18 | return result 19 | 20 | 21 | def convert_message_to_dict(to, sender, message, body, user, password): 22 | from_addr = message.get("From") or sender 23 | to = message.get("To") or ",".join(to) 24 | subject = message.get("Subject") or "" 25 | 26 | result = { 27 | "inbox": user, 28 | "password": password, 29 | "message_raw": bytes(body, "utf8"), 30 | "sender_raw": from_addr, 31 | "recipients_raw": to, 32 | # for easy searching 33 | "sender": convert_addresses(from_addr)[0], 34 | "recipients": convert_addresses(to), 35 | "subject": _header_to_unicode(subject), 36 | "read": False, 37 | } 38 | return result 39 | 40 | 41 | def expand_message_fields(message_info, include_attachment_bodies=False): 42 | raw = message_info["message_raw"] 43 | if IS_PY3: 44 | raw = raw.decode("utf8") 45 | message = email.message_from_string(raw) 46 | message_info["parts"] = list(_iter_parts(message, include_attachment_bodies)) 47 | return message_info 48 | 49 | 50 | def _iter_parts(message, include_attachment_bodies): 51 | parts = [message] if not message.is_multipart() else message.walk() 52 | 53 | index = 0 54 | for part in parts: 55 | filename = part.get_filename() or part.get("content-id") 56 | is_attachment = filename is not None 57 | if not include_attachment_bodies and is_attachment: 58 | body = None 59 | else: 60 | body = part.get_payload(decode=True) 61 | if not is_attachment and not body: 62 | continue 63 | 64 | charset = part.get_content_charset() 65 | if charset: 66 | try: 67 | body = body.decode(charset) 68 | except Exception: 69 | pass 70 | 71 | yield { 72 | "index": index, 73 | "type": part.get_content_type(), 74 | "is_attachment": is_attachment, 75 | "filename": _header_to_unicode(filename) if filename else None, 76 | "charset": charset, 77 | "body": body, 78 | "size": len(body) if body else 0 79 | } 80 | index += 1 81 | 82 | 83 | def _header_to_unicode(header): 84 | data, encoding = decode_header(header)[0] 85 | if encoding: 86 | data = data.decode(encoding) 87 | return data 88 | -------------------------------------------------------------------------------- /lathermail/representations.py: -------------------------------------------------------------------------------- 1 | import re 2 | import json 3 | import datetime 4 | 5 | from flask import make_response 6 | from bson import Timestamp, ObjectId 7 | 8 | from lathermail.compat import quote 9 | 10 | 11 | def output_json(data, code, headers=None): 12 | resp = make_response(json.dumps(data, indent=4, cls=MongoEncoder), code) 13 | resp.headers.extend(headers or {}) 14 | return resp 15 | 16 | 17 | class MongoEncoder(json.JSONEncoder): 18 | def default(self, obj): 19 | if isinstance(obj, datetime.date): 20 | return obj.isoformat() 21 | if isinstance(obj, datetime.datetime): 22 | return obj.isoformat() 23 | if isinstance(obj, Timestamp): 24 | return "Timestamp({}, {})".format(obj.as_datetime().isoformat(), obj.inc) 25 | elif isinstance(obj, ObjectId): 26 | return str(obj) 27 | elif hasattr(obj, "to_json"): 28 | return obj.to_json() 29 | elif isinstance(obj, bytes): 30 | return obj.decode("utf-8") 31 | else: 32 | return super(MongoEncoder, self).default(obj) 33 | 34 | 35 | def content_disposition(filename, user_agent=None): 36 | filename = filename.encode("utf-8") 37 | older_msie_pattern = r"^.*MSIE ([0-8]{1,}[\.0-9]{0,}).*$" 38 | safari_pattern = r"^.*AppleWebKit.*$" 39 | user_agent = user_agent or "" 40 | 41 | if re.match(older_msie_pattern, user_agent, re.IGNORECASE): 42 | return "attachment;filename={0}".format(quote(filename)) 43 | elif re.match(safari_pattern, user_agent, re.IGNORECASE): 44 | return "attachment;filename={0}".format(filename) 45 | return "attachment;filename*=utf-8''{0}".format(quote(filename)) 46 | -------------------------------------------------------------------------------- /lathermail/run_all.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import sys 4 | import argparse 5 | from threading import Thread 6 | 7 | from lathermail import app, init_app 8 | from lathermail.smtp import serve_smtp 9 | from lathermail import db 10 | 11 | 12 | def main(): 13 | parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) 14 | parser.add_argument( 15 | "--db-uri", default=app.config["DB_URI"], 16 | help="DB URI, e.g. mongodb://localhost/lathermail, sqlite:////tmp/my.db" 17 | ) 18 | parser.add_argument("--api-host", default=app.config["API_HOST"], help="API Host") 19 | parser.add_argument("--api-port", default=app.config["API_PORT"], type=int, help="API port") 20 | parser.add_argument("--smtp-host", default=app.config["SMTP_HOST"], help="SMTP host") 21 | parser.add_argument("--smtp-port", default=app.config["SMTP_PORT"], type=int, help="SMTP port") 22 | parser.add_argument("--debug", action="store_true", help="Run in debug mode") 23 | parser.add_argument("--debug-smtp", action="store_true", help="Turn on SMTP debug info") 24 | args = parser.parse_args() 25 | 26 | if args.debug_smtp: 27 | import smtpd 28 | smtpd.DEBUGSTREAM = sys.stderr 29 | 30 | app.config["DB_URI"] = args.db_uri 31 | app.config["DEBUG_MODE"] = args.debug 32 | init_app() 33 | 34 | t = Thread(target=serve_smtp, kwargs=dict(handler=db.engine.message_handler, 35 | host=args.smtp_host, port=args.smtp_port)) 36 | t.daemon = True 37 | t.start() 38 | 39 | app.run(debug=False, threaded=True, host=args.api_host, port=args.api_port) 40 | 41 | 42 | if __name__ == "__main__": 43 | main() 44 | -------------------------------------------------------------------------------- /lathermail/smtp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import email 4 | import smtpd 5 | import asyncore 6 | import logging 7 | import socket 8 | import base64 9 | 10 | from lathermail.compat import bytes 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | class SMTPChannelWithAuth(smtpd.SMTPChannel, object): 16 | 17 | def __init__(self, server, conn, addr, on_close=lambda s: None): 18 | smtpd.SMTPChannel.__init__(self, server, conn, addr) 19 | self.user = None 20 | self.password = None 21 | self.fqdn = socket.getfqdn() 22 | self._addr = addr 23 | self._on_close = on_close 24 | 25 | def close(self): 26 | smtpd.SMTPChannel.close(self) 27 | try: 28 | self._on_close(self._addr) 29 | except: 30 | log.exception("Exception in on_close for %s:", self._addr) 31 | 32 | def smtp_EHLO(self, arg): 33 | self.push("250-%s\r\n" 34 | "250 AUTH PLAIN" % self.fqdn) 35 | self.seen_greeting = arg 36 | 37 | def smtp_AUTH(self, arg): 38 | try: 39 | user, password = self.decode_plain_auth(arg) 40 | except ValueError: 41 | self.push("535 5.7.8 Authentication credentials invalid") 42 | else: 43 | self.push("235 2.7.0 Authentication Succeeded") 44 | self.user, self.password = user.decode("ascii"), password.decode("ascii") 45 | 46 | def smtp_MAIL(self, arg): 47 | if not (self.user and self.password): 48 | self.push("530 5.7.0 Authentication required") 49 | return 50 | smtpd.SMTPChannel.smtp_MAIL(self, arg) 51 | 52 | @staticmethod 53 | def decode_plain_auth(arg): 54 | user_password = arg.split()[-1] 55 | user_password = base64.b64decode(bytes(user_password, "ascii")) 56 | return user_password.split(b"\0", 3)[1:] 57 | 58 | 59 | class InboxServer(smtpd.SMTPServer, object): 60 | 61 | def __init__(self, localaddr, handler): 62 | super(InboxServer, self).__init__(localaddr, None) 63 | self._handler = handler 64 | self._channel_by_peer = {} 65 | 66 | def handle_accept(self): 67 | pair = self.accept() 68 | if pair is not None: 69 | conn, addr = pair 70 | log.info("Incoming connection from %s", repr(addr)) 71 | # Since there is no way to pass channel or additional data (user, password) to process_message() 72 | # we have dict of channels indexed by peers. It's cleaned on channel close() 73 | self._channel_by_peer[addr] = SMTPChannelWithAuth(self, conn, addr, on_close=self._on_channel_close) 74 | 75 | def process_message(self, peer, mailfrom, rcpttos, data, **kwargs): 76 | channel = self._channel_by_peer.get(peer) 77 | if not channel: 78 | log.error("No channel found for peer %s. Channels dump: %s", peer, self._channel_by_peer) 79 | return "451 Internal confusion" 80 | 81 | log.info("Storing message: inbox: '%s', from: %r, to: %r, peer: %r", 82 | channel.user, mailfrom, rcpttos, peer) 83 | try: 84 | if hasattr(data, 'decode'): 85 | data = data.decode("utf-8") 86 | message = email.message_from_string(data) 87 | return self._handler( 88 | to=rcpttos, sender=mailfrom, message=message, body=data, 89 | user=channel.user, password=channel.password 90 | ) 91 | except Exception: 92 | log.exception("Failed to process message:") 93 | 94 | def _on_channel_close(self, peer): 95 | self._channel_by_peer.pop(peer, None) 96 | log.info("Remove channel for peer: %s. Active channels: %s", peer, len(self._channel_by_peer)) 97 | 98 | 99 | def serve_smtp(host="127.0.0.1", port=10252, handler=None): 100 | if handler is None: 101 | 102 | def handler(to, sender, *args): 103 | print("{0} <- {1}".format(to, sender)) 104 | 105 | InboxServer((host, port), handler) 106 | log.info("Running SMTP server on %s:%s", host, port) 107 | while True: 108 | try: 109 | asyncore.loop(use_poll=True, count=10) 110 | except: 111 | log.exception("Error in loop:") 112 | -------------------------------------------------------------------------------- /lathermail/storage/__init__.py: -------------------------------------------------------------------------------- 1 | TEXT_FIELDS = { 2 | "sender.name", "sender.address", "recipients.name", "recipients.address", 3 | "subject", 4 | } 5 | SUFFIX_CONTAINS = "_contains" 6 | 7 | CONTAINS_FIELDS = {field + SUFFIX_CONTAINS for field in TEXT_FIELDS} 8 | 9 | ALLOWED_QUERY_FIELDS = {"_id", "read"} | TEXT_FIELDS | CONTAINS_FIELDS 10 | -------------------------------------------------------------------------------- /lathermail/storage/alchemy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals, print_function 4 | 5 | import uuid 6 | import logging 7 | 8 | from sqlalchemy import event 9 | from werkzeug.routing import UnicodeConverter 10 | from flask.ext.sqlalchemy import SQLAlchemy 11 | 12 | from . import ALLOWED_QUERY_FIELDS, SUFFIX_CONTAINS 13 | from .. import app 14 | from ..mail import convert_message_to_dict, expand_message_fields 15 | from ..utils import utcnow, as_utc 16 | 17 | 18 | log = logging.getLogger(__name__) 19 | db = SQLAlchemy(app) 20 | 21 | 22 | class Message(db.Model): 23 | __tablename__ = 'messages' 24 | _id = db.Column(db.String, primary_key=True) 25 | inbox = db.Column(db.String, index=True) 26 | password = db.Column(db.String, index=True) 27 | message_raw = db.Column(db.Binary) 28 | sender_raw = db.Column(db.String) 29 | recipients_raw = db.Column(db.String) 30 | subject = db.Column(db.String, index=True) 31 | sender_name = db.Column(db.String, index=True) 32 | sender_address = db.Column(db.String, index=True) 33 | created_at = db.Column(db.DateTime(), index=True) 34 | read = db.Column(db.Boolean) 35 | 36 | 37 | class Recipient(db.Model): 38 | __tablename__ = 'recipients' 39 | name = db.Column(db.String, primary_key=True, index=True) 40 | address = db.Column(db.String, primary_key=True, index=True) 41 | message_id = db.Column(db.String, db.ForeignKey('messages._id', ondelete="CASCADE"), primary_key=True, index=True) 42 | message = db.relationship("Message", backref="recipients") 43 | 44 | 45 | _message_fields = [column.name for column in Message.__table__.columns] 46 | 47 | 48 | def init_app_for_db(application): 49 | if app.config.get("DEBUG_MODE"): 50 | logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) 51 | db.init_app(application) 52 | application.url_map.converters['ObjectId'] = UnicodeConverter 53 | 54 | if application.config["DB_URI"].startswith("sqlite"): 55 | @event.listens_for(db.engine, "connect") 56 | def _on_engine_connect(dbapi_connection, connection_record): 57 | cursor = dbapi_connection.cursor() 58 | cursor.execute("PRAGMA foreign_keys=ON") 59 | if application.config.get("SQLITE_FAST_SAVE"): 60 | cursor.execute("PRAGMA synchronous = 0") 61 | cursor.close() 62 | 63 | _create_tables() 64 | 65 | 66 | def message_handler(*args, **kwargs): 67 | msg = convert_message_to_dict(*args, **kwargs) 68 | msg["_id"] = str(uuid.uuid4()) 69 | msg["created_at"] = utcnow() 70 | sender = msg.pop("sender") 71 | msg["sender_name"] = sender["name"] 72 | msg["sender_address"] = sender["address"] 73 | recipients = msg.pop("recipients") 74 | 75 | with app.app_context(): 76 | try: 77 | message = Message(**msg) 78 | message.recipients = [Recipient(name=rcp["name"], address=rcp["address"]) for rcp in recipients] 79 | db.session.add(message) 80 | db.session.commit() 81 | except Exception: 82 | import traceback 83 | traceback.print_exc() # TODO 84 | 85 | 86 | def get_inboxes(password): 87 | rows = db.session.query(Message.inbox).distinct(Message.inbox).filter_by(password=password) 88 | return [row.inbox for row in rows] 89 | 90 | 91 | def find_messages(password, inbox=None, fields=None, limit=0, include_attachment_bodies=False): 92 | messages = list(_iter_messages(password, inbox, fields, limit, include_attachment_bodies)) 93 | if messages: 94 | ids = (m["_id"] for m in messages) 95 | Message.query.filter(Message._id.in_(ids)).update({Message.read: True}, synchronize_session=False) 96 | db.session.commit() 97 | return messages 98 | 99 | 100 | def remove_messages(password, inbox=None, fields=None): 101 | message_ids_query = _prepare_sql_query( 102 | password, inbox, fields, 103 | order=False, to_select=Message._id, load_recipients=False 104 | ) 105 | count = Message.query.filter(Message._id.in_(message_ids_query)).delete(False) 106 | db.session.commit() 107 | return count 108 | 109 | 110 | def _iter_messages(password, inbox=None, fields=None, limit=0, include_attachment_bodies=False): 111 | query = _prepare_sql_query(password, inbox, fields, limit=limit) 112 | for message in query.all(): 113 | message = _convert_sa_message_to_dict(message) 114 | yield expand_message_fields(message, include_attachment_bodies) 115 | 116 | 117 | def _convert_sa_message_to_dict(message): 118 | result = dict((name, getattr(message, name)) for name in _message_fields) 119 | result["recipients"] = [{"name": rcpt.name, "address": rcpt.address} for rcpt in message.recipients] 120 | result["sender"] = {"name": result.pop("sender_name"), "address": result.pop("sender_address")} 121 | result["created_at"] = as_utc(result["created_at"]) 122 | return result 123 | 124 | 125 | def _prepare_sql_query(password, inbox=None, fields=None, limit=0, order=True, to_select=Message, load_recipients=True): 126 | filters = [] 127 | if password is not None: 128 | filters.append(Message.password == password) 129 | if inbox is not None: 130 | filters.append(Message.inbox == inbox) 131 | 132 | if fields: 133 | for field, value in fields.items(): 134 | if field in ALLOWED_QUERY_FIELDS and value is not None: 135 | is_contains = False 136 | if field.endswith(SUFFIX_CONTAINS): 137 | is_contains = True 138 | field = field[:-len(SUFFIX_CONTAINS)] 139 | 140 | if field.startswith("recipients."): 141 | sub_field = field.rsplit(".", 1)[1] 142 | attr = getattr(Recipient, sub_field) 143 | else: 144 | attr = getattr(Message, field.replace(".", "_")) 145 | if is_contains: 146 | filters.append(attr.contains(value)) 147 | else: 148 | filters.append(attr == value) 149 | 150 | if fields.get("created_at_gt") is not None: 151 | filters.append(Message.created_at > fields["created_at_gt"]) 152 | if fields.get("created_at_lt") is not None: 153 | filters.append(Message.created_at < fields["created_at_lt"]) 154 | 155 | query = db.session.query(to_select).join(Recipient).filter(db.and_(*filters)) 156 | if load_recipients: 157 | query = query.options(db.contains_eager(Message.recipients)) 158 | if order: 159 | query = query.order_by(Message.created_at.desc()) 160 | if limit: 161 | query = query.limit(limit) 162 | return query 163 | 164 | 165 | def drop_database(name): 166 | db.drop_all() 167 | 168 | 169 | @app.before_first_request 170 | def _init(): 171 | _create_tables() 172 | 173 | 174 | def switch_db(name): 175 | _create_tables() 176 | 177 | 178 | def _create_tables(): 179 | log.info("Ensuring DB has tables and indexes") 180 | db.create_all() 181 | -------------------------------------------------------------------------------- /lathermail/storage/mongo.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import re 4 | import logging 5 | 6 | from flask.ext.pymongo import PyMongo, DESCENDING 7 | 8 | from . import ALLOWED_QUERY_FIELDS, SUFFIX_CONTAINS 9 | from .. import app 10 | from ..mail import convert_message_to_dict, expand_message_fields 11 | from ..utils import utcnow 12 | 13 | 14 | log = logging.getLogger(__name__) 15 | mongo = PyMongo() 16 | 17 | 18 | def init_app_for_db(application): 19 | mongo.init_app(application) 20 | 21 | 22 | def switch_db(name): 23 | """ Hack to switch Flask-Pymongo db 24 | :param name: db name 25 | """ 26 | with app.app_context(): 27 | app.extensions['pymongo'][mongo.config_prefix] = mongo.cx, mongo.cx[name] 28 | 29 | 30 | def message_handler(*args, **kwargs): 31 | msg = convert_message_to_dict(*args, **kwargs) 32 | msg["created_at"] = utcnow() 33 | with app.app_context(): 34 | mongo.db.messages.insert(msg) 35 | 36 | 37 | def find_messages(password, inbox=None, fields=None, limit=0, include_attachment_bodies=False): 38 | messages = list(_iter_messages(password, inbox, fields, limit, include_attachment_bodies)) 39 | if messages: 40 | ids = [m["_id"] for m in messages] 41 | mongo.db.messages.update({"_id": {"$in": ids}, "read": False}, 42 | {"$set": {"read": True}}, multi=True) 43 | return messages 44 | 45 | 46 | def _iter_messages(password, inbox=None, fields=None, limit=0, include_attachment_bodies=False): 47 | query = _prepare_query(password, inbox, fields) 48 | for message in mongo.db.messages.find(query).sort("created_at", DESCENDING).limit(limit): 49 | yield expand_message_fields(message, include_attachment_bodies) 50 | 51 | 52 | def remove_messages(password, inbox=None, fields=None): 53 | query = _prepare_query(password, inbox, fields) 54 | return mongo.db.messages.remove(query)["n"] 55 | 56 | 57 | def get_inboxes(password): 58 | return mongo.db.messages.find({"password": password}).distinct("inbox") 59 | 60 | 61 | def _prepare_query(password, inbox=None, fields=None): 62 | query = {} 63 | if password is not None: 64 | query["password"] = password 65 | if inbox is not None: 66 | query["inbox"] = inbox 67 | 68 | if fields: 69 | for field, value in fields.items(): 70 | if field in ALLOWED_QUERY_FIELDS and value is not None: 71 | if field.endswith(SUFFIX_CONTAINS): 72 | field = field[:-len(SUFFIX_CONTAINS)] 73 | query[field] = {"$regex": re.escape(value)} 74 | else: 75 | query[field] = value 76 | 77 | if fields.get("created_at_gt") is not None: 78 | query["created_at"] = {"$gt": fields["created_at_gt"]} 79 | if fields.get("created_at_lt") is not None: 80 | query["created_at"] = {"$lt": fields["created_at_lt"]} 81 | if fields.get("subject_contains"): 82 | query["subject"] = {"$regex": re.escape(fields["subject_contains"])} 83 | 84 | return query 85 | 86 | 87 | @app.before_first_request 88 | def _ensure_index(): 89 | log.info("Ensuring DB has indexes") 90 | for field in list(ALLOWED_QUERY_FIELDS) + ["created_at"]: 91 | mongo.db.messages.ensure_index(field) 92 | 93 | 94 | def drop_database(name): 95 | mongo.cx.drop_database(name) 96 | -------------------------------------------------------------------------------- /lathermail/utils.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | 3 | from dateutil.tz import tzutc 4 | 5 | 6 | def utcnow(): 7 | return datetime.datetime.now(tzutc()) 8 | 9 | 10 | def as_utc(d): 11 | return d.replace(tzinfo=tzutc()) 12 | -------------------------------------------------------------------------------- /lathermail/validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import dateutil.parser 3 | 4 | from flask.ext.restful import reqparse, types 5 | 6 | from lathermail.compat import unicode 7 | from lathermail.storage import TEXT_FIELDS, SUFFIX_CONTAINS 8 | 9 | 10 | def iso_date(value): 11 | return dateutil.parser.parse(value) 12 | 13 | 14 | parser = reqparse.RequestParser() 15 | parser.add_argument('X-Mail-Inbox', type=unicode, dest="inbox", location="headers") 16 | parser.add_argument('X-Mail-Password', type=unicode, dest="password", location="headers", required=True) 17 | for field in TEXT_FIELDS: 18 | parser.add_argument(field, type=unicode) 19 | parser.add_argument(field + SUFFIX_CONTAINS, type=unicode) 20 | parser.add_argument("created_at_lt", type=iso_date) 21 | parser.add_argument("created_at_gt", type=iso_date) 22 | parser.add_argument("read", type=types.boolean) 23 | -------------------------------------------------------------------------------- /lathermail/web/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from flask import send_from_directory, Blueprint 4 | 5 | static_bp = Blueprint('static', __name__) 6 | _static_dir = os.path.join(os.path.dirname(__file__), 'static') 7 | 8 | 9 | @static_bp.route('/') 10 | def index(): 11 | return send_from_directory(_static_dir, "index.html") 12 | 13 | 14 | @static_bp.route('/') 15 | def serve_static(filename): 16 | return send_from_directory(_static_dir, filename) 17 | -------------------------------------------------------------------------------- /lathermail/web/static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Lathermail 6 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 38 | 39 | 40 | 41 |
42 |
43 |
44 |
45 | 46 | 47 | 48 | 51 | 52 | 53 |
54 |
55 |
56 |
57 | 58 |
59 | 60 |
61 | 62 | 63 | 65 | 73 | 74 | 75 |
66 | {{ message.subject }} 67 | To: 68 |

69 | 71 |

72 |
76 |
77 |
78 | 79 |
80 |

{{ selectedMessage.subject }}

81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 98 | 99 | 100 |
From:{{ selectedMessage.sender.name }} <{{ selectedMessage.sender.address }}>
To:
Created: 94 | 95 | {{ selectedMessage.created_at | date:'medium' }} () 96 | 97 |
101 |
102 | 103 |
104 | 110 |
111 | 112 | 113 | 116 | 121 | 124 | 139 |
140 |
141 |
142 | 143 | 144 | -------------------------------------------------------------------------------- /lathermail/web/static/js/lathermail.js: -------------------------------------------------------------------------------- 1 | var lathermailApp = angular.module('lathermailApp', ['ngRoute', 'angularMoment', 'ngStorage']) 2 | 3 | 4 | .config(['$routeProvider', '$locationProvider', 5 | function($routeProvider) { 6 | $routeProvider. 7 | when('/messages', { 8 | controller: 'lathermailCtrl' 9 | }). 10 | when('/messages/:messageId', { 11 | redirectTo: function (params) { 12 | return '/messages/' + params.messageId + '/text' 13 | } 14 | }). 15 | when('/messages/:messageId/:currentTab', { 16 | templateUrl: function (params) { 17 | return 'message.' + params.currentTab + '.html' 18 | }, 19 | controller: 'lathermailDetailCtrl' 20 | }). 21 | otherwise({ 22 | redirectTo: '/messages' 23 | }); 24 | }]); 25 | 26 | 27 | lathermailApp.controller('lathermailCtrl', function ($scope, $http, $routeParams, $location, $localStorage) { 28 | $scope.$storage = $localStorage.$default({ 29 | inbox: null, 30 | password: "password" 31 | }); 32 | $scope.query = ""; 33 | $scope.params = $routeParams; 34 | $scope.inboxes = []; 35 | $scope.tabs = [ 36 | {url: "text", title: function(){return "Text"}}, 37 | {url: "html", title: function(){return "HTML"}}, 38 | {url: "raw", title: function(){return "Raw"}}, 39 | { 40 | url: "attachments", 41 | title: function(message){ 42 | var attachmentCount = 0; 43 | if(message){ 44 | message.parts.forEach(function(p){ 45 | if(p.is_attachment) attachmentCount++; 46 | }); 47 | message.attachmentCount = attachmentCount; 48 | } 49 | return "Attachments" + "(" + attachmentCount + ")"; 50 | } 51 | } 52 | ]; 53 | 54 | $scope.messageById = {}; 55 | $scope.messages = null; 56 | $scope.selectedMessage = null; 57 | 58 | $scope.refreshMessages = function () { 59 | $http.defaults.headers.common = { 60 | "X-Mail-Inbox": $scope.$storage.inbox, 61 | "X-Mail-Password": $scope.$storage.password 62 | }; 63 | 64 | return $http.get("/api/0/inboxes/").then(function(resp) { 65 | $scope.inboxes = resp.data.inbox_list; 66 | if ($scope.inboxes.length && (!$scope.$storage.inbox || $scope.inboxes.indexOf($scope.$storage.inbox) == -1)) { 67 | $scope.$storage.inbox = $scope.inboxes[0]; 68 | } 69 | $http.defaults.headers.common["X-Mail-Inbox"] = $scope.$storage.inbox; 70 | }).then(function () { 71 | return $http.get("/api/0/messages/").then(function (resp) { 72 | $scope.messages = resp.data.message_list; 73 | $scope.messageById = {}; 74 | 75 | var index = 0; 76 | $scope.messages.forEach(function(message){ 77 | message.index = index++; 78 | $scope.messageById[message._id] = message; 79 | }); 80 | 81 | if ($routeParams.messageId) { 82 | $scope.selectMessage($scope.messageById[$routeParams.messageId]); 83 | } else { 84 | $location.path("/messages/" + $scope.messages[0]._id) 85 | } 86 | if (!$scope.messages || !$scope.messages.length){ 87 | $scope.selectedMessage = null; 88 | } 89 | }); 90 | }); 91 | }; 92 | 93 | $scope.selectMessage = function (message) { 94 | $scope.selectedMessage = message; 95 | }; 96 | 97 | $scope.isActiveMessage = function(message){ 98 | return $scope.selectedMessage && message._id === $scope.selectedMessage._id; 99 | }; 100 | 101 | $scope.deleteMessage = function () { 102 | $http.delete("/api/0/messages/" + $scope.selectedMessage._id).then(function (resp) { 103 | var index = $scope.selectedMessage.index; 104 | 105 | $scope.refreshMessages().then(function () { 106 | index = Math.max(Math.min(index, $scope.messages.length), 0); 107 | if($scope.messages.length > 1){ 108 | $scope.go('/messages/' + $scope.messages[index]._id + '/' + $routeParams.currentTab); 109 | } 110 | }); 111 | }); 112 | }; 113 | 114 | $scope.deleteAllMessages = function () { 115 | if(confirm("Are you sure you wan't to delete all messages in '" + $scope.$storage.inbox + "'?")){ 116 | $http.delete("/api/0/messages/").then(function (resp) { 117 | $scope.refreshMessages(); 118 | }); 119 | } 120 | }; 121 | 122 | $scope.isActiveTab = function (tabUrl) { 123 | return tabUrl === $routeParams.currentTab; 124 | }; 125 | $scope.go = function(path){ 126 | $location.path(path); 127 | }; 128 | 129 | $scope.refreshMessages(); 130 | }) 131 | .controller('lathermailDetailCtrl', function ($scope, $http, $routeParams) { 132 | $scope.params = $routeParams; 133 | 134 | if ($routeParams.messageId && $scope.messageById){ 135 | var message = $scope.messageById[$routeParams.messageId]; 136 | if (message){ 137 | $scope.selectMessage(message); 138 | } 139 | } 140 | }) 141 | 142 | .filter("trust", ['$sce', function($sce) { 143 | return function(htmlCode){ 144 | return $sce.trustAsHtml(htmlCode); 145 | } 146 | }]) 147 | 148 | .directive("recipients", function(){ 149 | return { 150 | restrict: "E", 151 | replace: true, 152 | scope: { 153 | recipients: '=' 154 | }, 155 | template: '{{ rcpt.name }} <{{ rcpt.address }}>{{$last ? "" : ", "}}' 156 | } 157 | }) 158 | .directive('fullHeight', function ($window) { 159 | return { 160 | restrict: 'A', 161 | link: function (scope, element, attrs) { 162 | var handle = function(){ 163 | element.css({'height': $window.innerHeight - element.offset().top}); 164 | }; 165 | angular.element($window).bind('resize', handle); 166 | scope.$watch(handle); 167 | } 168 | }; 169 | }); 170 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal = 1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup, find_packages 3 | 4 | install_requires = [ 5 | "Flask==0.10.1", 6 | "Flask-RESTful==0.2.12", 7 | "Flask-PyMongo==0.3.0", 8 | "pymongo==2.7.1", 9 | "python-dateutil==2.2", 10 | "SQLAlchemy==1.0.9", 11 | "Flask-SQLAlchemy==2.1", 12 | "MarkupSafe<=1.1.1", 13 | "Jinja2<=2.11.2", 14 | "itsdangerous<=1.1.0", 15 | ] 16 | 17 | 18 | def read(fname): 19 | return open(os.path.join(os.path.dirname(__file__), fname)).read() 20 | 21 | setup( 22 | name="lathermail", 23 | url="https://github.com/reclosedev/lathermail/", 24 | version="0.4.2", 25 | author="Roman Haritonov", 26 | description="SMTP Server with API for email testing inspired by mailtrap and maildump", 27 | author_email="reclosedev@gmail.com", 28 | license="MIT", 29 | packages=find_packages("."), 30 | include_package_data=True, 31 | install_requires=install_requires, 32 | entry_points={ 33 | 'console_scripts': 34 | [ 35 | 'lathermail = lathermail.run_all:main', 36 | ] 37 | }, 38 | classifiers=[ 39 | "Development Status :: 4 - Beta", 40 | "Environment :: Web Environment", 41 | "License :: OSI Approved :: MIT License", 42 | "Programming Language :: Python :: 2", 43 | "Programming Language :: Python :: 3", 44 | "Programming Language :: Python :: Implementation :: CPython", 45 | "Programming Language :: Python :: Implementation :: PyPy", 46 | "Topic :: Internet :: WWW/HTTP :: WSGI :: Server", 47 | "Topic :: Communications :: Email", 48 | ], 49 | long_description=read('README.rst') + '\n\n' + read('CHANGELOG.rst'), 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/reclosedev/lathermail/b017c7152761a824fedeef3e56840b2125478913/tests/__init__.py -------------------------------------------------------------------------------- /tests/__main__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | 5 | def main(): 6 | all_tests = unittest.TestLoader().discover(os.path.dirname(__file__)) 7 | unittest.TextTestRunner(failfast=False, buffer=True).run(all_tests) 8 | 9 | 10 | main() 11 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from tests.utils import BaseTestCase, smtp_send_email, send_email_plain, prepare_send_to_field, SendEmailError,\ 5 | InvalidStatus 6 | from lathermail.utils import utcnow 7 | 8 | 9 | class ApiTestCase(BaseTestCase): 10 | 11 | def test_send_and_search(self): 12 | to_tuple = [("Rcpt1", "rcpt1@example.com"), ("Rcpt2", "rcpt2@example.com"), ("", "rcpt3@example.com")] 13 | emails = [t[1] for t in to_tuple] 14 | to = prepare_send_to_field(to_tuple) 15 | n = 3 16 | body_fmt = "you you привет {} \n\naaa\nbbb\nzz" 17 | subject_fmt = "Test subject хэллоу {}" 18 | file_content = "file content" 19 | sender_name = "Me" 20 | sender_addr = "asdf@exmapl.com" 21 | 22 | for i in range(n): 23 | smtp_send_email( 24 | to, subject_fmt.format(i), "%s <%s>" % (sender_name, sender_addr), body_fmt.format(i), 25 | user=self.inbox, password=self.password, port=self.port, emails=emails, 26 | attachments=[("tасдest.txt", file_content)] 27 | ) 28 | res = self.get("/messages/").json 29 | self.assertEquals(res["message_count"], n) 30 | 31 | msg = res["message_list"][0] 32 | self.assertEquals(len(msg["parts"]), 2) 33 | self.assertEquals(msg["parts"][0]["body"], body_fmt.format(n - 1)) 34 | self.assertEquals(msg["parts"][0]["is_attachment"], False) 35 | self.assertEquals(msg["parts"][1]["is_attachment"], True) 36 | self.assertIsNone(msg["parts"][1]["body"]) 37 | self.assertEquals(len(msg["recipients"]), len(to_tuple)) 38 | self.assertEquals([(rcpt["name"], rcpt["address"]) for rcpt in msg["recipients"]], to_tuple) 39 | self.assertEquals(msg["sender"]["name"], sender_name) 40 | self.assertEquals(msg["sender"]["address"], sender_addr) 41 | 42 | def msg_count(params=None): 43 | return self.get("/messages/", params=params).json["message_count"] 44 | 45 | self.assertEquals(msg_count({"subject": subject_fmt.format(0)}), 1) 46 | self.assertEquals(msg_count({"subject_contains": "Test"}), n) 47 | self.assertEquals(msg_count({"subject_contains": "no such message"}), 0) 48 | 49 | before_send = utcnow() 50 | smtp_send_email("wwwww@wwwww.www", "wwwww", "www@wwwww.www", "wwwwwwww", 51 | user=self.inbox, password=self.password, port=self.port) 52 | 53 | self.assertEquals(msg_count({"recipients.address": emails[0]}), n) 54 | self.assertEquals(msg_count({"recipients.address": "no_such_email@example.com"}), 0) 55 | self.assertEquals(msg_count({"recipients.address_contains": emails[0][3:]}), n) 56 | self.assertEquals(msg_count({"recipients.address_contains": emails[0][:3]}), n) 57 | self.assertEquals(msg_count({"recipients.name": "Rcpt1"}), n) 58 | self.assertEquals(msg_count({"recipients.name": "Rcpt"}), 0) 59 | self.assertEquals(msg_count({"recipients.name_contains": "Rcpt"}), n) 60 | self.assertEquals(msg_count({"sender.name": sender_name}), n) 61 | self.assertEquals(msg_count({"sender.name": "unknown"}), 0) 62 | self.assertEquals(msg_count({"sender.name": sender_name[0]}), 0) 63 | self.assertEquals(msg_count({"sender.name_contains": sender_name[0]}), n) 64 | self.assertEquals(msg_count({"sender.name_contains": sender_name[-1]}), n) 65 | self.assertEquals(msg_count({"sender.address": sender_addr}), n) 66 | self.assertEquals(msg_count({"sender.address": sender_addr[0]}), 0) 67 | self.assertEquals(msg_count({"sender.address_contains": sender_addr[0]}), n) 68 | self.assertEquals(msg_count({"sender.address_contains": sender_addr[-1]}), n) 69 | 70 | now = utcnow() 71 | self.assertEquals(msg_count({"created_at_lt": before_send}), n) 72 | self.assertEquals(msg_count({"created_at_gt": before_send}), 1) 73 | self.assertEquals(msg_count({"created_at_lt": now}), n + 1) 74 | self.assertEquals(msg_count({"created_at_gt": now}), 0) 75 | 76 | def test_different_boxes_and_deletion(self): 77 | password1 = "pass1" 78 | password2 = "pass2" 79 | user = "inbox" 80 | n = 5 81 | 82 | def message_count(user, password): 83 | return self.get("/messages/", headers=auth(user, password)).json["message_count"] 84 | 85 | for i in range(n): 86 | self.send(user, password1) 87 | self.send(user, password2) 88 | 89 | self.assertEquals(message_count(user, password1), n) 90 | self.assertEquals(message_count(user, password2), n) 91 | 92 | one_message = self.get("/messages/", headers=auth(user, password1)).json["message_list"][0] 93 | self.delete("/messages/{}".format(one_message["_id"]), headers=auth(user, password1)) 94 | self.assertEquals( 95 | self.delete("/messages/{}".format(one_message["_id"]), 96 | headers=auth(user, password1), raise_errors=False).status_code, 97 | 404 98 | ) 99 | self.assertEquals(message_count(user, password1), n - 1) 100 | self.delete("/messages/", headers=auth(user, password1)) 101 | self.assertEquals(message_count(user, password1), 0) 102 | 103 | n_new = 2 104 | new_subject = "new subject" 105 | for i in range(n_new): 106 | self.send(user, password2, subject=new_subject) 107 | 108 | self.assertEquals(message_count(user, password2), n + n_new) 109 | self.delete("/messages/", headers=auth(user, password2), params={"subject": new_subject}) 110 | self.assertEquals(message_count(user, password2), n) 111 | 112 | def test_read_flag(self): 113 | n_read = 5 114 | n_unread = 3 115 | subject_read = "read emails" 116 | subject_unread = "unread emails" 117 | 118 | for i in range(n_read): 119 | self.send(subject=subject_read) 120 | for i in range(n_unread): 121 | self.send(subject=subject_unread) 122 | 123 | self.assertEquals(self.get("/messages/", {"subject": subject_read}).json["message_count"], n_read) 124 | self.assertEquals(self.get("/messages/", {"read": False}).json["message_count"], n_unread) 125 | self.assertEquals(self.get("/messages/", {"read": False}).json["message_count"], 0) 126 | self.assertEquals(self.get("/messages/").json["message_count"], n_unread + n_read) 127 | 128 | def test_get_inboxes(self): 129 | inboxes = ["first", "second", "third"] 130 | for inbox in inboxes: 131 | self.send(inbox) 132 | self.send("another_inbox", "another_password") 133 | retreived = self.get("/inboxes/", headers=auth(None, self.password)).json["inbox_list"] 134 | self.assertEquals(sorted(retreived), sorted(inboxes)) 135 | self.assertEquals(self.get("/inboxes/", headers=auth(None, "unknown")).json["inbox_count"], 0) 136 | 137 | def test_binary_attach(self): 138 | binary_data = b"%PDF\x93" 139 | smtp_send_email( 140 | "test@example.com", "Binary test", "Test ", "Text body да", 141 | user=self.inbox, password=self.password, port=self.port, 142 | attachments=[("filename.pd", binary_data)] 143 | ) 144 | msg = self.get("/messages/").json["message_list"][0] 145 | self.assertEquals(self.get("/messages/{}/attachments/{}".format(msg["_id"], 1), 146 | parse_json=False).data, binary_data) 147 | 148 | def test_html_alternative_and_attach(self): 149 | binary_data = b"%PDF\x93" 150 | html_body = "

hello

" 151 | text_body = "Text body да" 152 | smtp_send_email( 153 | "test@example.com", "Binary test", "Test ", text_body, 154 | user=self.inbox, password=self.password, port=self.port, 155 | attachments=[("filename.pd", binary_data)], 156 | html_body=html_body 157 | ) 158 | msg = self.get("/messages/").json["message_list"][0] 159 | self.assertEqual(len(msg["parts"]), 3) 160 | self.assertEqual(msg["parts"][0]["body"], text_body) 161 | self.assertEqual(msg["parts"][1]["body"], html_body) 162 | self.assertIsNone(msg["parts"][2]["body"]) 163 | self.assertEquals(self.get("/messages/{}/attachments/{}".format(msg["_id"], 2), 164 | parse_json=False).data, binary_data) 165 | 166 | def test_get_single_message(self): 167 | self.send() 168 | msg = self.get("/messages/").json["message_list"][0] 169 | msg2 = self.get("/messages/{0}".format(msg["_id"])).json["message_info"] 170 | 171 | msg.pop("read") 172 | msg2.pop("read") 173 | self.assertEquals(msg2, msg) 174 | 175 | def test_not_found(self): 176 | self.send() 177 | wrong_id = "56337fb2b2c79a71698baaaa" 178 | with self.assertRaises(InvalidStatus) as e: 179 | self.get("/messages/" + wrong_id) 180 | self.assertEquals(e.exception.response.status_code, 404) 181 | 182 | msg = self.get("/messages/").json["message_list"][0] 183 | 184 | for part in 0, 1, 2: 185 | with self.assertRaises(InvalidStatus): 186 | self.get("/messages/{0}/attachments/{1}".format(msg["_id"], part)) 187 | self.assertEquals(e.exception.response.status_code, 404) 188 | 189 | with self.assertRaises(InvalidStatus) as e: 190 | self.get("/messages/{0}/attachments/1".format(wrong_id)) 191 | self.assertEquals(e.exception.response.status_code, 404) 192 | 193 | def test_wrong_smtp_credentials(self): 194 | with self.assertRaises(SendEmailError) as e: 195 | self.send(user="\0\0") 196 | self.assertEquals(e.exception.args[0].smtp_code, 535) 197 | 198 | with self.assertRaises(SendEmailError) as e: 199 | smtp_send_email("to@example.com", "no credentials", "from@example.com", "body", port=self.port) 200 | self.assertEquals(e.exception.args[0].smtp_code, 530) 201 | 202 | def test_send_plain_message(self): 203 | text_body = "Text body" 204 | to = "asdf@exmapl.com" 205 | sender = "test@example.com" 206 | send_email_plain( 207 | sender, to, text_body.encode("utf-8"), 208 | user=self.inbox, password=self.password, port=self.port, 209 | ) 210 | msg = self.get("/messages/").json["message_list"][0] 211 | self.assertEqual(msg["message_raw"], text_body) 212 | self.assertEqual(msg["recipients_raw"], to) 213 | self.assertEqual(msg["sender_raw"], sender) 214 | 215 | 216 | def auth(user, password): 217 | return {"X-Mail-Inbox": user, "X-Mail-Password": password} 218 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals, print_function 3 | 4 | import os 5 | import time 6 | import socket 7 | import unittest 8 | import smtplib 9 | import json 10 | from threading import Thread 11 | from email.mime.multipart import MIMEMultipart 12 | from email.mime.base import MIMEBase 13 | from email.mime.text import MIMEText 14 | from email.utils import formatdate, formataddr 15 | from email.header import Header 16 | 17 | import lathermail 18 | import lathermail.db 19 | import lathermail.smtp 20 | from lathermail.compat import Encoders, NO_CONTENT, unicode, urlencode 21 | 22 | 23 | class InvalidStatus(Exception): 24 | def __init__(self, response): 25 | super(InvalidStatus, self).__init__("Invalid status {}.\n{}".format(response.status_code, response.data)) 26 | self.response = response 27 | self.code = response.status_code 28 | 29 | 30 | class SendEmailError(Exception): 31 | """ Exception, raised in case send is failed. 32 | """ 33 | 34 | 35 | class BaseTestCase(unittest.TestCase): 36 | 37 | inbox = "inbox" 38 | password = "password" 39 | port = 2525 40 | server = None 41 | db_name = "lathermail_test_db" 42 | prefix = "/api/0" 43 | 44 | @classmethod 45 | def setUpClass(cls): 46 | conf = lathermail.app.config 47 | 48 | if os.getenv("LATHERMAIL_TEST_DB_TYPE", "sqlite") == "mongo": 49 | conf["DB_URI"] = conf["SQLALCHEMY_DATABASE_URI"] = "mongodb://localhost/%s" % cls.db_name 50 | else: 51 | conf["DB_URI"] = conf["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:" 52 | 53 | lathermail.init_app() 54 | cls.c = lathermail.app.test_client() 55 | super(BaseTestCase, cls).setUpClass() 56 | cls.server = SmtpServerRunner(cls.db_name) 57 | cls.server.start(cls.port) 58 | 59 | @classmethod 60 | def tearDownClass(cls): 61 | super(BaseTestCase, cls).tearDownClass() 62 | 63 | def setUp(self): 64 | lathermail.db.engine.switch_db(self.db_name) 65 | super(BaseTestCase, self).setUp() 66 | 67 | def tearDown(self): 68 | with lathermail.app.app_context(): 69 | lathermail.db.engine.drop_database(self.db_name) 70 | 71 | def request(self, method, url, params=None, raise_errors=True, parse_json=True, **kwargs): 72 | method = method.lower() 73 | new_kwargs = {"headers": {"X-Mail-Inbox": self.inbox, "X-Mail-Password": self.password}} 74 | new_kwargs.update(kwargs) 75 | func = getattr(self.c, method.lower()) 76 | if params: 77 | params = _prepare_params(params) 78 | if method in ("get", "delete"): 79 | new_kwargs["query_string"] = urlencode(params) 80 | else: 81 | new_kwargs["data"] = params 82 | 83 | rv = func(self.prefix + url, **new_kwargs) 84 | if parse_json: 85 | try: 86 | rv.json = json.loads(rv.data.decode("utf-8")) 87 | except ValueError as e: 88 | if rv.status_code != NO_CONTENT: 89 | print("JSON decode error: {}, data:\n{}".format(e, rv.data)) 90 | rv.json = None 91 | if raise_errors and rv.status_code >= 400: 92 | raise InvalidStatus(rv) 93 | return rv 94 | 95 | def get(self, url, params=None, **kwargs): 96 | return self.request("get", url, params, **kwargs) 97 | 98 | def delete(self, url, params=None, **kwargs): 99 | return self.request("delete", url, params, **kwargs) 100 | 101 | def send(self, user=None, password=None, subject="test", body="Hello"): 102 | smtp_send_email("test1@example.com", subject, "me@example.com", body, 103 | user=user or self.inbox, password=password or self.password, port=self.port) 104 | 105 | 106 | def _prepare_params(params): 107 | def convert(v): 108 | if isinstance(v, unicode): 109 | return v.encode("utf-8") 110 | if isinstance(v, str): 111 | return v 112 | return str(v) 113 | return {convert(k): convert(v) for k, v in params.items()} 114 | 115 | 116 | def prepare_send_to_field(name_email_pairs): 117 | return ", ".join([formataddr((str(Header(name, "utf-8")), email)) 118 | for name, email in name_email_pairs]) 119 | 120 | 121 | def smtp_send_email(email, subject, from_addr, body, server_host="127.0.0.1", user=None, password=None, 122 | emails=None, attachments=None, port=0, html_body=None): 123 | msg = MIMEMultipart() 124 | msg['To'] = email 125 | msg['Subject'] = subject 126 | msg['From'] = from_addr 127 | msg['Date'] = formatdate(localtime=True) 128 | msg.attach(MIMEText(body, _charset="utf8")) 129 | if html_body: 130 | msg.attach(MIMEText(html_body, "html", _charset="utf8")) 131 | for name, data in attachments or []: 132 | part = MIMEBase('application', "octet-stream") 133 | part.set_payload(data) 134 | Encoders.encode_base64(part) 135 | part.add_header('Content-Disposition', 'attachment', filename=(Header(name, 'utf-8').encode())) 136 | msg.attach(part) 137 | try: 138 | s = smtplib.SMTP(server_host, port) 139 | if user and password: 140 | s.login(user, password) 141 | if emails is None: 142 | emails = [email] 143 | s.sendmail(from_addr, emails, msg.as_string()) 144 | s.quit() 145 | #print(u"Sent email to [%s] from [%s] with subject [%s]", email, from_addr, subject) 146 | except (smtplib.SMTPConnectError, smtplib.SMTPException, IOError) as e: 147 | print("Sending email error to [%s] from [%s] with subject [%s]:\n%s", email, from_addr, subject, e) 148 | raise SendEmailError(e) 149 | 150 | 151 | def send_email_plain(from_addr, to_addrs, msg, user=None, password=None, server_host="127.0.0.1", port=0): 152 | try: 153 | s = smtplib.SMTP(server_host, port) 154 | if user and password: 155 | s.login(user, password) 156 | s.sendmail(from_addr, to_addrs, msg) 157 | s.quit() 158 | except (smtplib.SMTPConnectError, smtplib.SMTPException, IOError) as e: 159 | print("Sending email error to [%s] from [%s] with subject [%s]:\n%s", to_addrs, from_addr, e) 160 | raise SendEmailError(e) 161 | 162 | 163 | class SmtpServerRunner(object): 164 | 165 | def __init__(self, db_name): 166 | self._process = None 167 | self.db_name = db_name 168 | 169 | def start(self, port=2025): 170 | 171 | def wrapper(**kwargs): 172 | lathermail.db.engine.switch_db(self.db_name) 173 | lathermail.smtp.serve_smtp(**kwargs) 174 | 175 | smtp_thread = Thread(target=wrapper, kwargs=dict(handler=lathermail.db.engine.message_handler, port=port)) 176 | smtp_thread.daemon = True 177 | smtp_thread.start() 178 | self.wait_start(port) 179 | 180 | def wait_start(self, port): 181 | timeout = 0.1 182 | host_port = ("127.0.0.1", port) 183 | for i in range(10): 184 | try: 185 | sock = socket.create_connection(host_port, timeout=timeout) 186 | except Exception: 187 | time.sleep(timeout) 188 | continue 189 | else: 190 | sock.close() 191 | return 192 | raise Exception("Can't connect to %s" % str(host_port)) 193 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27,py34,py35,pypy 3 | [testenv] 4 | deps = pytest 5 | passenv = * 6 | commands = py.test {posargs} 7 | --------------------------------------------------------------------------------