├── .nvmrc ├── byemail ├── tests │ ├── __init__.py │ ├── commons.py │ ├── data │ │ ├── example_public.pem │ │ └── example_private.pem │ ├── workdir │ │ └── settings.py │ ├── conftest.py │ ├── data_emails.py │ ├── test_mailutils.py │ ├── test_httpserver.py │ ├── test_smtp.py │ └── test_storage.py ├── client │ ├── cypress.json │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── img │ │ │ └── icons │ │ │ │ ├── favicon-16x16.png │ │ │ │ ├── favicon-32x32.png │ │ │ │ ├── mstile-150x150.png │ │ │ │ ├── apple-touch-icon.png │ │ │ │ ├── android-chrome-192x192.png │ │ │ │ ├── android-chrome-512x512.png │ │ │ │ ├── apple-touch-icon-120x120.png │ │ │ │ ├── apple-touch-icon-152x152.png │ │ │ │ ├── apple-touch-icon-180x180.png │ │ │ │ ├── apple-touch-icon-60x60.png │ │ │ │ ├── apple-touch-icon-76x76.png │ │ │ │ ├── msapplication-icon-144x144.png │ │ │ │ └── safari-pinned-tab.svg │ │ ├── manifest.json │ │ └── index.html │ ├── .postcssrc.js │ ├── babel.config.js │ ├── src │ │ ├── assets │ │ │ └── logo.png │ │ ├── plugins │ │ │ └── vuetify.js │ │ ├── components │ │ │ ├── AttachmentField.vue │ │ │ ├── QuickReply.vue │ │ │ ├── MessageList.vue │ │ │ ├── NotificationButton.vue │ │ │ ├── MessageContent.vue │ │ │ ├── SearchAddressField.vue │ │ │ ├── MailboxList.vue │ │ │ └── MessageComposer.vue │ │ ├── store │ │ │ ├── modules │ │ │ │ ├── account.js │ │ │ │ ├── draft.js │ │ │ │ ├── push-notifications.js │ │ │ │ └── mailboxes.js │ │ │ ├── mutation-types.js │ │ │ └── index.js │ │ ├── main.js │ │ ├── App.vue │ │ ├── views │ │ │ ├── MailEdit.vue │ │ │ ├── Mailbox.vue │ │ │ ├── Login.vue │ │ │ ├── Webmail.vue │ │ │ ├── Mailboxes.vue │ │ │ ├── Settings.vue │ │ │ └── Mail.vue │ │ ├── router.js │ │ ├── registerServiceWorker.js │ │ └── service-worker.js │ ├── tests │ │ └── unit │ │ │ ├── .eslintrc.js │ │ │ └── HelloWorld.spec.js │ ├── .prettierrc.json │ ├── .gitignore │ ├── .eslintrc.js │ ├── vue.config.js │ ├── jest.config.js │ ├── README.md │ ├── package.json │ └── cypress │ │ └── integration │ │ └── main.js ├── __init__.py ├── scripts │ ├── send_test_mail.py │ ├── generate_test_mail.py │ └── show_in_db.py ├── storage │ ├── __init__.py │ ├── core.py │ └── tinydb.py ├── archives │ ├── inject_test_mail.py │ ├── utils.py │ ├── perserv.py │ ├── msg.py │ └── mailutils.py ├── default_settings.py ├── settings_tpl.py ├── conf.py ├── account.py ├── push.py ├── middlewares │ └── dkim.py ├── helpers │ └── reloader.py ├── mailutils.py └── commands.py ├── setup.cfg ├── MANIFEST.in ├── pytest.ini ├── LINKS.txt ├── CHANGELOG.md ├── tox.ini ├── .travis.yml ├── TODO.rst ├── .gitignore ├── .editorconfig ├── Pipfile ├── LICENSE ├── setup.py ├── pypi-release-cl.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /byemail/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /byemail/client/cypress.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [aliases] 2 | test=pytest -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include byemail/client/dist/ * -------------------------------------------------------------------------------- /byemail/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "0.0.1" 2 | __url__ = "" 3 | -------------------------------------------------------------------------------- /byemail/client/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths="byemail" 3 | norecursedirs = client -------------------------------------------------------------------------------- /LINKS.txt: -------------------------------------------------------------------------------- 1 | https://linuxfr.org/news/se-passer-de-google-facebook-et-autres-big-brothers-2-0-2-le-courriel -------------------------------------------------------------------------------- /byemail/client/.postcssrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } -------------------------------------------------------------------------------- /byemail/client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /byemail/client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/favicon.ico -------------------------------------------------------------------------------- /byemail/client/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/src/assets/logo.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /byemail/client/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jrmi/byemail/HEAD/byemail/client/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # [Changelog](https://github.com/jrmi/byemail/releases) 2 | 3 | ## [0.0.1](https://github.com/jrmi/byemail/compare/0.0.1...0.0.1) 4 | 5 | - First version 6 | -------------------------------------------------------------------------------- /byemail/client/src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuetify from 'vuetify' 3 | import 'vuetify/dist/vuetify.min.css' 4 | 5 | Vue.use(Vuetify, {}) 6 | -------------------------------------------------------------------------------- /byemail/client/tests/unit/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | jest: true 4 | }, 5 | rules: { 6 | 'import/no-extraneous-dependencies': 'off' 7 | } 8 | } -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py37 3 | skip_install = True 4 | envdir = .toxvenv 5 | 6 | [testenv] 7 | envdir = .toxvenv 8 | deps = pytest 9 | commands = pytest {posargs} 10 | -------------------------------------------------------------------------------- /byemail/tests/commons.py: -------------------------------------------------------------------------------- 1 | from email import policy 2 | from email.parser import BytesParser 3 | 4 | class objectview(object): 5 | def __init__(self, d): 6 | self.__dict__ = d 7 | -------------------------------------------------------------------------------- /byemail/client/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "tabWidth": 2, 4 | "semi": false, 5 | "printWidth": 100, 6 | "bracketSpacing": true, 7 | "arrowParens": "avoid", 8 | "singleQuote": true 9 | } 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | python: 4 | - '3.7' 5 | install: 6 | - pipenv install --dev 7 | script: 8 | - pytest 9 | notifications: 10 | email: 11 | - 571533+jrmi@users.noreply.github.com 12 | -------------------------------------------------------------------------------- /TODO.rst: -------------------------------------------------------------------------------- 1 | V1 2 | == 3 | 4 | - Receive 90% of emails 5 | - Store in db 6 | - Make mail boxes 7 | - Unsecure webmail that allow view of all stored mails 8 | 9 | 10 | Next 11 | ==== 12 | 13 | - Handle thread 14 | - Spam detection 15 | - Heavy attachment sending 16 | - Easy save -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .env.leave 3 | db.json 4 | maildb.json 5 | data/ 6 | test_msg/ 7 | 8 | 9 | node_modules/ 10 | 11 | *.egg-info/ 12 | __pycache__/ 13 | .eggs/ 14 | site-packages/ 15 | 16 | .pytest_cache/ 17 | .tox 18 | .toxvenv/ 19 | .venv 20 | build/ 21 | dist/ 22 | 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /byemail/tests/data/example_public.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkM 3 | oGeLnQg1fWn7/zYtIxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v/R 4 | tdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhitdY9tf6mcwGjaNBcWToI 5 | MmPSPDdQPNUYckcQ2QIDAQAB 6 | -----END PUBLIC KEY----- 7 | -------------------------------------------------------------------------------- /byemail/client/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw* 22 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 4 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | # 4 space indentation 12 | [*.py] 13 | indent_style = space 14 | indent_size = 4 15 | 16 | # Indentation override for all JS under lib directory 17 | [byemail/client/**.{js,vue}] 18 | indent_style = space 19 | indent_size = 2 -------------------------------------------------------------------------------- /byemail/client/tests/unit/HelloWorld.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils' 2 | import HelloWorld from '@/components/HelloWorld.vue' 3 | 4 | describe('HelloWorld.vue', () => { 5 | it('renders props.msg when passed', () => { 6 | const msg = 'new message' 7 | const wrapper = shallowMount(HelloWorld, { 8 | propsData: { msg } 9 | }) 10 | expect(wrapper.text()).toMatch(msg) 11 | }) 12 | }) 13 | -------------------------------------------------------------------------------- /byemail/client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ['plugin:vue/essential', '@vue/standard'], 7 | rules: { 8 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 9 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 10 | 'space-before-function-paren': ['warn', 'never'] 11 | }, 12 | parserOptions: { 13 | parser: 'babel-eslint' 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /byemail/client/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | devServer: { 3 | proxy: { 4 | '/api': { 5 | target: 'http://localhost:8000', 6 | ws: true, 7 | changeOrigin: false 8 | }, 9 | '/login': { 10 | target: 'http://localhost:8000' 11 | }, 12 | '/logout': { 13 | target: 'http://localhost:8000' 14 | } 15 | } 16 | }, 17 | pwa: { 18 | name: 'byemail-client', 19 | workboxPluginMode: 'InjectManifest', 20 | workboxOptions: { 21 | swSrc: 'src/service-worker.js' 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /byemail/tests/workdir/settings.py: -------------------------------------------------------------------------------- 1 | """ Test configuration file """ 2 | from byemail.default_settings import * 3 | 4 | ACCOUNTS = [ 5 | { 6 | "name": "test", 7 | "password": "test_pass", 8 | "accept": ["@anything.com"], 9 | "address": "Test ", 10 | } 11 | ] 12 | 13 | STORAGE = { 14 | "backend": "byemail.storage.sqldb.Backend", 15 | "config": { 16 | "default": { 17 | "engine": "tortoise.backends.sqlite", 18 | "credentials": {"file_path": ":memory:"}, 19 | } 20 | }, 21 | } 22 | 23 | -------------------------------------------------------------------------------- /byemail/client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleFileExtensions: [ 3 | 'js', 4 | 'jsx', 5 | 'json', 6 | 'vue' 7 | ], 8 | transform: { 9 | '^.+\\.vue$': 'vue-jest', 10 | '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', 11 | '^.+\\.jsx?$': 'babel-jest' 12 | }, 13 | moduleNameMapper: { 14 | '^@/(.*)$': '/src/$1' 15 | }, 16 | snapshotSerializers: [ 17 | 'jest-serializer-vue' 18 | ], 19 | testMatch: [ 20 | '/(tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx))' 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /byemail/client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Byemail client", 3 | "short_name": "Byemail", 4 | "description": "Byemail application web client to access your mails everywhere", 5 | "icons": [ 6 | { 7 | "src": "./img/icons/android-chrome-192x192.png", 8 | "sizes": "192x192", 9 | "type": "image/png" 10 | }, 11 | { 12 | "src": "./img/icons/android-chrome-512x512.png", 13 | "sizes": "512x512", 14 | "type": "image/png" 15 | } 16 | ], 17 | "start_url": "./index.html", 18 | "display": "fullscreen", 19 | "background_color": "#00001a", 20 | "theme_color": "#6600cc" 21 | } 22 | -------------------------------------------------------------------------------- /byemail/client/README.md: -------------------------------------------------------------------------------- 1 | # byemail-client 2 | 3 | > The Byemail web client 4 | 5 | ## Build Setup 6 | 7 | ``` bash 8 | # install dependencies 9 | npm install 10 | 11 | # serve with hot reload at localhost:8080 12 | npm run serve 13 | 14 | # build for production with minification 15 | npm run build 16 | 17 | # build for production and view the bundle analyzer report 18 | npm run build --report 19 | 20 | # run unit tests 21 | npm run unit 22 | 23 | # run e2e tests 24 | npm run e2e 25 | 26 | # run all tests 27 | npm test 28 | ``` 29 | 30 | For detailed explanation on how things work, checkout the [guide](http://vuejs-templates.github.io/webpack/) and [docs for vue-loader](http://vuejs.github.io/vue-loader). 31 | -------------------------------------------------------------------------------- /byemail/client/src/components/AttachmentField.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 32 | 33 | 35 | -------------------------------------------------------------------------------- /byemail/client/src/store/modules/account.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import * as types from '../mutation-types' 3 | 4 | // initial state 5 | const state = { 6 | account: {} 7 | } 8 | 9 | // getters 10 | const getters = { 11 | account: state => { 12 | return state.account 13 | } 14 | } 15 | 16 | // mutations 17 | const mutations = { 18 | [types.SET_ACCOUNT](state, account) { 19 | state.account = { ...account } 20 | } 21 | } 22 | 23 | // actions 24 | const actions = { 25 | loadAccount({ commit }, { userId }) { 26 | return Vue.http.get(`/api/users/${userId}/account`, { responseType: 'json' }).then(response => { 27 | return commit(types.SET_ACCOUNT, { account: response }) 28 | }) 29 | } 30 | } 31 | 32 | export default { 33 | state, 34 | getters, 35 | actions, 36 | mutations 37 | } 38 | -------------------------------------------------------------------------------- /byemail/scripts/send_test_mail.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import email 5 | import codecs 6 | 7 | from email import policy 8 | from email.parser import BytesParser 9 | 10 | 11 | def mail_from_file(mail_path): 12 | 13 | with open(arg, "rb") as in_file: 14 | #import ipdb; ipdb.set_trace() 15 | ff = in_file.read() 16 | 17 | msg = BytesParser(policy=policy.default).parsebytes(ff) 18 | 19 | return msg 20 | 21 | 22 | if __name__ == "__main__": 23 | from smtplib import SMTP as Client 24 | 25 | client = Client('localhost', 8025) 26 | 27 | for arg in sys.argv: 28 | print(f"Sending {arg}") 29 | try: 30 | msg = mail_from_file(arg) 31 | r = client.sendmail('from@localhost', ['to@localhost'], msg.as_bytes()) 32 | except UnicodeEncodeError as e: 33 | print(f"EEEEEE - Fail to send {arg} - {e}") -------------------------------------------------------------------------------- /byemail/client/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import './plugins/vuetify' 3 | import App from './App.vue' 4 | import router from './router' 5 | import store from './store/index' 6 | import VueResource from 'vue-resource' 7 | import './registerServiceWorker' 8 | 9 | Vue.config.productionTip = false 10 | 11 | Vue.use(VueResource) 12 | 13 | Vue.http.interceptors.push(function(request) { 14 | // return response callback 15 | return function(response) { 16 | /* Log network errors and show message */ 17 | if (response.status >= 500) { 18 | console.log('Error while accessing backend. See error below...') 19 | store.dispatch('setLoading', false) 20 | store.dispatch('showMessage', { 21 | color: 'error', 22 | message: 'Network error...' 23 | }) 24 | } 25 | } 26 | }) 27 | 28 | new Vue({ 29 | router, 30 | store, 31 | render: h => h(App) 32 | }).$mount('#app') 33 | -------------------------------------------------------------------------------- /byemail/client/src/App.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 40 | -------------------------------------------------------------------------------- /byemail/storage/__init__.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from byemail.conf import settings 4 | 5 | class DoesntExists(Exception): 6 | pass 7 | 8 | class MultipleResults(Exception): 9 | pass 10 | 11 | class Storage(): 12 | def __init__(self, loop=None): 13 | self.loop = loop 14 | self._storage = None 15 | 16 | def load_storage(self, loop=None): 17 | global storage 18 | config = dict(settings.STORAGE) 19 | module_path, _, backend = config.pop('backend').rpartition('.') 20 | 21 | # Load storage backend 22 | module = import_module(module_path) 23 | 24 | return getattr(module, backend)(loop=loop, **config) 25 | 26 | def __getattr__(self, name): 27 | if not getattr(self, '_storage'): 28 | self._storage = self.load_storage(self.loop) 29 | 30 | return getattr(self._storage, name) 31 | 32 | 33 | storage = Storage() 34 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [pipenv] 7 | allow_prereleases = true 8 | 9 | [requires] 10 | python_version = "3.7" 11 | 12 | [packages] 13 | aiosmtpd = "*" 14 | codernitydb = "*" 15 | tinydb = "*" 16 | tinydb-serialization = "*" 17 | arrow = "*" 18 | sanic = "*" 19 | begins = "*" 20 | uvloop = "*" 21 | ujson = "*" 22 | aiodns = "*" 23 | sanic-auth = "*" 24 | python-magic = "*" 25 | dkimpy = "*" 26 | tortoise-orm = "*" 27 | asynctest = "*" 28 | py-vapid = "*" 29 | pywebpush = "*" 30 | urllib3 = ">=1.24.2" 31 | 32 | [dev-packages] 33 | pylint = "*" 34 | ipdb = "*" 35 | pytest = "*" 36 | tox = ">=3.5.0" 37 | icecream = "*" 38 | aiohttp = "*" 39 | faker = "*" 40 | pipenv = "*" 41 | tox-pipenv = {editable = true,ref = "master",git = "https://github.com/tox-dev/tox-pipenv.git"} 42 | rope = ">=0.11.0" 43 | ipython = "*" 44 | docutils = "*" 45 | black = "*" 46 | pytest-sanic = "*" 47 | -------------------------------------------------------------------------------- /byemail/tests/data/example_private.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXwIBAAKBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYtIxN2SnFC 3 | jxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v/RtdC2UzJ1lWT947qR+Rcac2gb 4 | to/NMqJ0fzfVjH4OuKhitdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB 5 | AoGBALmn+XwWk7akvkUlqb+dOxyLB9i5VBVfje89Teolwc9YJT36BGN/l4e0l6QX 6 | /1//6DWUTB3KI6wFcm7TWJcxbS0tcKZX7FsJvUz1SbQnkS54DJck1EZO/BLa5ckJ 7 | gAYIaqlA9C0ZwM6i58lLlPadX/rtHb7pWzeNcZHjKrjM461ZAkEA+itss2nRlmyO 8 | n1/5yDyCluST4dQfO8kAB3toSEVc7DeFeDhnC1mZdjASZNvdHS4gbLIA1hUGEF9m 9 | 3hKsGUMMPwJBAPW5v/U+AWTADFCS22t72NUurgzeAbzb1HWMqO4y4+9Hpjk5wvL/ 10 | eVYizyuce3/fGke7aRYw/ADKygMJdW8H/OcCQQDz5OQb4j2QDpPZc0Nc4QlbvMsj 11 | 7p7otWRO5xRa6SzXqqV3+F0VpqvDmshEBkoCydaYwc2o6WQ5EBmExeV8124XAkEA 12 | qZzGsIxVP+sEVRWZmW6KNFSdVUpk3qzK0Tz/WjQMe5z0UunY9Ax9/4PVhp/j61bf 13 | eAYXunajbBSOLlx4D+TunwJBANkPI5S9iylsbLs6NkaMHV6k5ioHBBmgCak95JGX 14 | GMot/L2x0IYyMLAz6oLWh2hm7zwtb0CgOrPo1ke44hFYnfc= 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /byemail/client/src/views/MailEdit.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 38 | 39 | 41 | -------------------------------------------------------------------------------- /byemail/client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 15 | Byemail 16 | 17 | 18 | 24 |
25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /byemail/client/src/components/QuickReply.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 38 | 39 | 40 | 52 | -------------------------------------------------------------------------------- /byemail/client/src/store/mutation-types.js: -------------------------------------------------------------------------------- 1 | // General 2 | export const SET_LOADING = 'SET_LOADING' 3 | export const SET_NOTIFICATION = 'SET_NOTIFICATION' 4 | export const SET_SERVICE_WORKER = 'setServiceWorker' 5 | export const SET_MESSAGE = 'setMessage' 6 | 7 | // Account 8 | export const SET_ACCOUNT = 'SET_ACCOUNT' 9 | 10 | // Mail 11 | export const SET_MAILBOXES = 'SET_MAILBOXES' 12 | export const SET_UNREADS = 'SET_UNREADS' 13 | export const RESET_MAILBOXES = 'resetMailboxes' 14 | export const SET_CURRENT_MAILBOX = 'SET_CURRENT_MAILBOX' 15 | export const SET_CURRENT_MAIL = 'SET_CURRENT_MAIL' 16 | export const SET_ALL_MAIL_READ = 'SET_ALL_MAIL_READ' 17 | export const SET_CURRENT_MAIL_READ = 'SET_CURRENT_MAIL_READ' 18 | export const ADD_NEW_MAIL = 'ADD_NEW_MAIL' 19 | 20 | // Draft 21 | export const RESET_DRAFT = 'resetDraft' 22 | export const ADD_DRAFT_RECIPIENT = 'addDraftRecipient' 23 | export const UPDATE_DRAFT_RECIPIENT = 'updateDraftRecipient' 24 | export const REMOVE_DRAFT_RECIPIENT = 'removeDraftRecipient' 25 | export const ADD_DRAFT_ATTACHMENT = 'addDraftAttachment' 26 | export const UPDATE_DRAFT_ATTACHMENT = 'updateDraftAttachment' 27 | export const REMOVE_DRAFT_ATTACHMENT = 'removeDraftAttachment' 28 | export const SET_DRAFT_SUBJECT = 'setDraftSubject' 29 | export const SET_DRAFT_CONTENT = 'setDraftContent' 30 | -------------------------------------------------------------------------------- /byemail/archives/inject_test_mail.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 -*- 3 | # 4 | 5 | # Import smtplib for the actual sending function 6 | import smtplib 7 | import sys 8 | 9 | # Import the email modules we'll need 10 | from email.MIMEMultipart import MIMEMultipart 11 | from email.mime.text import MIMEText 12 | from email.MIMEBase import MIMEBase 13 | from email import Encoders 14 | import email 15 | import os 16 | import codecs 17 | 18 | if __name__ == "__main__": 19 | # Send the message via our own SMTP server, but don't include the 20 | # envelope header. 21 | 22 | for arg in sys.argv[1:]: 23 | print(arg) 24 | #message_file = codecs.open( arg, "r", "utf-8" ) 25 | try: 26 | with open(arg, 'r') as in_file: 27 | msg = email.message_from_file(infile) 28 | except UnicodeDecodeError: 29 | with open(arg, 'r') as in_file: 30 | ff = file.read().decode('ISO-8859-1') 31 | msg = email.message_from_string(ff) 32 | 33 | me = "test@localhost" 34 | you = "test@localhost" 35 | #msg['From'] = me 36 | msg['To'] = you 37 | s = smtplib.SMTP("::1", 8025, "localhost", 10) 38 | s.set_debuglevel(0) 39 | s.sendmail(me, [you], msg.as_string()) 40 | 41 | s.quit() 42 | -------------------------------------------------------------------------------- /byemail/client/src/components/MessageList.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 36 | 37 | 38 | 45 | -------------------------------------------------------------------------------- /byemail/client/src/components/NotificationButton.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 46 | 47 | 48 | 50 | -------------------------------------------------------------------------------- /byemail/default_settings.py: -------------------------------------------------------------------------------- 1 | """ Configuration file """ 2 | import os 3 | 4 | ACCOUNTS = [] 5 | 6 | DEBUG = False 7 | 8 | STORAGE = { 9 | "backend": "byemail.storage.sqldb.Backend", 10 | "uri": "sqlite://db.sqlite" 11 | } 12 | 13 | DKIM_CONFIG = { 14 | 'private_key': 'dkimprivatekey.pem', 15 | 'public_key': 'dkimpublickey.pem', 16 | 'selector': 'dkim', 17 | 'domain': 'example.com', 18 | 'headers': ['From', 'To', 'Subject', 'Date', 'Message-Id'] 19 | } 20 | 21 | HTTP_CONF = { 22 | 'host': 'localhost', 23 | 'port': 8000 24 | } 25 | 26 | SMTP_CONF = { 27 | 'host': 'localhost', # None for default 28 | 'port': 8025, 29 | 'ssl': None, # For enabling SSL provide context 30 | } 31 | 32 | OUTGOING_MIDDLEWARES = [ 33 | # Next middleware not working yet 34 | # 'byemail.middlewares.dkim.sign' 35 | ] 36 | 37 | INCOMING_MIDDLEWARES = [ 38 | # Next middleware not working yet 39 | # 'byemail.middlewares.dkim.verify' 40 | ] 41 | 42 | LOGGING = { 43 | 'version': 1, 44 | 'disable_existing_loggers': False, 45 | 'formatters': { 46 | 'verbose': { 47 | 'format': '%(levelname)s %(asctime)s %(name)s %(module)s %(message)s' 48 | }, 49 | }, 50 | 'handlers': { 51 | 'console': { 52 | 'level':'DEBUG', 53 | 'class':'logging.StreamHandler', 54 | 'formatter': 'verbose' 55 | } 56 | }, 57 | 58 | 'loggers': { 59 | # root loggers 60 | '': { 61 | 'level': 'INFO', 62 | 'handlers': ['console'], 63 | }, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /byemail/client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "byemail-client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "description": "The byemail web client", 6 | "author": "Jeremie Pardou-Piquemal ", 7 | "scripts": { 8 | "serve": "vue-cli-service serve", 9 | "build": "vue-cli-service build", 10 | "lint": "vue-cli-service lint", 11 | "test:unit": "vue-cli-service test:unit", 12 | "cypress:open": "cypress open --browser chromium" 13 | }, 14 | "dependencies": { 15 | "crypto-js": "^3.1.9-1", 16 | "lodash": "^4.17.4", 17 | "material-icons": "^0.1.0", 18 | "moment": "^2.18.1", 19 | "register-service-worker": "^1.5.2", 20 | "vue": "^2.5.16", 21 | "vue-pull-refresh": "^0.2.7", 22 | "vue-resource": "^1.5.1", 23 | "vue-router": "^3.0.1", 24 | "vuetify": "^1.2.0", 25 | "vuex": "^3.0.1" 26 | }, 27 | "devDependencies": { 28 | "@vue/cli-plugin-babel": "^3.0.0-beta.15", 29 | "@vue/cli-plugin-eslint": "^3.0.0-beta.15", 30 | "@vue/cli-plugin-pwa": "^3.4.0", 31 | "@vue/cli-plugin-unit-jest": "^3.0.0-beta.15", 32 | "@vue/cli-service": "^3.0.0-beta.15", 33 | "@vue/eslint-config-standard": "^3.0.0-rc.3", 34 | "@vue/test-utils": "^1.0.0-beta.16", 35 | "babel-core": "7.0.0-bridge.0", 36 | "babel-jest": "^23.0.1", 37 | "cypress": "^3.2.0", 38 | "less": "^3.5.2", 39 | "less-loader": "^4.1.0", 40 | "vue-cli-plugin-vuetify": "^0.1.6", 41 | "vue-template-compiler": "^2.5.16" 42 | }, 43 | "browserslist": [ 44 | "> 1%", 45 | "last 2 versions", 46 | "not ie <= 8" 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /byemail/client/src/components/MessageContent.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 28 | 29 | 30 | 63 | -------------------------------------------------------------------------------- /byemail/client/src/router.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Router from 'vue-router' 3 | import Login from '@/views/Login' 4 | import Webmail from '@/views/Webmail' 5 | import Mailboxes from '@/views/Mailboxes' 6 | import Mailbox from '@/views/Mailbox' 7 | import Mail from '@/views/Mail' 8 | import MailEdit from '@/views/MailEdit' 9 | import Settings from '@/views/Settings' 10 | 11 | Vue.use(Router) 12 | 13 | export default new Router({ 14 | routes: [ 15 | { 16 | path: '/', 17 | redirect: { name: 'mailboxes' } 18 | }, 19 | { 20 | path: '/login', 21 | name: 'login', 22 | component: Login 23 | }, 24 | { 25 | path: '/webmail/:userId', 26 | name: 'webmail', 27 | component: Webmail, 28 | children: [ 29 | { 30 | path: 'mailboxes/', 31 | name: 'mailboxes', 32 | components: { 33 | default: Mailboxes 34 | }, 35 | children: [ 36 | { 37 | path: 'mailbox/:mailboxId', 38 | name: 'mailbox', 39 | components: { 40 | default: Mailbox 41 | } 42 | }, 43 | { 44 | path: 'mailbox/:mailboxId/mail/:mailId', 45 | name: 'mail', 46 | components: { 47 | default: Mailbox, 48 | mail: Mail 49 | } 50 | } 51 | ] 52 | }, 53 | { 54 | path: 'mailedit', 55 | name: 'mailedit', 56 | component: MailEdit 57 | } 58 | ] 59 | }, 60 | { 61 | path: '/settings/:userId', 62 | name: 'settings', 63 | component: Settings 64 | } 65 | ] 66 | }) 67 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from importlib import import_module 3 | 4 | VERSION = import_module("byemail").__version__ 5 | URL = import_module("byemail").__url__ 6 | 7 | with open("README.md", "r") as fh: 8 | long_description = fh.read() 9 | 10 | setup( 11 | name="byemail", 12 | version=VERSION, 13 | description="byemail", 14 | long_description=long_description, 15 | long_description_content_type="text/markdown", 16 | classifiers=[ 17 | "Development Status :: 4 - Beta", 18 | "License :: OSI Approved :: Apache Software License", 19 | "Programming Language :: Python :: 3.5", 20 | "Programming Language :: Python :: 3.6", 21 | "Programming Language :: Python :: 3 :: Only", 22 | "Operating System :: POSIX :: Linux", 23 | "Topic :: Internet", 24 | "Programming Language :: Python", 25 | ], 26 | keywords="mail asyncio smtp webmail", 27 | url=URL, 28 | author="Jeremie Pardou", 29 | author_email="jeremie.pardou@mhcomm.fr", 30 | license="Apache Software License", 31 | packages=["byemail", "byemail.storage", "byemail.helpers", "byemail.middlewares"], 32 | entry_points={"console_scripts": ["byemail = byemail.commands:run.start"]}, 33 | test_suite="pytest", 34 | include_package_data=True, 35 | install_requires=[ 36 | "begins", 37 | "python-magic", 38 | "aiosmtpd", 39 | "arrow", 40 | "ujson", 41 | "aiodns", 42 | "sanic", 43 | "sanic-auth", 44 | "tortoise-orm", 45 | "py-vapid", 46 | "pywebpush", 47 | "uvloop", 48 | ], 49 | extras_require={}, 50 | setup_requires=["pytest-runner"], 51 | tests_require=["pytest"], 52 | ) 53 | 54 | -------------------------------------------------------------------------------- /byemail/client/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import mailboxes from './modules/mailboxes' 4 | import draft from './modules/draft' 5 | import account from './modules/account' 6 | import notification from './modules/push-notifications' 7 | import createLogger from 'vuex/dist/logger' 8 | import * as types from './mutation-types' 9 | 10 | const debug = process.env.NODE_ENV !== 'production' 11 | 12 | Vue.use(Vuex) 13 | 14 | const state = { 15 | isLoading: false, 16 | message: '', 17 | messageColor: 'primary', 18 | serviceWorker: false 19 | } 20 | 21 | const mutations = { 22 | [types.SET_LOADING](state, status) { 23 | state.isLoading = status 24 | }, 25 | [types.SET_SERVICE_WORKER](state, status) { 26 | state.serviceWorker = status 27 | }, 28 | [types.SET_MESSAGE](state, status) { 29 | state.message = status.message 30 | state.messageColor = status.color 31 | } 32 | } 33 | 34 | const actions = { 35 | setLoading: ({ commit }, status) => { 36 | commit(types.SET_LOADING, status) 37 | }, 38 | setServiceWorker: ({ commit }, status) => { 39 | commit(types.SET_SERVICE_WORKER, status) 40 | }, 41 | showMessage: ({ commit }, status) => { 42 | commit(types.SET_MESSAGE, status) 43 | } 44 | } 45 | 46 | const getters = { 47 | isLoading: state => { 48 | return state.isLoading 49 | }, 50 | serviceWorkerRegistered: state => { 51 | return state.serviceWorker 52 | }, 53 | getMessage: state => { 54 | return { 55 | message: state.message, 56 | color: state.messageColor 57 | } 58 | } 59 | } 60 | 61 | export default new Vuex.Store({ 62 | state, 63 | mutations, 64 | actions, 65 | getters, 66 | modules: { 67 | mailboxes, 68 | draft, 69 | notification, 70 | account 71 | }, 72 | strict: debug, 73 | plugins: debug ? [createLogger()] : [] 74 | }) 75 | -------------------------------------------------------------------------------- /byemail/client/src/components/SearchAddressField.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 75 | 76 | 78 | -------------------------------------------------------------------------------- /byemail/client/src/components/MailboxList.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 56 | 57 | 59 | -------------------------------------------------------------------------------- /byemail/settings_tpl.py: -------------------------------------------------------------------------------- 1 | """ Configuration file """ 2 | import os 3 | 4 | BASEDIR = os.path.dirname(__file__) 5 | DATADIR = os.path.join(BASEDIR, "data") 6 | 7 | DOMAIN = "http://localhost:8080" # Domain to serve content 8 | 9 | ACCOUNTS = [ 10 | # Account list 11 | # example : 12 | # { 13 | # 'name': 'myname', # For login 14 | # 'password': 'test', # Password 15 | # 'address': 'my super mail ', # The from address 16 | # 'accept': ['@example.com'], # All accepted email address suffixes 17 | # } 18 | ] 19 | 20 | DEBUG = False 21 | 22 | STORAGE = {"backend": "byemail.storage.sqldb.Backend", "uri": "sqlite://db.sqlite"} 23 | 24 | HTTP_CONF = {"host": "localhost", "port": 8000} 25 | 26 | SMTP_CONF = { 27 | "host": "localhost", # None for default 28 | "port": 8025, 29 | "ssl": None, # For enabling SSL provide context 30 | } 31 | 32 | OUTGOING_MIDDLEWARES = [ 33 | # Next middleware not working yet 34 | # 'byemail.middlewares.dkim.sign' 35 | ] 36 | 37 | INCOMING_MIDDLEWARES = [ 38 | # Next middleware not working yet 39 | # 'byemail.middlewares.dkim.verify' 40 | ] 41 | 42 | VAPID_PUBLIC_KEY = "vapid_public_key.pem" 43 | VAPID_PRIVATE_KEY = "vapid_private_key.pem" 44 | VAPID_CLAIMS = {"sub": "mailto:youremail"} 45 | 46 | LOGGING = { 47 | "version": 1, 48 | "disable_existing_loggers": False, 49 | "formatters": { 50 | "verbose": { 51 | "format": "%(levelname)s %(asctime)s %(threadName)s %(name)s %(module)s %(message)s" 52 | } 53 | }, 54 | "handlers": { 55 | "console": { 56 | "level": "DEBUG", 57 | "class": "logging.StreamHandler", 58 | "formatter": "verbose", 59 | } 60 | }, 61 | "loggers": { 62 | # root loggers 63 | "": {"level": "DEBUG", "handlers": ["console"]}, 64 | "mail.log": {"level": "INFO"}, 65 | "asyncio": {"level": "WARNING"}, 66 | "aiosqlite": {"level": "INFO"}, 67 | "db_client": {"level": "INFO"}, 68 | }, 69 | } 70 | 71 | -------------------------------------------------------------------------------- /byemail/client/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker' 4 | import store from './store' 5 | 6 | if (process.env.NODE_ENV === 'production') { 7 | console.log('Try to register service worker') 8 | register(`${process.env.BASE_URL}service-worker.js`, { 9 | ready(registration) { 10 | console.log( 11 | 'App is being served from cache by a service worker.\n' + 12 | 'For more details, visit https://goo.gl/AFskqB' 13 | ) 14 | store.dispatch('setServiceWorker', true) 15 | }, 16 | registered() { 17 | console.log('Service worker has been registered.') 18 | // Listen for claiming of our ServiceWorker 19 | navigator.serviceWorker.addEventListener('controllerchange', event => { 20 | // Listen for changes in the state of our ServiceWorker 21 | navigator.serviceWorker.controller.addEventListener('statechange', () => { 22 | // If the ServiceWorker becomes "activated", let the user know they can go offline! 23 | if (this.state === 'activated') { 24 | // Show the "You may now use offline" notification 25 | console.log('You can go offline now !') 26 | } 27 | }) 28 | }) 29 | store.dispatch('setServiceWorker', true) 30 | }, 31 | cached() { 32 | console.log('Content has been cached for offline use.') 33 | }, 34 | updatefound() { 35 | console.log('New content is downloading.') 36 | }, 37 | updated() { 38 | console.log('New content is available; please refresh.') 39 | }, 40 | offline() { 41 | console.log('No internet connection found. App is running in offline mode.') 42 | }, 43 | error(error) { 44 | console.error('Error during service worker registration:', error) 45 | } 46 | }) 47 | } 48 | 49 | const exportable = { 50 | deferredPrompt: null 51 | } 52 | 53 | window.addEventListener('beforeinstallprompt', e => { 54 | console.log('before install launched') 55 | // Prevent Chrome 67 and earlier from automatically showing the prompt 56 | e.preventDefault() 57 | // Stash the event so it can be triggered later. 58 | exportable.deferredPrompt = e 59 | }) 60 | 61 | export default exportable 62 | -------------------------------------------------------------------------------- /byemail/client/src/views/Mailbox.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 74 | 75 | 76 | 78 | -------------------------------------------------------------------------------- /byemail/conf.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | __author__ = "jeremie" 4 | __copyright__ = "(C) 2016 by MHComm. All rights reserved" 5 | __email__ = "info@mhcomm.fr" 6 | 7 | import sys 8 | import importlib 9 | import traceback 10 | import os 11 | import byemail.default_settings as default_settings 12 | import logging 13 | import logging.config 14 | 15 | class ConfigError(ImportError): 16 | """ custom exception """ 17 | 18 | class Settings(): 19 | """ pypeman projects settings. Rather similar implementations to django.conf.settings """ 20 | 21 | def __init__(self): 22 | self.__dict__['_settings_mod'] = None 23 | 24 | def init_settings(self): 25 | try: 26 | settings_module = os.environ.get('BYEMAIL_SETTINGS_MODULE', 'settings') 27 | settings_mod = self.__dict__['_settings_mod'] = importlib.import_module(settings_module) 28 | except: 29 | msg = "Can't import '%s' module !" % settings_module 30 | print(msg, file=sys.stderr) 31 | print(traceback.format_exc(), file=sys.stderr) 32 | raise ConfigError(msg) 33 | 34 | # Populate entire dict with values. helpful e.g. for ipython tab completion 35 | default_vals = [ (key, val) for (key, val) in default_settings.__dict__.items() 36 | if 'A' <= key[0] <= 'Z'] 37 | self.__dict__.update(default_vals) 38 | 39 | mod_vals = [ (key, val) for (key, val) in settings_mod.__dict__.items() 40 | if 'A' <= key[0] <= 'Z'] 41 | self.__dict__.update(mod_vals) 42 | 43 | logging.config.dictConfig(self.__dict__['LOGGING']) 44 | 45 | def __getattr__(self, name): 46 | """ lazy getattr. first access imports and populates settings """ 47 | if name in self.__dict__: 48 | return self.__dict__[name] 49 | 50 | if not self.__dict__['_settings_mod']: 51 | self.init_settings() 52 | 53 | return self.__dict__[name] 54 | 55 | def __setattr__(self, name, value): 56 | """ make sure nobody tries to modify settings manually """ 57 | if name in self.__dict__: 58 | self.__dict__[name] = value 59 | else: 60 | print(name,value) 61 | raise Exception("Settings are not editable !") 62 | 63 | 64 | settings = Settings() 65 | -------------------------------------------------------------------------------- /byemail/tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | 4 | import pytest 5 | 6 | from faker import Faker 7 | 8 | from email.headerregistry import AddressHeader, HeaderRegistry 9 | 10 | fake = Faker() 11 | fake.seed(42) 12 | 13 | 14 | MAIL_TEST = b"""Content-Type: text/plain; charset="utf-8"\r 15 | Content-Transfer-Encoding: 7bit\r 16 | MIME-Version: 1.0\r 17 | From: Joe SixPack \r 18 | Subject: Is dinner ready?\r 19 | Message-Id: <152060134529.22888.2561159661807344297@emiter>\r 20 | To: Suzie Q \r 21 | To: Roger M \r 22 | Date: Fri, 09 Mar 2017 14:15:45 +0100\r 23 | \r 24 | Hi.\r 25 | \r 26 | We lost the game. Are you hungry yet?\r 27 | \r 28 | Joe.\r 29 | \r 30 | """ 31 | 32 | # @pytest.fixture(scope="session") 33 | # def loop(): 34 | # asyncio.set_event_loop(None) 35 | # return 36 | 37 | 38 | @pytest.yield_fixture(scope="session") 39 | def loop(): 40 | asyncio.set_event_loop(None) 41 | loop = asyncio.new_event_loop() 42 | yield loop 43 | loop.close() 44 | 45 | 46 | @pytest.fixture 47 | def msg_test(): 48 | from email import policy 49 | from email.parser import BytesParser 50 | 51 | header_registry = HeaderRegistry() 52 | header_registry.map_to_type("To", AddressHeader) 53 | policy = policy.EmailPolicy(header_factory=header_registry) 54 | 55 | return BytesParser(policy=policy).parsebytes(MAIL_TEST) 56 | 57 | 58 | @pytest.fixture 59 | def msg_test_with_attachments(msg_test): 60 | for content in ["att1", "att2"]: 61 | print(msg_test) 62 | msg_test.add_attachment(content, subtype="plain", filename=f"{content}.txt") 63 | return msg_test 64 | 65 | 66 | @pytest.fixture 67 | def fake_account(): 68 | from byemail.account import Account 69 | 70 | return Account(1, "test", "test", "test@example.com", "test@example.com") 71 | 72 | 73 | @pytest.fixture 74 | def other_fake_account(): 75 | from byemail.account import Account 76 | 77 | return Account(1, "test2", "test2", "test2@example.com", "test2@example.com") 78 | 79 | 80 | @pytest.fixture 81 | def fake_emails(): 82 | return fake.email 83 | 84 | 85 | @pytest.fixture(scope="session") 86 | def settings(): 87 | from byemail.conf import settings 88 | 89 | # Override settings before 90 | os.environ["BYEMAIL_SETTINGS_MODULE"] = "byemail.tests.workdir.settings" 91 | 92 | settings.init_settings() 93 | 94 | return settings 95 | -------------------------------------------------------------------------------- /byemail/tests/data_emails.py: -------------------------------------------------------------------------------- 1 | from email import policy 2 | from email.parser import BytesParser 3 | from byemail.mailutils import parse_email 4 | 5 | from byemail.account import account_manager 6 | 7 | INCOMING_1 = ( 8 | b"""Content-Type: text/plain; charset="utf-8"\r 9 | Content-Transfer-Encoding: 7bit\r 10 | MIME-Version: 1.0\r 11 | From: Suzie \r 12 | Subject: First mail\r 13 | Message-Id: <152060134529.22888.2561159661807344297@emiter>\r 14 | To: Test \r 15 | Date: Fri, 09 Mar 2019 14:15:45 +0100\r 16 | \r 17 | Do you read me?\r 18 | \r 19 | Suzie.\r 20 | \r 21 | """, 22 | "Suzie ", 23 | ) 24 | 25 | INCOMING_2 = ( 26 | b"""Content-Type: text/plain; charset="utf-8"\r 27 | Content-Transfer-Encoding: 7bit\r 28 | MIME-Version: 1.0\r 29 | From: Sam \r 30 | Subject: My mail\r 31 | Message-Id: <152060134529.22888.2561159661807344297@emiter>\r 32 | To: Test \r 33 | Date: Fri, 09 Mar 2019 14:15:45 +0100\r 34 | \r 35 | Do you read me?\r 36 | \r 37 | Sam.\r 38 | \r 39 | """, 40 | "Sam ", 41 | ) 42 | 43 | OUTGOING_1 = ( 44 | b"""Content-Type: text/plain; charset="utf-8"\r 45 | Content-Transfer-Encoding: 7bit\r 46 | MIME-Version: 1.0\r 47 | From: Test \r 48 | Subject: Second mail\r 49 | Message-Id: <152060134529.22888.2561159661807344298@emiter>\r 50 | To: Suzie \r 51 | Date: Fri, 09 Mar 2019 14:16:45 +0100\r 52 | \r 53 | Yes sure, why not.\r 54 | \r 55 | Test.\r 56 | \r 57 | """, 58 | [parse_email("Suzie ")], 59 | ) 60 | 61 | incoming_messages = [INCOMING_1, INCOMING_2] 62 | outgoing_messages = [OUTGOING_1] 63 | 64 | 65 | async def populate_with_test_data(storage): 66 | """ Populate database with test data for testing purpose """ 67 | 68 | account = account_manager.get_account_for_address("test@example.com") 69 | print(account) 70 | 71 | for msg_src, from_addr in incoming_messages: 72 | msg = BytesParser(policy=policy.default).parsebytes(msg_src) 73 | recipients = [parse_email("Test ")] 74 | await storage.store_mail(account, msg, from_addr, recipients, incoming=True) 75 | 76 | for msg_src, recipients in outgoing_messages: 77 | from_addr = "test@example.com" 78 | msg = BytesParser(policy=policy.default).parsebytes(msg_src) 79 | await storage.store_mail(account, msg, from_addr, recipients, incoming=False) 80 | -------------------------------------------------------------------------------- /byemail/scripts/generate_test_mail.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import asyncio 4 | from random import randint 5 | from faker import Faker 6 | 7 | from byemail import storage 8 | from byemail.conf import settings 9 | from byemail import mailutils 10 | from byemail.account import account_manager 11 | 12 | async def main(): 13 | settings.init_settings() 14 | await storage.storage.start() 15 | fake = Faker() 16 | 17 | for account_name, account in account_manager.accounts.items(): 18 | from_addr = mailutils.parse_email(account.address) 19 | print(f"Generate messages for {from_addr}...") 20 | 21 | for _ in range(randint(3,5)): 22 | to_addr = mailutils.parse_email(fake.safe_email()) 23 | for _ in range(randint(3,5)): 24 | # First message 25 | subject = fake.sentence(nb_words=5, variable_nb_words=True) 26 | msg = mailutils.make_msg( 27 | subject=subject, 28 | content=fake.text(max_nb_chars=200, ext_word_list=None), 29 | from_addr=from_addr, 30 | tos=[to_addr], 31 | ccs=None, 32 | attachments=None 33 | ) 34 | msg_to_store = await mailutils.extract_data_from_msg(msg) 35 | saved_msg = await storage.storage.store_msg( 36 | msg_to_store, 37 | account=account, 38 | from_addr=from_addr, 39 | to_addrs=[to_addr], 40 | incoming=False 41 | ) 42 | 43 | # response 44 | msg = mailutils.make_msg( 45 | subject="Re: " + subject, 46 | content=fake.text(max_nb_chars=200, ext_word_list=None), 47 | from_addr=to_addr, 48 | tos=[from_addr], 49 | ccs=None, 50 | attachments=None 51 | ) 52 | 53 | msg_to_store = await mailutils.extract_data_from_msg(msg) 54 | saved_msg = await storage.storage.store_msg( 55 | msg_to_store, 56 | account=account, 57 | from_addr=to_addr, 58 | to_addrs=[from_addr], 59 | incoming=True 60 | ) 61 | 62 | 63 | await storage.storage.stop() 64 | 65 | 66 | if __name__ == "__main__": 67 | loop = asyncio.get_event_loop() 68 | loop.run_until_complete(main()) -------------------------------------------------------------------------------- /byemail/client/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 70 | 71 | 86 | -------------------------------------------------------------------------------- /byemail/account.py: -------------------------------------------------------------------------------- 1 | from byemail.conf import settings 2 | 3 | class Account(): 4 | def __init__(self, id, name, password, accept, address): 5 | self.id = id 6 | self.name = name 7 | self.password = password 8 | self.accept = accept 9 | self.session = {} 10 | self.address = address 11 | 12 | def check_credentials(self, credentials): 13 | return credentials['password'] == self.password 14 | 15 | def match_address(self, address): 16 | # Simple version for now but planning regex 17 | for matcher in self.accept: 18 | if address.endswith(matcher): 19 | return True 20 | return False 21 | 22 | def get_session(self): 23 | return self.session 24 | 25 | def to_json(self): 26 | result = dict(self.__dict__) 27 | del result['password'] 28 | return result 29 | 30 | def __str__(self): 31 | return "Account(%s)" % self.name 32 | 33 | 34 | class AccountManager(): 35 | """ Account manager """ 36 | def __init__(self): 37 | self._accounts = None 38 | 39 | @property 40 | def accounts(self): 41 | if self._accounts is None: 42 | # Load all accounts from configuration 43 | self.load_accounts() 44 | return self._accounts 45 | 46 | def load_accounts(self): 47 | result = {} 48 | for settings_account in settings.ACCOUNTS: 49 | account = Account(**{ 50 | 'id': settings_account['name'], 51 | 'name': settings_account['name'], 52 | 'password': settings_account['password'], 53 | 'accept': settings_account['accept'], 54 | 'address': settings_account['address'], 55 | }) 56 | result[settings_account['name']] = account 57 | 58 | self._accounts = result 59 | 60 | return result 61 | 62 | def get(self, name): 63 | return self.accounts.get(name) 64 | 65 | def authenticate(self, credentials): 66 | account = self.get(credentials['name']) 67 | 68 | if account and account.check_credentials(credentials): 69 | return account 70 | return None 71 | 72 | def is_local_address(self, address): 73 | return self.get_account_for_address(address) is not None 74 | 75 | def get_account_for_address(self, address): 76 | for name, account in self.accounts.items(): 77 | if account.match_address(address): 78 | return account 79 | 80 | return None 81 | 82 | account_manager = AccountManager() 83 | -------------------------------------------------------------------------------- /byemail/push.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | import time 4 | 5 | from py_vapid import Vapid01 as Vapid 6 | from py_vapid import b64urlencode 7 | from cryptography.hazmat.primitives import serialization 8 | from pywebpush import webpush, WebPushException 9 | 10 | from byemail.conf import settings 11 | from byemail.storage import storage 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | async def send_web_push(subscription_information, message_body): 17 | claims = dict(settings.VAPID_CLAIMS) 18 | try: 19 | return webpush( 20 | subscription_info=subscription_information, 21 | data=message_body, 22 | vapid_private_key=settings.VAPID_PRIVATE_KEY, 23 | vapid_claims=claims, 24 | ) 25 | except WebPushException as ex: 26 | logger.exception("Exception while trying to send push notification") 27 | 28 | if ex.response and ex.response.json(): 29 | extra = ex.response.json() 30 | logger.info( 31 | "Remote service replied with a %s:%s, %s", 32 | extra.code, 33 | extra.errno, 34 | extra.message, 35 | ) 36 | return None 37 | 38 | 39 | async def notify_account(account, payload): 40 | """ 41 | Notify user on all subscription 42 | """ 43 | for subscription in await storage.get_subscriptions(account): 44 | if subscription: 45 | result = await send_web_push(subscription, payload) 46 | if result is None: 47 | storage.remove_subscription(account, subscription) 48 | 49 | 50 | async def get_application_server_key(): 51 | """ 52 | Get and prepare application server_key 53 | """ 54 | 55 | vapid = Vapid.from_file(settings.VAPID_PRIVATE_KEY) 56 | raw_pub = vapid.public_key.public_bytes( 57 | serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint 58 | ) 59 | 60 | return b64urlencode(raw_pub) 61 | 62 | 63 | def gen_application_server_keys(): 64 | """ 65 | Generate Vapid key pair 66 | """ 67 | vapid = Vapid() 68 | vapid.generate_keys() 69 | vapid.save_key(settings.VAPID_PRIVATE_KEY) 70 | vapid.save_public_key(settings.VAPID_PUBLIC_KEY) 71 | 72 | 73 | # To generate with openssl command line 74 | # See https://mushfiq.me/2017/09/25/web-push-notification-using-python/ 75 | # openssl ecparam -name prime256v1 -genkey -noout -out vapid_private.pem 76 | # openssl ec -in vapid_private.pem -pubout -out vapid_public.pem 77 | # openssl ec -in ./vapid_private.pem -outform DER|tail -c +8|head -c 32|base64|tr -d '=' |tr '/+' '_-' >> private_key.txt 78 | # openssl ec -in ./vapid_private.pem -pubout -outform DER|tail -c 65|base64|tr -d '=' |tr '/+' '_-' >> public_key.txt 79 | 80 | -------------------------------------------------------------------------------- /byemail/client/src/store/modules/draft.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import _ from 'lodash' 3 | import * as types from '../mutation-types' 4 | 5 | // initial state 6 | const state = { 7 | draftRecipients: {}, 8 | draftAttachments: {}, 9 | draft: { 10 | content: '', 11 | subject: '', 12 | recipients: [], 13 | attachments: [] 14 | } 15 | } 16 | 17 | // getters 18 | const getters = { 19 | draft: state => { 20 | const draft = { ...state.draft } 21 | draft.recipients = draft.recipients.map(recipientId => state.draftRecipients[recipientId]) 22 | draft.attachments = draft.attachments.map(attachmentId => state.draftAttachments[attachmentId]) 23 | return draft 24 | } 25 | } 26 | 27 | // mutations 28 | const mutations = { 29 | [types.RESET_DRAFT](state) { 30 | Object.assign(state, { 31 | draft: { 32 | content: '', 33 | subject: '', 34 | recipients: [], 35 | attachments: [] 36 | } 37 | }) 38 | state.draftRecipients = {} 39 | state.draftAttachments = {} 40 | }, 41 | // Recipients 42 | [types.ADD_DRAFT_RECIPIENT](state, { recipient }) { 43 | const rid = recipient.id 44 | Vue.set(state.draftRecipients, rid, recipient) 45 | state.draft.recipients.push(rid) 46 | }, 47 | [types.UPDATE_DRAFT_RECIPIENT](state, { recipient }) { 48 | const rid = recipient.id 49 | Object.assign(state.draftRecipients[rid], recipient) 50 | }, 51 | [types.REMOVE_DRAFT_RECIPIENT](state, { rid }) { 52 | state.draft.recipients.splice(state.draft.recipients.indexOf(rid), 1) 53 | delete state.draftRecipients[rid] 54 | }, 55 | // Attachments 56 | [types.ADD_DRAFT_ATTACHMENT](state, { attachment }) { 57 | const aid = attachment.id 58 | Vue.set(state.draftAttachments, aid, attachment) 59 | state.draft.attachments.push(aid) 60 | }, 61 | [types.UPDATE_DRAFT_ATTACHMENT](state, { attachment }) { 62 | const rid = attachment.id 63 | Object.assign(state.draftAttachments[rid], attachment) 64 | }, 65 | [types.REMOVE_DRAFT_ATTACHMENT](state, { aid }) { 66 | state.draft.attachments.splice(state.draft.attachments.indexOf(aid), 1) 67 | delete state.draftAttachments[aid] 68 | }, 69 | // Subject and content 70 | [types.SET_DRAFT_SUBJECT](state, { subject }) { 71 | state.draft.subject = subject 72 | }, 73 | [types.SET_DRAFT_CONTENT](state, { content }) { 74 | state.draft.content = content 75 | } 76 | } 77 | 78 | // actions 79 | const actions = { 80 | addDraftEmptyRecipient({ commit }) { 81 | const recipient = { 82 | id: _.uniqueId(), 83 | address: '', 84 | type: 'to' 85 | } 86 | commit({ type: types.ADD_DRAFT_RECIPIENT, recipient }) 87 | }, 88 | addDraftEmptyAttachment({ commit }) { 89 | const attachment = { 90 | id: _.uniqueId(), 91 | filename: '', 92 | b64: '' 93 | } 94 | commit({ type: types.ADD_DRAFT_ATTACHMENT, attachment }) 95 | } 96 | } 97 | 98 | export default { 99 | state, 100 | getters, 101 | actions, 102 | mutations 103 | } 104 | -------------------------------------------------------------------------------- /byemail/tests/test_mailutils.py: -------------------------------------------------------------------------------- 1 | """ Tests for mailutils module """ 2 | import os 3 | import pytest 4 | import asyncio 5 | from unittest import mock 6 | 7 | from byemail import mailutils 8 | 9 | BASEDIR = os.path.dirname(__file__) 10 | DATADIR = os.path.join(BASEDIR, 'data') 11 | 12 | DNS_DKIM_RESPONSE_TPL = """v=DKIM1; k=rsa; s=email; p={}""" 13 | 14 | message_content = """Hi. 15 | 16 | We lost the game. Are you hungry yet? 17 | 18 | Joe.""" 19 | 20 | 21 | def test_make_msg(loop): 22 | """ Test message composition """ 23 | from_address = mailutils.parse_email("Joe SixPack ") 24 | to_address = mailutils.parse_email("Suzie Q ") 25 | 26 | with mock.patch('byemail.mailutils.settings') as set_mock: 27 | set_mock.DKIM_CONFIG = { 28 | 'private_key': os.path.join(DATADIR, 'example_private.pem'), 29 | 'public_key': os.path.join(DATADIR, 'example_public.pem'), 30 | 'selector': 'test', 31 | 'domain': 'example.com', 32 | 'headers': ['From', 'To', 'Subject', 'Date', 'Message-Id'] 33 | } 34 | 35 | msg = mailutils.make_msg( 36 | "Is dinner ready?", 37 | message_content, 38 | from_addr=from_address, 39 | tos=to_address 40 | ) 41 | 42 | print(msg) 43 | 44 | assert msg['From'] == str(from_address) 45 | 46 | 47 | def test_extract_data(loop, msg_test): 48 | """ Test mail extraction data """ 49 | 50 | data = loop.run_until_complete(mailutils.extract_data_from_msg(msg_test)) 51 | 52 | assert data['from'].addr_spec == "joe@football.example.com" 53 | 54 | 55 | '''def _test_msg_signing(): 56 | # Use https://www.mail-tester.com to test 57 | from_address = mailutils.parse_email("Joe SixPack ") 58 | to_address = mailutils.parse_email("Suzie Q ") 59 | 60 | with mock.patch('byemail.mailutils.settings') as set_mock: 61 | set_mock.DKIM_CONFIG = { 62 | 'private_key': os.path.join(DATADIR, 'example_private.pem'), 63 | 'public_key': os.path.join(DATADIR, 'example_public.pem'), 64 | 'selector': 'test', 65 | 'domain': 'example.com', 66 | 'headers': ['From', 'To', 'Subject', 'Date', 'Message-Id'] 67 | } 68 | 69 | msg = mailutils.make_msg( 70 | "Is dinner ready?", 71 | message_content, 72 | from_addr=from_address, 73 | tos=to_address 74 | ) 75 | 76 | dkim_sign = mailutils.gen_dkim_sign(msg) 77 | 78 | #new_dkim = mailutils.gen_dkim_sign2(msg) 79 | #assert old_dkim == new_dkim, "DKIM differs" 80 | 81 | msg['DKIM-Signature'] = dkim_sign 82 | print(msg['DKIM-Signature']) 83 | 84 | 85 | with open(os.path.join(DATADIR, 'example_public.pem')) as key: 86 | publickey = key.read().replace('\n', '')\ 87 | .replace('-----BEGIN PUBLIC KEY-----', '')\ 88 | .replace('-----END PUBLIC KEY-----', '') 89 | 90 | def dnsfunc(*args): 91 | print("Called with ", *args) 92 | return DNS_DKIM_RESPONSE_TPL.format(publickey) 93 | 94 | assert dkim.verify(msg.as_string().encode(), dnsfunc=dnsfunc)''' 95 | 96 | 97 | -------------------------------------------------------------------------------- /pypi-release-cl.md: -------------------------------------------------------------------------------- 1 | # Links 2 | 3 | Follow https://packaging.python.org/tutorials/packaging-projects/ if you need to. 4 | 5 | # First time requirements: 6 | 7 | ``` 8 | pip install twine wheel setuptools 9 | ``` 10 | 11 | Create account on pypi and pypitest by going on websites. 12 | 13 | Then create a ~/.pypirc file with: 14 | 15 | ``` 16 | [pypi] 17 | repository: https://upload.pypi.org/legacy/ 18 | username: YOUR_USERNAME_HERE 19 | password: YOUR_PASSWORD_HERE 20 | 21 | [testpypi] 22 | repository: https://test.pypi.org/legacy/ 23 | username: YOUR_USERNAME_HERE 24 | password: YOUR_PASSWORD_HERE 25 | 26 | ``` 27 | 28 | # Checklist for releasing byemail on pypi in version x.x.x 29 | 30 | - [ ] Update CHANGELOG.md 31 | - [ ] Update version number (can also be minor or major) 32 | 33 | ``` 34 | vim byemail/__init__.py # or 'bumpversion patch' if installed 35 | ``` 36 | 37 | - [ ] Install the package again for local development, but with the new version number: 38 | 39 | ``` 40 | python setup.py develop 41 | ``` 42 | 43 | - [ ] Run the tests and fix eventual errors: 44 | 45 | ``` 46 | tox 47 | ``` 48 | 49 | - [ ] Commit the changes: 50 | 51 | ``` 52 | git add CHANGELOG.md 53 | git commit -m "Changelog for upcoming release x.x.x." 54 | ``` 55 | 56 | - [ ] tag the version 57 | 58 | ``` 59 | git tag x.x.x # The same as in byemail/__init__py file 60 | ``` 61 | 62 | - [ ] push on github 63 | 64 | ``` 65 | git push --tags 66 | ``` 67 | 68 | - [ ] Make a release on github 69 | 70 | - Go to the project homepage on github 71 | - Near the top, you will see Releases link. Click on it. 72 | - Click on 'Draft a new release' 73 | - Fill in all the details 74 | - Tag version should be the version number of your package release 75 | - Release Title can be anything you want. 76 | - Click Publish release at the bottom of the page 77 | - Now under Releases you can view all of your releases. 78 | - Copy the download link (tar.gz) and save it somewhere. 79 | 80 | - [ ] Generate webclient: 81 | 82 | ``` 83 | cd byemail/client 84 | npm install 85 | npm run build 86 | cd ../.. 87 | ``` 88 | 89 | - [ ] Generate packages: 90 | 91 | ``` 92 | python setup.py sdist bdist_wheel 93 | ``` 94 | 95 | - [ ] publish release on pypi and see result on https://testpypi.python.org/pypi : 96 | 97 | ``` 98 | twine upload -r testpypi dist/* 99 | ``` 100 | 101 | - [ ] Then when all is ok, release on PyPI by uploading both sdist and wheel: 102 | 103 | ``` 104 | twine upload dist/* 105 | ``` 106 | 107 | - [ ] Test that it pip installs: 108 | 109 | ``` 110 | mktmpenv 111 | pip install my_project 112 | 113 | deactivate 114 | ``` 115 | 116 | - [ ] Push: `git push` 117 | - [ ] Push tags: `git push --tags` 118 | - [ ] Check the PyPI listing page to make sure that the README, release notes, and roadmap display properly. If not, copy and paste the RestructuredText into http://rst.ninjs.org/ to find out what broke the formatting. 119 | - [ ] Edit the release on GitHub (e.g. https://github.com/audreyr/cookiecutter/releases). Paste the release notes into the release's release page, and come up with a title for the release. 120 | -------------------------------------------------------------------------------- /byemail/client/src/views/Webmail.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 91 | 92 | 93 | 122 | -------------------------------------------------------------------------------- /byemail/middlewares/dkim.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | import dkim 4 | import magic 5 | 6 | from byemail.conf import settings 7 | 8 | async def sign(msg, from_addr, to_addrs): 9 | """ Adding DKIM signature to message """ 10 | 11 | signature = gen_sign(msg) 12 | msg['DKIM-Signature'] = signature 13 | 14 | 15 | async def verify(msg, from_addr, to_addrs): 16 | """ Verify DKIM signature """ 17 | if not verify_sign(msg): 18 | raise 19 | 20 | 21 | def verify_sign(msg): 22 | return True 23 | 24 | 25 | def gen_sign(msg): 26 | 27 | # TODO Open key one for all 28 | with open(settings.DKIM_CONFIG['private_key']) as key: 29 | private_key = key.read() 30 | 31 | identity = msg['From'].addresses[0].addr_spec.encode() 32 | 33 | # Generate message signature 34 | sig = dkim.sign( 35 | msg.as_bytes(), 36 | settings.DKIM_CONFIG['selector'].encode(), 37 | settings.DKIM_CONFIG['domain'].encode(), 38 | private_key.encode(), 39 | identity=identity, 40 | include_headers=[s.encode() for s in settings.DKIM_CONFIG['headers']], 41 | canonicalize=(b'relaxed',b'simple') 42 | ) 43 | 44 | # Clean de generated signature 45 | sig = sig.decode().replace('\r\n ', '').replace('\r\n', '') 46 | 47 | # Add the DKIM-Signature 48 | signature = sig[len("DKIM-Signature: "):] 49 | 50 | return signature 51 | 52 | 53 | def sign2(msg): 54 | # Not working for now but I don't know why 55 | import hashlib 56 | import base64 57 | import rsa 58 | 59 | res = OrderedDict([ 60 | ('v', "1"), 61 | ('a', "rsa-sha256"), 62 | ('c', "simple/simple"), 63 | ('d', ""), 64 | ('i', ""), 65 | ('q', "dns/txt"), 66 | ('s', ""), 67 | ('h', []), 68 | ('bh', ""), 69 | ('b', ""), 70 | ]) 71 | 72 | # TODO Open key one for all 73 | with open(settings.DKIM_CONFIG['private_key']) as key: 74 | private_key = key.read() 75 | '''.replace('\n', '')\ 76 | .replace('-----BEGIN RSA PRIVATE KEY-----', '')\ 77 | .replace('-----END RSA PRIVATE KEY-----', '')''' 78 | 79 | res['i'] = msg['From'].addresses[0].addr_spec 80 | res['d'] = settings.DKIM_CONFIG['domain'] 81 | res['s'] = settings.DKIM_CONFIG['selector'] 82 | res['h'] = " : ".join(settings.DKIM_CONFIG['headers']) 83 | 84 | body = msg.get_body(('html', 'plain',)) 85 | #print(body.get_content()) 86 | body_content = body.get_content() 87 | 88 | #body_content = """\n""" 89 | if body_content.endswith('\n'): 90 | body_content = body_content[:-1] 91 | 92 | body_content = body_content.replace('\n', '\r\n') 93 | #print(len(body_content)) 94 | #print(repr(body_content)) 95 | computed = base64.b64encode(hashlib.sha256(body_content.encode()).digest()) 96 | #print(computed) 97 | res['bh'] = computed.decode() 98 | 99 | #assert computed == b"2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=" 100 | 101 | to_be_signed = "" 102 | for header in settings.DKIM_CONFIG['headers']: 103 | to_be_signed += "{}: {}\r\n".format(header, msg[header]) 104 | 105 | to_be_signed += "DKIM-Signature: {}\r\n".format("; ".join([ "%s=%s" % v for v in res.items()])) 106 | print(repr(msg.as_bytes())) 107 | print(to_be_signed) 108 | 109 | #key = RSA.importKey(private_key) 110 | #print(key.size()) 111 | #print(key.sign(to_be_signed.encode(), 10)) 112 | 113 | print(rsa.sign(to_be_signed.encode(), private_key, 'SHA-1')) 114 | 115 | 116 | return "; ".join([ "%s=%s" % v for v in res.items()]) -------------------------------------------------------------------------------- /byemail/helpers/reloader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | import tempfile 5 | import traceback 6 | 7 | import asyncio 8 | from asyncio import ensure_future 9 | 10 | 11 | async def check_for_newerfile(future, lockfile, interval, loop): 12 | def mtime(p): 13 | return os.stat(p).st_mtime 14 | 15 | exists = os.path.exists 16 | files = dict() 17 | 18 | for module in list(sys.modules.values()): 19 | path = getattr(module, "__file__", "") 20 | if path: 21 | if path[-4:] in (".pyo", ".pyc"): 22 | path = path[:-1] 23 | if exists(path): 24 | files[path] = mtime(path) 25 | 26 | async def reccur(): 27 | status = None 28 | await asyncio.sleep(interval) 29 | 30 | if not exists(lockfile) or mtime(lockfile) < time.time() - interval - 5: 31 | status = "error" 32 | 33 | for path, lmtime in list(files.items()): 34 | if not exists(path) or mtime(path) > lmtime: 35 | status = "reload" 36 | print("Pending reload...") 37 | break 38 | 39 | if status: 40 | future.set_result(status) 41 | else: 42 | ensure_future(reccur(), loop=loop) 43 | 44 | ensure_future(reccur(), loop=loop) 45 | 46 | 47 | def reloader_opt(to_call, reload, interval, loop=None): 48 | 49 | loop = loop if loop else asyncio.get_event_loop() 50 | 51 | if reload and not os.environ.get("PROCESS_CHILD"): 52 | import subprocess 53 | 54 | lockfile = None 55 | try: 56 | fd, lockfile = tempfile.mkstemp(prefix="process.", suffix=".lock") 57 | os.close(fd) # We only need this file to exist. We never write to it. 58 | while os.path.exists(lockfile): 59 | args = [sys.executable] + sys.argv 60 | environ = os.environ.copy() 61 | environ["PROCESS_CHILD"] = "true" 62 | environ["PROCESS_LOCKFILE"] = lockfile 63 | p = subprocess.Popen(args, env=environ) 64 | while p.poll() is None: # Busy wait... 65 | os.utime(lockfile, None) # I am alive! 66 | time.sleep(interval) 67 | if p.poll() != 3: 68 | if os.path.exists(lockfile): 69 | os.unlink(lockfile) 70 | sys.exit(p.poll()) 71 | except KeyboardInterrupt: 72 | pass 73 | finally: 74 | if os.path.exists(lockfile): 75 | os.unlink(lockfile) 76 | return 77 | 78 | try: 79 | if reload: 80 | lockfile = os.environ.get("PROCESS_LOCKFILE") 81 | 82 | future = asyncio.Future() 83 | ensure_future( 84 | check_for_newerfile(future, lockfile, interval, loop), loop=loop 85 | ) 86 | 87 | def done(future): 88 | # Stop event loop 89 | 90 | if loop.is_running() and future.result() != "error": 91 | loop.stop() 92 | 93 | future.add_done_callback(done) 94 | 95 | to_call() 96 | 97 | if future.done() and future.result() == "reload": 98 | sys.exit(3) 99 | 100 | else: 101 | to_call() 102 | 103 | except KeyboardInterrupt: 104 | pass 105 | except (SystemExit, MemoryError): 106 | raise 107 | except Exception: 108 | if not reload: 109 | raise 110 | traceback.print_exc() 111 | time.sleep(interval) 112 | sys.exit(3) 113 | -------------------------------------------------------------------------------- /byemail/archives/utils.py: -------------------------------------------------------------------------------- 1 | 2 | class EmailAddressList(object): 3 | """Email address list class 4 | 5 | The purpose of this class is to make it easier to work with email address 6 | containing strings. 7 | 8 | >>> a1 = EmailAddressList('Name 1 <1@foo.com>') 9 | >>> a2 = EmailAddressList('2@foo.com') 10 | >>> a3 = EmailAddressList('3@foo.com, 4@foo.com') 11 | >>> str(a1 + a3) 12 | 'Name 1 <1@foo.com>, 3@foo.com, 4@foo.com' 13 | 14 | Duplicate entires are automatically removed: 15 | >>> str(a1 + a2 + a2 + a1) 16 | 'Name 1 <1@foo.com>, 2@foo.com' 17 | 18 | It is also easy to remove entries from a list: 19 | >>> str(a3 - '3@foo.com') 20 | '4@foo.com' 21 | """ 22 | __slots__ = ('_addrs', '_lookup') 23 | 24 | def __init__(self, *args): 25 | self._lookup = {} 26 | self._addrs = [] 27 | 28 | unicode_args = [] 29 | #for arg in args: 30 | # unicode_args.append(arg.decode('utf-8')) 31 | for name, email in getaddresses(map(str, args)): 32 | #for name, email in getaddresses(unicode_args): 33 | if email and not email in self._lookup: 34 | self._lookup[email] = name 35 | self._addrs.append((name, email)) 36 | 37 | def __repr__(self): 38 | return '<%s %r>' % (type(self).__name__, self._addrs) 39 | 40 | def __str__(self): 41 | return ', '.join([formataddr(addr) for addr in self._addrs]) 42 | 43 | def __getitem__(self, item): 44 | """ 45 | >>> a = EmailAddressList('3@foo.com, Name 4 <4@foo.com>') 46 | >>> a[1] 47 | (u'Name 4', u'4@foo.com') 48 | """ 49 | return self._addrs[item] 50 | 51 | def __len__(self): 52 | """ 53 | >>> a = EmailAddressList('3@foo.com, Name 4 <4@foo.com>') 54 | >>> len(a) 55 | 2 56 | """ 57 | return len(self._addrs) 58 | 59 | def __iter__(self): 60 | """ 61 | >>> a = EmailAddressList('3@foo.com, Name 4 <4@foo.com>') 62 | >>> list(a) 63 | [('', u'3@foo.com'), (u'Name 4', u'4@foo.com')] 64 | """ 65 | return iter(self._addrs) 66 | 67 | def __contains__(self, other): 68 | """ 69 | >>> addrs = EmailAddressList('a@example.com, b@example.com') 70 | >>> 'a@example.com' in addrs 71 | True 72 | >>> 'c@example.com' in addrs 73 | False 74 | """ 75 | if not isinstance(other, EmailAddressList): 76 | other = EmailAddressList(other) 77 | for email in other._lookup.keys(): 78 | if not email in self._lookup: 79 | return False 80 | return True 81 | 82 | def __add__(self, other): 83 | """ 84 | >>> l1 = EmailAddressList('a@example.com, b@example.com') 85 | >>> l2 = EmailAddressList('a@example.com, c@example.com') 86 | >>> str(l1 + l2) 87 | 'a@example.com, b@example.com, c@example.com' 88 | """ 89 | return EmailAddressList(self, other) 90 | 91 | def __sub__(self, other): 92 | """ 93 | >>> l1 = EmailAddressList('a@example.com, b@example.com') 94 | >>> l2 = EmailAddressList('a@example.com') 95 | >>> str(l1 - l2) 96 | 'b@example.com' 97 | """ 98 | new = EmailAddressList() 99 | if not isinstance(other, EmailAddressList): 100 | other = EmailAddressList(other) 101 | for name, email in self._addrs: 102 | if not email in other._lookup: 103 | new._lookup[email] = name 104 | new._addrs.append((name, email)) 105 | return new -------------------------------------------------------------------------------- /byemail/archives/perserv.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # coding: utf-8 -*- 3 | # 4 | 5 | from django.core.files.base import ContentFile 6 | from byemail.smtp.util.msg import MessageRenderer 7 | 8 | 9 | from smtpd import SMTPServer 10 | 11 | import email 12 | import uuid 13 | import asyncore 14 | import asynchat 15 | import os 16 | import sys 17 | import traceback 18 | import errno 19 | import mimetypes 20 | from datetime import datetime 21 | 22 | from byemail.webmail.models import Message, Attachment, Tag 23 | 24 | messRend = MessageRenderer() 25 | 26 | class persoSMTP (SMTPServer): 27 | def process_message (self, peer, mailfrom, rcpttos, data): 28 | try: 29 | msg = messRend.process_msg(email.message_from_string(data)) 30 | print(msg['head']) 31 | #print(msg['parts']) 32 | #print(msg['attachments']) 33 | message = Message() 34 | message.subject = msg['head']['subject'] 35 | message.mail_to = msg['head']['to'] 36 | message.mail_from = msg['head']['from'] 37 | 38 | # Si pas de date on met la date de reception 39 | if(msg['head']['date']): 40 | message.send_date = msg['head']['date'] 41 | else: 42 | message.send_date = datetime.now() 43 | 44 | message.content = '\n'.join(msg['parts']) 45 | msg_filename = uuid.uuid4() 46 | message.raw_message.save(str(msg_filename),ContentFile(data), False ) 47 | 48 | message.save() 49 | 50 | for attach in msg['attachments']: 51 | content_type = attach['content_type'] 52 | # TODO Gerer les mails en piece jointe 53 | if(content_type != 'message/rfc822'): 54 | attachment = Attachment() 55 | attachment.mime_type = content_type 56 | attachment.filename = attach['filename'] 57 | attachment.content_id = attach['content_id'] 58 | attachment.description = attach['description'] 59 | attachment.message = message 60 | attachment.save() 61 | 62 | for tag in Tag.objects.filter(default = True).all(): 63 | message.tags.add(tag) 64 | except Exception, e: 65 | # TODO En cas d'erreur on enregistre le fichier... 66 | print(e) 67 | print(traceback.print_exc()) 68 | raise 69 | 70 | 71 | 72 | if __name__ == "__main__": 73 | test = persoSMTP(("192.168.1.5",8025) ,("smtp.neuf.fr",25)) 74 | try: 75 | asyncore.loop () 76 | except KeyboardInterrupt: 77 | pass 78 | 79 | 80 | 81 | """ 82 | counter = 1 83 | for part in msg.walk(): 84 | # multipart/* are just containers 85 | if part.get_content_maintype() == 'multipart': 86 | continue 87 | # Applications should really sanitize the given filename so that an 88 | # email message can't be used to overwrite important files 89 | filename = part.get_filename() 90 | print(filename) 91 | if not filename: 92 | ext = mimetypes.guess_extension(part.get_content_type()) 93 | if not ext: 94 | # Use a generic bag-of-bits extension 95 | ext = '.bin' 96 | filename = 'part-%03d%s' % (counter, ext) 97 | counter += 1 98 | fp = open(os.path.join(".", filename), 'wb') 99 | fp.write(part.get_payload(decode=True)) 100 | fp.close()""" 101 | -------------------------------------------------------------------------------- /byemail/scripts/show_in_db.py: -------------------------------------------------------------------------------- 1 | #!/bin/env python 2 | 3 | import base64 4 | import datetime 5 | import asyncio 6 | 7 | import email 8 | from email import policy 9 | from email.parser import BytesParser 10 | 11 | from tinydb import Query 12 | 13 | import arrow 14 | 15 | def handle_part(part): 16 | charset = part.get_content_charset('latin1') 17 | ctype = part.get_content_type() 18 | mainctype = part.get_content_maintype() 19 | 20 | print(ctype, charset, part.get_content_disposition()) 21 | 22 | if ctype == 'text/plain': 23 | print('Text alternative') 24 | #print(part.get_content()) 25 | return 26 | 27 | if ctype == 'text/html': 28 | print('Html alternative') 29 | #print(part.get_content()) 30 | return 31 | 32 | if mainctype == 'image': 33 | print('Image attachment') 34 | return 35 | 36 | if ctype == 'message/rfc822': 37 | print('A message attachment') 38 | return 39 | 40 | if ctype == 'text/x-vcard': 41 | print('A vcard attachment') 42 | return 43 | 44 | if mainctype == 'multipart': 45 | return 46 | 47 | print('Not handled type "%s"' % ctype) 48 | 49 | if __name__ == "__main__": 50 | 51 | loop = asyncio.get_event_loop() 52 | 53 | from byemail import mailstore 54 | 55 | Message = Query() 56 | Mailbox = Query() 57 | 58 | 59 | for mail in mailstore.search(Message.type=='mail' and Message.status=='delivered')[:1]: 60 | msg = BytesParser(policy=policy.default).parsebytes(base64.b64decode(mail['data'])) 61 | 62 | print('----------') 63 | print(mail['received']) 64 | print("Subject:", mail['subject']) 65 | print("From:", mail['from']) 66 | print("Return:", mail['return']) 67 | print("To:", mail['tos']) 68 | print("Orig-To:", mail['original-to']) 69 | print("Date:", mail['date']) 70 | if mail['in-thread']: 71 | print("Thread-Topic:", (mail['thread-topic'])) 72 | print("Thread-Index:", (mail['thread-index'])) 73 | 74 | print("Body type:", mail['main-body-type']) 75 | print("Attachements count:", len(mail['attachments'])) 76 | 77 | for att in mail['attachments']: 78 | print(" - Att:", att['type'], att['filename']) 79 | 80 | print('---\n\n\n') 81 | 82 | 83 | '''for mail in db.search(Message.type=='mail' and Message.status=='error')[:1]: 84 | msg = BytesParser(policy=policy.default).parsebytes(base64.b64decode(mail['data'])) 85 | 86 | print("Subject:", msg['Subject']) 87 | 88 | '''try: 89 | print("From:", msg['From']) 90 | print("Tos:", msg.get('To')) 91 | except email.errors.HeaderParseError: 92 | print('Missing header as error')''' 93 | 94 | print("-BODY") 95 | #body = msg.get_body() 96 | 97 | #handle_part(body) 98 | 99 | print("-ATTACHMENTS") 100 | for att in msg.iter_attachments(): 101 | handle_part(att)''' 102 | 103 | print("---- Mailbox ----") 104 | '''mailboxes = list(await mailstore.get_mailboxes()) 105 | 106 | sorted_mailboxes = sorted(mailboxes, key=lambda x: x['last_message'], reverse=True) 107 | 108 | for mailbox in sorted_mailboxes: 109 | print('Mailbox for %s - last message: %s (#%d messages)' % (mailbox['from'], mailbox['last_message'], len(mailbox['messages']))) 110 | sorted_messages = sorted(mailbox['messages'], key=lambda x : x['date'], reverse=True) 111 | for msg in sorted_messages: 112 | message = db.search( (Mailbox.type == 'mail') & (Mailbox.status == 'delivered') & (Mailbox['id'] == msg['id']) )[0] 113 | print(' ', message['date'], message['subject'] ) 114 | print('-')''' 115 | 116 | -------------------------------------------------------------------------------- /byemail/client/src/service-worker.js: -------------------------------------------------------------------------------- 1 | /* global workbox */ 2 | 3 | if (workbox) { 4 | console.log(`Yay! Workbox is loaded 🎉`) 5 | workbox.setConfig({ debug: true }) 6 | workbox.core.setLogLevel(workbox.core.LOG_LEVELS.debug) 7 | workbox.precaching.precacheAndRoute(self.__precacheManifest) 8 | 9 | workbox.routing.registerRoute( 10 | '/api/users/.+/account', 11 | workbox.strategies.networkFirst({ 12 | cacheName: 'account-cache' 13 | }) 14 | ) 15 | 16 | workbox.routing.registerRoute( 17 | new RegExp('/api/users/.+/mailboxes'), 18 | workbox.strategies.networkFirst({ 19 | cacheName: 'mailbox-cache' 20 | }) 21 | ) 22 | 23 | workbox.routing.registerRoute( 24 | new RegExp('/api/users/.+/unreads'), 25 | workbox.strategies.networkFirst({ 26 | cacheName: 'mailbox-cache' 27 | }) 28 | ) 29 | 30 | workbox.routing.registerRoute( 31 | new RegExp('/api/users/.+/mailbox/.+'), 32 | workbox.strategies.networkFirst({ 33 | cacheName: 'mailbox-cache' 34 | }) 35 | ) 36 | 37 | workbox.routing.registerRoute( 38 | new RegExp('/api/users/.+/mail/.+'), 39 | workbox.strategies.cacheFirst({ 40 | cacheName: 'mail-cache' 41 | }) 42 | ) 43 | 44 | workbox.routing.registerRoute( 45 | /^https:\/\/fonts\.googleapis\.com/, 46 | workbox.strategies.staleWhileRevalidate({ 47 | cacheName: 'google-fonts-stylesheets' 48 | }) 49 | ) 50 | 51 | workbox.routing.registerRoute( 52 | /\.(?:png|gif|jpg|jpeg|svg)$/, 53 | workbox.strategies.cacheFirst({ 54 | cacheName: 'images', 55 | plugins: [ 56 | new workbox.expiration.Plugin({ 57 | maxEntries: 60, 58 | maxAgeSeconds: 30 * 24 * 60 * 60 // 30 Days 59 | }) 60 | ] 61 | }) 62 | ) 63 | 64 | console.log(`And everything's fine 🎉`) 65 | } else { 66 | console.log(`Boo! Workbox didn't load 😬`) 67 | } 68 | 69 | // Register event listener for the 'push' event. 70 | self.addEventListener('push', function(event) { 71 | console.log('Get a push') 72 | // Retrieve the textual payload from event.data (a PushMessageData object). 73 | // Other formats are supported (ArrayBuffer, Blob, JSON), check out the documentation 74 | // on https://developer.mozilla.org/en-US/docs/Web/API/PushMessageData. 75 | const payload = event.data ? event.data.text() : 'You have a new message' 76 | 77 | event.waitUntil( 78 | // Retrieve a list of the clients of this service worker. 79 | self.clients.matchAll().then(function(clientList) { 80 | // Check if there's at least one focused client. 81 | var focused = clientList.some(function(client) { 82 | return client.focused 83 | }) 84 | 85 | if (focused) { 86 | // Focused 87 | } else if (clientList.length > 0) { 88 | // Click to focus 89 | } else { 90 | // Reopen 91 | } 92 | 93 | return self.registration.showNotification('Byemail', { 94 | body: payload, 95 | tag: 'newmail' 96 | }) 97 | }) 98 | ) 99 | }) 100 | 101 | // Register event listener for the 'notificationclick' event. 102 | self.addEventListener('notificationclick', function(event) { 103 | console.log('notif click') 104 | event.waitUntil( 105 | // Retrieve a list of the clients of this service worker. 106 | self.clients.matchAll().then(function(clientList) { 107 | console.log('clients', clientList) 108 | // If there is at least one client, focus it. 109 | if (clientList.length > 0) { 110 | return clientList[0].focus() 111 | } 112 | 113 | // Otherwise, open a new page. 114 | return self.clients.openWindow('/index.html') 115 | }) 116 | ) 117 | }) 118 | 119 | self.addEventListener('activate', event => { 120 | // Calling claim() to force a "controllerchange" event on navigator.serviceWorker 121 | console.log('activate called') 122 | event.waitUntil(self.clients.claim()) 123 | }) 124 | -------------------------------------------------------------------------------- /byemail/client/src/views/Mailboxes.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 84 | 85 | 151 | -------------------------------------------------------------------------------- /byemail/client/src/views/Settings.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 113 | 114 | 129 | -------------------------------------------------------------------------------- /byemail/tests/test_httpserver.py: -------------------------------------------------------------------------------- 1 | """ Tests for mailutils module """ 2 | import os 3 | import sys 4 | import json 5 | import pytest 6 | import asyncio 7 | from unittest import mock 8 | from sanic.websocket import WebSocketProtocol 9 | 10 | from . import commons 11 | 12 | from byemail.storage import storage 13 | 14 | BASEDIR = os.path.dirname(__file__) 15 | WORKDIR = os.path.join(BASEDIR, "workdir") 16 | 17 | 18 | @pytest.fixture 19 | def httpapp(loop, settings): 20 | from byemail.httpserver import get_app 21 | 22 | storage.loop = loop 23 | loop.run_until_complete(storage.start()) 24 | 25 | return get_app() 26 | 27 | 28 | @pytest.fixture 29 | def test_cli(loop, sanic_client, settings): 30 | from byemail.httpserver import get_app 31 | 32 | storage.loop = loop 33 | loop.run_until_complete(storage.start()) 34 | 35 | return loop.run_until_complete(sanic_client(get_app(), protocol=WebSocketProtocol)) 36 | 37 | 38 | def get_auth_cookie(loop, test_client): 39 | data = {"name": "test", "password": "test_pass"} 40 | 41 | # Authenticate 42 | response = loop.run_until_complete( 43 | test_client.post("/login", data=json.dumps(data)) 44 | ) 45 | 46 | return {"session_key": response.cookies["session_key"].value} 47 | 48 | 49 | def test_basic(loop, test_cli): 50 | response = loop.run_until_complete(test_cli.get("/check")) 51 | assert response.status == 200 52 | 53 | 54 | def test_auth(loop, test_cli): 55 | data = {"name": "test", "password": "bad_password"} 56 | 57 | response = loop.run_until_complete(test_cli.post("/login", data=json.dumps(data))) 58 | 59 | assert response.status == 403 60 | 61 | data = {"name": "test", "password": "test_pass"} 62 | 63 | response = loop.run_until_complete(test_cli.post("/login", data=json.dumps(data))) 64 | 65 | assert response.status == 200 66 | 67 | 68 | @pytest.mark.skipif("TRAVIS" in os.environ, reason="Not working on travis") 69 | def test_send_mail(loop, test_cli): 70 | 71 | data = { 72 | "recipients": [ 73 | {"address": "alt.n2-75zy2uk@yopmail.com", "type": "to"}, #  test_byemail 74 | {"address": "alt.n2-75zy2uk@yopmail.com", "type": "cc"}, 75 | {"address": "bad@inbox.mailtrap.io", "type": "cc"}, 76 | ], 77 | "subject": "Test mail", 78 | "content": "Content\nMultiline", 79 | "attachments": [{"filename": "testfile.txt", "b64": "VGVzdAo="}], 80 | } 81 | 82 | cookies = get_auth_cookie(loop, test_cli) 83 | response = loop.run_until_complete( 84 | test_cli.post( 85 | f"/api/users/test/sendmail/", data=json.dumps(data), cookies=cookies 86 | ) 87 | ) 88 | 89 | assert response.status == 200 90 | 91 | result = loop.run_until_complete(response.json()) 92 | 93 | assert result["delivery_status"] == { 94 | "alt.n2-75zy2uk@yopmail.com": { 95 | "status": "DELIVERED", 96 | "smtp_info": ["250", "Delivered"], 97 | }, 98 | "bad@inbox.mailtrap.io": { 99 | "reason": "SMTP_ERROR", 100 | "smtp_info": "(554, b'5.5.1 Error: no inbox for this email')", 101 | "status": "ERROR", 102 | }, 103 | } 104 | 105 | 106 | def test_contacts_search(loop, test_cli, fake_account): 107 | """ Test contact search """ 108 | 109 | cookies = get_auth_cookie(loop, test_cli) 110 | 111 | response = loop.run_until_complete( 112 | test_cli.get("/api/users/test/contacts/search?text=toto", cookies=cookies) 113 | ) 114 | 115 | assert response.status == 200 116 | 117 | result = loop.run_until_complete(response.json()) 118 | 119 | assert result == [] 120 | 121 | loop.run_until_complete( 122 | storage.get_or_create_mailbox(fake_account, "titi@localhost", "Titi") 123 | ) 124 | 125 | response = loop.run_until_complete( 126 | test_cli.get("/api/users/test/contacts/search?text=titi", cookies=cookies) 127 | ) 128 | 129 | assert response.status == 200 130 | 131 | result = loop.run_until_complete(response.json()) 132 | 133 | assert result == ["titi@localhost"] 134 | 135 | -------------------------------------------------------------------------------- /byemail/client/src/store/modules/push-notifications.js: -------------------------------------------------------------------------------- 1 | import * as types from '../mutation-types' 2 | 3 | function urlBase64ToUint8Array(base64String) { 4 | const padding = '='.repeat((4 - (base64String.length % 4)) % 4) 5 | const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') 6 | const rawData = window.atob(base64) 7 | const outputArray = new Uint8Array(rawData.length) 8 | 9 | for (let i = 0; i < rawData.length; ++i) { 10 | outputArray[i] = rawData.charCodeAt(i) 11 | } 12 | return outputArray 13 | } 14 | 15 | function pushRegister() { 16 | console.log('Start notification subscription process') 17 | navigator.serviceWorker.ready.then(async function(registration) { 18 | registration.pushManager 19 | .getSubscription() 20 | .then(async function(subscription) { 21 | if (subscription) { 22 | console.log('Push already registered') 23 | return subscription 24 | } else { 25 | console.log('New push registration') 26 | 27 | // Get public key from server 28 | const response = await fetch('/api/publickey') 29 | const vapidPublicKey = await response.text() 30 | const convertedVapidKey = urlBase64ToUint8Array(vapidPublicKey) 31 | 32 | return registration.pushManager.subscribe({ 33 | userVisibleOnly: true, 34 | applicationServerKey: convertedVapidKey 35 | }) 36 | } 37 | }) 38 | .then( 39 | subscription => { 40 | console.log('Send/update push subscription to server') 41 | fetch(`/api/subscription/`, { 42 | method: 'post', 43 | headers: { 44 | 'Content-type': 'application/json' 45 | }, 46 | body: JSON.stringify({ 47 | subscription: subscription 48 | }) 49 | }) 50 | }, 51 | failed => { 52 | console.log('Fail to push subscription to server: ', failed) 53 | } 54 | ) 55 | }) 56 | } 57 | 58 | // initial state 59 | const state = { 60 | notification: false 61 | } 62 | 63 | // getters 64 | const getters = { 65 | notificationEnabled: state => { 66 | return state.notification 67 | } 68 | } 69 | 70 | // mutations 71 | const mutations = { 72 | [types.SET_NOTIFICATION](state, status) { 73 | state.notification = status 74 | } 75 | } 76 | 77 | // actions 78 | const actions = { 79 | checkNotificationStatus({ commit }) { 80 | if ('serviceWorker' in navigator) { 81 | return navigator.serviceWorker.ready 82 | .then(registration => { 83 | return registration.pushManager.getSubscription() 84 | }) 85 | .then(subscription => { 86 | if (subscription) { 87 | return commit(types.SET_NOTIFICATION, true) 88 | } else { 89 | return commit(types.SET_NOTIFICATION, false) 90 | } 91 | }) 92 | } else { 93 | console.log('Notification subscription unaviable') 94 | return commit(types.SET_NOTIFICATION, false) 95 | } 96 | }, 97 | 98 | subscribeNotification({ commit }) { 99 | return Notification.requestPermission().then(result => { 100 | console.log('Notification result: ', result) 101 | if (result === 'granted') { 102 | pushRegister() 103 | commit(types.SET_NOTIFICATION, true) 104 | } 105 | }) 106 | }, 107 | 108 | unsubscribeNotification({ commit }) { 109 | return navigator.serviceWorker.ready 110 | .then(registration => { 111 | return registration.pushManager.getSubscription() 112 | }) 113 | .then(subscription => { 114 | if (subscription) { 115 | return subscription.unsubscribe().then(() => { 116 | commit(types.SET_NOTIFICATION, false) 117 | return fetch('/api/unsubscription/', { 118 | method: 'post', 119 | headers: { 120 | 'Content-type': 'application/json' 121 | }, 122 | body: JSON.stringify({ 123 | subscription: subscription 124 | }) 125 | }) 126 | }) 127 | } else { 128 | commit(types.SET_NOTIFICATION, false) 129 | } 130 | }) 131 | } 132 | } 133 | 134 | export default { 135 | state, 136 | getters, 137 | actions, 138 | mutations 139 | } 140 | -------------------------------------------------------------------------------- /byemail/storage/core.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | from byemail import mailutils 4 | 5 | 6 | class Backend: 7 | def __init__(self, loop=None): 8 | # Get asyncio loop 9 | self.loop = loop or asyncio.get_event_loop() 10 | 11 | async def start(self, test=False): 12 | """ Allow backend specific initialisation """ 13 | pass 14 | 15 | async def stop(self): 16 | """ Allow backend specific tear down code """ 17 | pass 18 | 19 | async def store_bad_msg(self, bad_msg): 20 | """ To handle msg that failed to parse """ 21 | raise NotImplementedError() 22 | 23 | async def store_content(self, uid, content): 24 | """ Store raw message content """ 25 | raise NotImplementedError() 26 | 27 | async def get_content_msg(self, uid): 28 | """ Return EmailMessage instance for this message uid """ 29 | raise NotImplementedError() 30 | 31 | async def get_or_create_mailbox(self, account, address, name): 32 | """ Create a mailbox """ 33 | raise NotImplementedError() 34 | 35 | async def get_mailboxes(self, account, offset=0, limit=None): 36 | """ Return all mailboxes in db with unread message count and total """ 37 | raise NotImplementedError() 38 | 39 | async def get_unreads(self, account, offset=0, limit=None): 40 | """Return unread message list for this account""" 41 | raise NotImplementedError() 42 | 43 | async def get_mailbox(self, account, mailbox_id, offset=0, limit=None): 44 | """ Return the selected mailbox """ 45 | raise NotImplementedError() 46 | 47 | async def store_mail(self, account, msg, from_addr, recipients, incoming=True): 48 | """ Store a mail """ 49 | 50 | msg_data = await mailutils.extract_data_from_msg(msg) 51 | 52 | await mailutils.apply_middlewares(msg_data, from_addr, recipients, incoming) 53 | 54 | msg_data["original-sender"] = from_addr 55 | msg_data["original-recipients"] = recipients 56 | 57 | stored_msg = await self.store_msg( 58 | msg_data, 59 | account=account, 60 | from_addr=from_addr, 61 | to_addrs=recipients, 62 | incoming=incoming, 63 | ) 64 | 65 | await self.store_content(stored_msg["uid"], msg.as_bytes()) 66 | 67 | stored_msg["status"] = "received" if incoming else "sending" 68 | 69 | # TODO use db transaction here 70 | await self.update_mail(account, stored_msg) 71 | 72 | return stored_msg 73 | 74 | async def store_msg( 75 | self, 76 | msg, 77 | account, 78 | from_addr, 79 | to_addrs, 80 | incoming=True, 81 | extra_data=None, 82 | extra_mailbox_message_data=None, 83 | ): 84 | """ Store message in database """ 85 | raise NotImplementedError() 86 | 87 | async def get_mail(self, account, mail_uid): 88 | """ Get message by uid """ 89 | raise NotImplementedError() 90 | 91 | async def get_mail_attachment(self, account, mail_uid, att_index): 92 | """ Return a specific mail attachment """ 93 | raise NotImplementedError() 94 | 95 | async def update_mail(self, account, mail): 96 | """ Update any mail """ 97 | raise NotImplementedError() 98 | 99 | async def mark_mail_read(self, account, mail_uid): 100 | raise NotImplementedError() 101 | 102 | async def mark_mail_unread(self, account, mail): 103 | raise NotImplementedError() 104 | 105 | async def save_user_session(self, session_key, session): 106 | """ Save modified user session """ 107 | raise NotImplementedError() 108 | 109 | async def get_user_session(self, session_key): 110 | """ Load user session """ 111 | raise NotImplementedError() 112 | 113 | async def add_subscription(self, account, subscription): 114 | """ Add user subscription """ 115 | raise NotImplementedError() 116 | 117 | async def remove_subscription(self, account, subscription): 118 | """ Remove user subscription """ 119 | raise NotImplementedError() 120 | 121 | async def get_subscriptions(self, account): 122 | """ Get all user subscriptions """ 123 | raise NotImplementedError() 124 | 125 | async def contacts_search(self, account, text): 126 | """ Search a contact from mailboxes """ 127 | raise NotImplementedError() 128 | -------------------------------------------------------------------------------- /byemail/client/src/components/MessageComposer.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 146 | 147 | 149 | -------------------------------------------------------------------------------- /byemail/client/cypress/integration/main.js: -------------------------------------------------------------------------------- 1 | /* global describe it expect cy before after beforeEach afterEach */ 2 | 3 | const BASE_URL = 'http://192.168.0.20:8080/' 4 | 5 | describe('Basic Tests', function() { 6 | it('See homepage', function() { 7 | cy.visit(BASE_URL) 8 | cy.contains('Login') 9 | }) 10 | 11 | it('Login to mailbox', function() { 12 | cy.visit(BASE_URL) 13 | cy.contains('Login') 14 | 15 | // Login part 16 | cy.get('input[aria-label=Login]') 17 | .type('test') 18 | .should('have.value', 'test') 19 | cy.get('input[aria-label=Password]').type('test') 20 | cy.contains('Submit').click() 21 | 22 | cy.url().should('include', '/webmail/test/mailboxes') 23 | }) 24 | }) 25 | 26 | describe('Logged tests', function() { 27 | beforeEach(function() { 28 | cy.visit(BASE_URL) 29 | cy.contains('Login') 30 | 31 | // Login part 32 | cy.get('input[aria-label=Login]') 33 | .type('test') 34 | .should('have.value', 'test') 35 | cy.get('input[aria-label=Password]').type('test') 36 | cy.contains('Submit').click() 37 | }) 38 | 39 | it('Can see mails', function() { 40 | cy.get('.mailboxlist') 41 | .contains('Suzie') 42 | .click() 43 | 44 | cy.contains('Mailbox: Suzie') 45 | 46 | cy.get('.maillist') 47 | .contains('First mail') 48 | .click() 49 | 50 | cy.contains('read me') 51 | 52 | cy.get('.maillist') 53 | .contains('Second mail') 54 | .click() 55 | 56 | cy.contains('Yes sure') 57 | 58 | cy.get('.mailboxlist') 59 | .contains('Sam') 60 | .click() 61 | 62 | cy.contains('Mailbox: Sam') 63 | }) 64 | 65 | it('Can mark read mail', function() { 66 | cy.get('.mailboxlist') 67 | .contains('Sam') 68 | .click() 69 | 70 | cy.contains('Mailbox: Sam') 71 | 72 | cy.get('.maillist') 73 | .contains('My mail') 74 | .click() 75 | 76 | cy.get('.mail') 77 | .contains('visibility') 78 | .click() 79 | 80 | cy.get('.mail') 81 | .not() 82 | .contains('visibility') 83 | }) 84 | }) 85 | 86 | describe('Can write emails', function() { 87 | beforeEach(function() { 88 | cy.visit(BASE_URL) 89 | cy.contains('Login') 90 | 91 | // Login part 92 | cy.get('input[aria-label=Login]') 93 | .type('test') 94 | .should('have.value', 'test') 95 | cy.get('input[aria-label=Password]').type('test') 96 | cy.contains('Submit').click() 97 | }) 98 | 99 | it('Can respond mail', function() { 100 | cy.get('.mailboxlist') 101 | .contains('Sam') 102 | .click() 103 | 104 | cy.contains('Mailbox: Sam') 105 | 106 | cy.get('.maillist .incoming') 107 | .contains('My mail') 108 | .click() 109 | 110 | cy.get('.mail') 111 | .contains('reply') 112 | .click() 113 | 114 | cy.get('.mail-compose textarea').type('Answer') 115 | 116 | cy.get('.mail-compose') 117 | .contains('send') 118 | .click() 119 | 120 | cy.get('.maillist', { timeout: 10000 }) 121 | .contains('Re: My mail') 122 | .parent() 123 | .contains('a few seconds ago') 124 | }) 125 | 126 | it('Can write new email', function() { 127 | cy.get('.mailboxlist') 128 | .contains('email') 129 | .click() 130 | 131 | cy.url().should('include', '/mailedit') 132 | 133 | // Add to 134 | cy.get('.recipients:first .layout > div:nth-child(2)') 135 | .find('input') 136 | .type('sam') 137 | cy.get('.v-autocomplete__content') 138 | .contains('sam@example.com') 139 | .click() 140 | 141 | // Add cc 142 | cy.contains('Add recipient').click() 143 | cy.get('.recipients:nth-child(2) .layout > div:first') 144 | .contains('arrow_drop_down') 145 | .click() 146 | 147 | cy.get('.v-menu__content') 148 | .contains('Cc') 149 | .click() 150 | 151 | cy.get('.recipients:nth-child(2) .layout > div:nth-child(2)') 152 | .find('input') 153 | .type('suz') 154 | 155 | cy.get('.v-autocomplete__content') 156 | .contains('suzie@example.com') 157 | .click() 158 | 159 | // Write subject and content 160 | cy.contains('Subject') 161 | .parent() 162 | .find('input') 163 | .type('My subject') 164 | cy.contains('Content') 165 | .parent() 166 | .find('textarea') 167 | .type('My content') 168 | 169 | // Send 170 | cy.contains('Send').click() 171 | 172 | cy.url().should('include', '/mailboxes') 173 | 174 | cy.get('.mailboxlist') 175 | .contains('Suzie') 176 | .click() 177 | 178 | cy.get('.maillist > div:first').contains('My subject') 179 | 180 | cy.get('.mailboxlist') 181 | .contains('Sam') 182 | .click() 183 | 184 | cy.get('.maillist > div:first').contains('My subject') 185 | }) 186 | }) 187 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Welcome to byemail 2 | 3 | [![Build](https://travis-ci.org/jrmi/byemail.svg?branch=master)](https://travis-ci.org/jrmi/byemail) 4 | [![Build](https://badge.fury.io/py/byemail.svg)](https://badge.fury.io/py/byemail) 5 | [![GitHub license](https://img.shields.io/github/license/jrmi/byemail.svg)](https://github.com/jrmi/byemail/blob/master/LICENSE) 6 | [![Build](https://img.shields.io/pypi/wheel/byemail.svg)](https://github.com/jrmi/byemail) 7 | [![Build](https://img.shields.io/badge/python-3.7-blue.svg)](https://github.com/jrmi/byemail) 8 | [![Build](https://img.shields.io/pypi/status/byemail.svg)](https://github.com/jrmi/byemail) 9 | 10 | # What is it ? 11 | 12 | Byemail is a complete stack for a personal mail system including SMTP receiver, sender, webmail, 13 | mailing list and more. Install only one tool to rules them all. 14 | 15 | E-mails are still a popular means of communication today. We use email to communicate in company, to send messages to our friends, to keep in touch with family members, etc. 16 | Despite the explosion of social networks and new means of communication, the mail system still has a bright future ahead of it. 17 | 18 | If we believe in the decentralization of the web, it is difficult to believe that most emails are managed by a handful of private companies that lead the market. Why ? 19 | 20 | Because, despite their long existence and the dependence of a large part of the population, 21 | mail servers are still difficult to install, configure and maintain mainly because they implement features that are not necessary for most end users and their architecture is no longer adapted to today's uses. 22 | 23 | To create a complete mail system, we have to install a SMTP server to receive/send emails then an IMAP or POP3 server to gather the mails and finally a client to read them. Don't forget to configure your DNS and pray that your IP will not be banned for misuse. 24 | 25 | To fulfill all ours needs we also need to add modules like: 26 | 27 | - a user account manager 28 | - a spam filter, 29 | - a webmail, 30 | - a mailing list manager, 31 | - ... 32 | 33 | All this results in a complex system to set up and which requires great skills to administrate, not to mention the many security vulnerabilities that can be created without even noticing it. All the people who tried the adventure were afraid to create an open relay SMTP server so used by spammers or to be marked as spam from major mail systems. 34 | 35 | Byemail is **fully compatible** with the current email system but the objective is to create a simpler and more secure stack first, 36 | then add functionality that is currently inaccessible due to the complexity of the architecture and the aging of the technology to meet the expectations of users with new needs. 37 | 38 | With Byemail, you install only one tool for your email communication. 39 | Some common use cases on the roadmap: 40 | 41 | - Access your webmail from everywhere, 42 | - Receiving and sending mail for a family or small business, 43 | - Create a mailing list on the fly, 44 | - Share huge attachment without thinking of it and without flooding the net, 45 | - Send "burn after reading" email, 46 | - Cancel mail sent by mistake, 47 | - Create temporary address on the fly for spam protection, 48 | - Really secure mail even if the recipient doesn't have configured any gpg key, 49 | - Auto update your DNS configuration, 50 | - Spam protect you with captcha, 51 | - Easy quitting by easily export all your data to import them in another instance, 52 | - ActivityPub compatibility, 53 | - Scheduled mails 54 | - Scheduled mail acknowledgment (for privacy concern) 55 | - and more ... 56 | 57 | Some technical advantages: 58 | 59 | - Easy backup: you only have one directory to save, 60 | - Easy configuration, everything in one python file, 61 | - Middleware to filter/modify/... incoming and outgoing mails, 62 | - Secure by design, open relay can't be done at all, 63 | - Use DKIM, SPF, DMARC to allow better receivability, 64 | - ... 65 | 66 | # Installation 67 | 68 | You can install byemail in a virtualenv by doing a: 69 | 70 | ```sh 71 | $ pip install byemail 72 | ``` 73 | 74 | Create and move to any directory, then execute: 75 | 76 | ```sh 77 | $ byemail init 78 | ``` 79 | 80 | A new set of file are created in the current directory. 81 | Now **Customize the settings.py**. You should at least add one account using the 82 | given example in the file. 83 | 84 | Then execute: 85 | 86 | ```sh 87 | $ byemail start 88 | ``` 89 | 90 | You can now log to http://: to check new mails. Mail can be send to http://:8025. 91 | 92 | To configure your DNS correctly, execute: 93 | 94 | ```sh 95 | $ byemail dnsconfig 96 | ``` 97 | 98 | And copy (and adapt if necessary) the command result to your domain dns config. 99 | 100 | As root you can make a tunnel to the 25 port without root permission for the server by doing: 101 | 102 | ```sh 103 | $ socat tcp-listen:25,reuseaddr,fork tcp::8025 > nohupsocket.out & 104 | ``` 105 | 106 | DISCLAIMER: This is an early functional version. 107 | Don't hope using it in production for now but don't be afraid to try it and 108 | help us. 109 | -------------------------------------------------------------------------------- /byemail/client/src/views/Mail.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 147 | 148 | 149 | 169 | -------------------------------------------------------------------------------- /byemail/mailutils.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import base64 4 | import datetime 5 | import logging 6 | import importlib 7 | from collections import OrderedDict 8 | 9 | from email import policy 10 | from email.utils import localtime, make_msgid 11 | from email.message import EmailMessage 12 | from email.headerregistry import AddressHeader, HeaderRegistry 13 | 14 | import magic 15 | 16 | from byemail.conf import settings 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | def parse_email(email_str): 21 | """ Helper to parse an email address from client """ 22 | # TODO make a more reliable function 23 | res = {} 24 | AddressHeader.parse(email_str, res) 25 | 26 | return res['groups'][0].addresses[0] 27 | 28 | def make_msg(subject, content, from_addr, tos=None, ccs=None, attachments=None): 29 | 30 | from_addr = parse_email(from_addr) 31 | 32 | # To have a correct address header 33 | header_registry = HeaderRegistry() 34 | header_registry.map_to_type('To', AddressHeader) 35 | msg = EmailMessage(policy.EmailPolicy(header_factory=header_registry)) 36 | 37 | msg.set_content(content) 38 | msg['From'] = from_addr 39 | msg['Subject'] = subject 40 | msg['Message-ID'] = make_msgid() 41 | 42 | if tos: 43 | msg['To'] = tos 44 | if ccs: 45 | msg['Cc'] = ccs 46 | 47 | if attachments: 48 | for att in attachments: 49 | filename = att['filename'] 50 | content = base64.b64decode(att['b64']) 51 | # Guess type here 52 | maintype, subtype = magic.from_buffer(content, mime=True).split('/') 53 | msg.add_attachment( 54 | content, 55 | maintype=maintype, 56 | subtype=subtype, 57 | filename=filename 58 | ) 59 | 60 | msg['Date'] = localtime() 61 | 62 | # TODO add html alternative 63 | # See https://docs.python.org/3.6/library/email.examples.html 64 | 65 | """msg.add_alternative("\ 66 | 67 | 68 | 69 |

{body}

70 | 71 | 72 | "".format(content, subtype='html')""" 73 | 74 | return msg 75 | 76 | async def extract_data_from_msg(msg): 77 | """ Extract data from a message to save it """ 78 | logger.debug(msg) 79 | 80 | body = msg.get_body(('html', 'plain',)) 81 | 82 | # Fix missing date 83 | if msg['Date'] is None: 84 | msg['Date'] = datetime.datetime.now() 85 | 86 | msg_out = { 87 | 'status': 'new', 88 | 'subject': msg['Subject'], 89 | # TODO rename this field 90 | 'received': datetime.datetime.now().isoformat(), 91 | 'from': msg['From'].addresses[0], 92 | 'recipients': list(msg['To'].addresses), 93 | 'original-to': msg['X-Original-To'], 94 | 'delivered-to': msg['Delivered-To'], 95 | 'dkim-signature': msg['DKIM-Signature'], 96 | 'message-id': msg['Message-ID'], 97 | 'domain-signature': msg['DomainKey-Signature'], 98 | 'date': msg['Date'].datetime, 99 | 'return': msg['Return-Path'] or msg['Reply-To'], 100 | 'in-thread': False, 101 | 'body-type': body.get_content_type() if body else "text/plain", 102 | 'body-charset': body.get_content_charset() if body else "utf-8", 103 | 'body': body.get_content() if body else "Body not found", 104 | 'attachments': [] 105 | } 106 | 107 | if msg['Cc']: 108 | msg_out['carboncopy'] = list(msg['Cc'].addresses) 109 | 110 | for ind, att in enumerate(msg.iter_attachments()): 111 | msg_out['attachments'].append({ 112 | 'index': ind, 113 | 'type': att.get_content_type(), 114 | 'filename': att.get_filename(), 115 | 'content-id': att['content-id'][1:-1] if att['content-id'] else None 116 | }) 117 | 118 | if msg['Thread-Topic']: 119 | msg_out['in-thread'] = True 120 | msg_out['thread-topic'] = msg['Thread-Topic'] 121 | msg_out['thread-index'] = msg['Thread-index'] 122 | 123 | return msg_out 124 | 125 | async def apply_middlewares(msg, from_addr, recipients, incoming=True): 126 | """ Apply all configured incoming and outgoing middlewares """ 127 | 128 | if incoming: 129 | middlewares = settings.INCOMING_MIDDLEWARES 130 | else: 131 | middlewares = settings.OUTGOING_MIDDLEWARES 132 | 133 | for middleware in middlewares: 134 | try: 135 | module, _, func = middleware.rpartition(".") 136 | mod = importlib.import_module(module) 137 | except ModuleNotFoundError: 138 | logger.error("Module %s can't be loaded !", middleware) 139 | raise 140 | else: 141 | await getattr(mod, func)( 142 | msg, 143 | from_addr=from_addr, 144 | recipients=recipients, 145 | incoming=incoming 146 | ) 147 | 148 | 149 | RE_CID = re.compile(r'cid:([^">]+)[">]?') 150 | 151 | async def convert_cid_link(msg): 152 | """ Convert content-id tag to url """ 153 | 154 | if msg['body-type'] == 'text/html': 155 | cids = {att['content-id']: att for att in msg['attachments']} 156 | 157 | body = msg['body'] 158 | 159 | result = RE_CID.findall(body) 160 | for cid in result: 161 | if cid in cids: 162 | url = cids[cid]['url'] 163 | body = body.replace(f'cid:{cid}', f'{settings.DOMAIN}{url}') 164 | 165 | msg['body'] = body 166 | 167 | return msg 168 | 169 | 170 | -------------------------------------------------------------------------------- /byemail/client/src/store/modules/mailboxes.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Moment from 'moment' 3 | import _ from 'lodash' 4 | import * as types from '../mutation-types' 5 | 6 | var tagsToReplace = { 7 | '&': '&', 8 | '<': '<', 9 | '>': '>' 10 | } 11 | 12 | function sanitizeText(str) { 13 | return str.replace(/[&<>]/g, tag => { 14 | return tagsToReplace[tag] || tag 15 | }) 16 | } 17 | 18 | // initial state 19 | const state = { 20 | all: null, 21 | current: null, 22 | mail: null, 23 | unreads: null 24 | } 25 | 26 | // getters 27 | const getters = { 28 | allMailboxes: state => { 29 | if (state.all !== null) { 30 | return _.orderBy(state.all, 'last_message', 'desc') 31 | } else { 32 | return null 33 | } 34 | }, 35 | allUnreads: state => state.unreads, 36 | currentMailbox: state => state.current, 37 | currentMail: state => state.mail 38 | } 39 | 40 | // mutations 41 | const mutations = { 42 | [types.SET_MAILBOXES](state, { mailboxes }) { 43 | state.all = mailboxes 44 | }, 45 | [types.SET_CURRENT_MAILBOX](state, { mailbox }) { 46 | state.current = mailbox 47 | }, 48 | [types.SET_CURRENT_MAIL](state, { mail }) { 49 | state.mail = mail 50 | }, 51 | [types.SET_UNREADS](state, { unreads }) { 52 | state.unreads = unreads 53 | }, 54 | [types.SET_ALL_MAIL_READ](state) { 55 | const newUnread = JSON.parse(JSON.stringify(state.unreads)) 56 | delete newUnread[state.current.uid] 57 | state.unreads = newUnread 58 | }, 59 | [types.SET_CURRENT_MAIL_READ](state) { 60 | const newUnread = JSON.parse(JSON.stringify(state.unreads)) 61 | delete newUnread[state.current.uid][state.mail.uid] 62 | state.unreads = newUnread 63 | }, 64 | [types.RESET_MAILBOXES](state) { 65 | Object.assign(state, { 66 | all: null, 67 | current: null, 68 | mail: null 69 | }) 70 | } 71 | } 72 | 73 | // actions 74 | const actions = { 75 | getAllMailboxes({ commit }, { userId }) { 76 | return Vue.http 77 | .get(`/api/users/${userId}/mailboxes`, { responseType: 'json' }) 78 | .then(response => { 79 | let mailboxes = response.body 80 | for (let mb of mailboxes) { 81 | mb.last_message = Moment(mb.last_message) 82 | } 83 | return commit({ type: types.SET_MAILBOXES, mailboxes }) 84 | }) 85 | }, 86 | getAllUnreads({ commit }, { userId }) { 87 | return Vue.http.get(`/api/users/${userId}/unreads`, { responseType: 'json' }).then(response => { 88 | const unreads = {} 89 | 90 | for (let unr of response.body) { 91 | unreads[unr.mailbox] = unreads[unr.mailbox] || {} 92 | unreads[unr.mailbox][unr.message] = true 93 | } 94 | return commit({ type: types.SET_UNREADS, unreads }) 95 | }) 96 | }, 97 | getMailbox({ commit }, { userId, mailboxId }) { 98 | return Vue.http 99 | .get(`/api/users/${userId}/mailbox/${mailboxId}`, { 100 | responseType: 'json' 101 | }) 102 | .then(function(response) { 103 | let mailbox = response.body 104 | for (let msg of mailbox.messages) { 105 | msg.date = Moment(msg.date) 106 | } 107 | mailbox.messages = _.orderBy(mailbox.messages, 'date', 'desc') 108 | return commit({ type: types.SET_CURRENT_MAILBOX, mailbox }) 109 | }) 110 | }, 111 | getMail({ commit }, { userId, mailId }) { 112 | return Vue.http 113 | .get(`/api/users/${userId}/mail/${mailId}`, { responseType: 'json' }) 114 | .then(function(response) { 115 | const mail = response.body 116 | mail.date = Moment(mail.date) 117 | if (mail['body-type'] === 'text/html') { 118 | // mail.iframeSrc = 'data:text/html;charset=' + mail['body-charset'] + ',' + escape(mail.body) 119 | } else { 120 | mail.body = sanitizeText(mail.body).replace(/\n/g, '
') 121 | } 122 | return commit({ type: types.SET_CURRENT_MAIL, mail }) 123 | }) 124 | }, 125 | markAllMailRead({ commit }, { userId }) { 126 | let promises = [] 127 | for (let msg of state.current.messages) { 128 | if (msg.unread) { 129 | promises.push(Vue.http.post(`/api/users/${userId}/mail/${msg.uid}/mark_read`)) 130 | } 131 | } 132 | return Promise.all(promises).then(() => { 133 | return commit({ type: types.SET_ALL_MAIL_READ }) 134 | }) 135 | }, 136 | markMailRead({ commit }, { mailId, userId }) { 137 | return Vue.http.post(`/api/users/${userId}/mail/${mailId}/mark_read`).then(response => { 138 | return commit({ type: types.SET_CURRENT_MAIL_READ }) 139 | }) 140 | }, 141 | sendMail({ dispatch, commit }, { recipients, subject, content, attachments, replyTo, userId }) { 142 | return Vue.http 143 | .post(`/api/users/${userId}/sendmail/`, { 144 | recipients, 145 | subject, 146 | content, 147 | attachments, 148 | replyTo 149 | }) 150 | .then(function(response) { 151 | let promise = dispatch('getAllMailboxes', { userId }) 152 | if (state.current) { 153 | promise.then(() => { 154 | return dispatch('getMailbox', { 155 | mailboxId: state.current.uid, 156 | userId 157 | }) 158 | }) 159 | } 160 | return promise 161 | }) 162 | }, 163 | resendMail({ dispatch, commit }, { to, userId }) { 164 | return Vue.http 165 | .post(`/api/users/${userId}/mail/${state.mail.uid}/resend`, { to }) 166 | .then(function(response) { 167 | let promise = dispatch('getAllMailboxes', { userId }) 168 | if (state.current) { 169 | promise.then(() => { 170 | return dispatch('getMailbox', { 171 | mailboxId: state.current.uid, 172 | userId 173 | }) 174 | }) 175 | } 176 | return promise 177 | }) 178 | } 179 | } 180 | 181 | export default { 182 | state, 183 | getters, 184 | actions, 185 | mutations 186 | } 187 | -------------------------------------------------------------------------------- /byemail/archives/msg.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # Copyright (C) 2006-2007 Edgewall Software 4 | # All rights reserved. 5 | # 6 | # This software is licensed as described in the file COPYING, which 7 | # you should have received as part of this distribution. The terms 8 | # are also available at http://posterity.edgewall.org/wiki/License. 9 | # 10 | # This software consists of voluntary contributions made by many 11 | # individuals. For the exact contribution history, see the revision 12 | # history and logs, available at http://posterity.edgewall.org/log/. 13 | 14 | import re 15 | import email 16 | 17 | from mailutils import decode_header, decode_text_part, \ 18 | decode_text_plain_part, parse_rfc2822_date 19 | 20 | missing = object() 21 | href_re = re.compile('(.*)(http://[^> ]+)(.*)') 22 | 23 | quote_re = re.compile('((?:[>] ?)*)(.*)') 24 | 25 | class MessageRenderer(object): 26 | """Parses a rfc822 message into a python structure. 27 | 28 | A message is represented by dictionary of the following format: 29 | { 30 | 'head': dict(subject=, from=, to=, cc=, bcc, date), 31 | 'parts': [part1, part2, ...] 32 | 'attachments': [attachment1, attachment2, ...] 33 | } 34 | `parts` is a list of messages mime parts that can be inlined into 35 | the message, usually text/plain and text/html. 36 | 37 | `attachments` is a list of dictionaries of the following format: 38 | dict(content_type=, content_id=, filename=, description=). 39 | If the attachments is a rfc822 message `content_type` is set to 40 | 'message/rfc822' and the rest of the dictionary is identical to the 41 | message representation described above. 42 | 43 | """ 44 | def render(self, msg): 45 | part = email.message_from_string(msg.payload) 46 | return self.process_msg(part) 47 | 48 | def process_msg(self, part): 49 | head = {} 50 | parts = [] 51 | attachments = [] 52 | head['subject'] = decode_header(part['Subject']) 53 | head['from'] = decode_header(part['From']) 54 | head['to'] = decode_header(part['To']) 55 | head['cc'] = decode_header(part['CC']) 56 | head['bcc'] = decode_header(part['BCC']) 57 | head['reply_to'] = decode_header(part['Reply-To']) 58 | head['organization'] = decode_header(part['Organization']) 59 | try: 60 | head['date'] = parse_rfc2822_date(part['Date']) 61 | except: 62 | head['date'] = None 63 | self.process_part(part, parts, attachments, ignore_msg=True) 64 | return dict(head=head, parts=parts, attachments=attachments, 65 | content_type='message/rfc822') 66 | 67 | def process_part(self, part, parts, attachments, ignore_msg=False): 68 | content_type = part.get_content_type() 69 | attachment = part.get_param('attachment', missing, 70 | 'Content-Disposition') 71 | if content_type == 'message/rfc822' : 72 | if (not ignore_msg): 73 | for msg in part.get_payload(): 74 | attachments.append(self.process_msg(msg)) 75 | return True 76 | 77 | if part.is_multipart(): 78 | # In case of a multipart/alternative multipart all parts 79 | # are syntactically identical but semantically different. 80 | # This means that we should only inline one version of the 81 | # information. 82 | if content_type == 'multipart/alternative': 83 | for p in reversed(part.get_payload()): 84 | if self.process_part(p, parts, attachments): 85 | return True 86 | else: 87 | for p in part.get_payload(): 88 | self.process_part(p, parts, attachments) 89 | return True 90 | success = False 91 | if content_type == 'text/html' and attachment is missing: 92 | #data = self.render_text_html(part) 93 | data = decode_text_part(part) 94 | if data: 95 | parts.append(data) 96 | success = True 97 | elif content_type.startswith('text/') and attachment is missing: 98 | #parts.append(self.render_text_plain(part)) 99 | 100 | parts.append(decode_text_plain_part(part)) 101 | success = True 102 | # Is it an attachment? 103 | filename = part.get_filename(None) 104 | if filename: 105 | content_id = part.get('Content-Id', '').strip('<>') 106 | description = decode_header(part.get('content-description','')) 107 | attachments.append(dict(filename=filename, 108 | content_id=content_id, 109 | content_type=content_type, 110 | description=description)) 111 | success = True 112 | return success 113 | 114 | 115 | class EmailFilter(object): 116 | """Rename unwanted elements from html emails 117 | 118 | Most html emails contains elements that have to be renamed to something 119 | safer before they can be inlined into the Posterity message view. 120 | 121 | This filter currently rename html, head and body elements to div. 122 | 123 | >>> from genshi.input import HTML 124 | >>> stream = HTML('foo') 125 | >>> stream = stream | EmailFilter() 126 | >>> stream.render() 127 | '

foo
' 128 | """ 129 | def __call__(self, stream): 130 | for kind, data, pos in stream: 131 | if kind is START: 132 | tag, attrib = data 133 | if tag.lower() in ['html', 'head', 'body']: 134 | data = QName('div'), attrib 135 | if kind is END: 136 | if data.lower() in ['html', 'head', 'body']: 137 | data = QName('div') 138 | yield kind, data, pos 139 | 140 | 141 | -------------------------------------------------------------------------------- /byemail/commands.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # ############################################################################ 4 | # This is the code for THE byemail command line program 5 | # and all it's related commands 6 | # ############################################################################ 7 | 8 | import os 9 | import sys 10 | import shutil 11 | import asyncio 12 | import subprocess 13 | from os import path 14 | from urllib import request 15 | 16 | 17 | import uvloop 18 | import begin 19 | from aiosmtpd.smtp import SMTP 20 | 21 | 22 | import byemail 23 | from byemail.conf import settings 24 | from byemail import mailutils 25 | from byemail import storage 26 | from byemail.helpers import reloader 27 | from byemail import push 28 | 29 | # TODO: remove below if statement asap. This is a workaround for a bug in begins 30 | # TODO: which provokes an exception when calling command without parameters. 31 | # TODO: more info at https://github.com/aliles/begins/issues/48 32 | if len(sys.argv) == 1: 33 | sys.argv.append("-h") 34 | 35 | # Keep this import 36 | sys.path.insert(0, os.getcwd()) 37 | 38 | 39 | def main(loop, test=False): 40 | """ Main function """ 41 | 42 | settings.init_settings() 43 | 44 | from byemail import smtp, httpserver 45 | from byemail.storage import storage 46 | 47 | # Start storage 48 | loop.run_until_complete(storage.start(test)) 49 | 50 | # Start stmp server 51 | def smtp_factory(): 52 | return SMTP(smtp.MsgHandler(loop=loop), enable_SMTPUTF8=True) 53 | 54 | # Can't use the aioSMTP controller here 55 | smtp_server = loop.create_server(smtp_factory, **settings.SMTP_CONF) 56 | asyncio.ensure_future(smtp_server, loop=loop) 57 | 58 | # Start http server 59 | app = httpserver.get_app() 60 | server = app.create_server(**settings.HTTP_CONF, return_asyncio_server=True) 61 | asyncio.ensure_future(server, loop=loop) 62 | 63 | try: 64 | loop.run_forever() 65 | except KeyboardInterrupt: 66 | print("Stopping") 67 | 68 | loop.run_until_complete(storage.stop()) 69 | 70 | 71 | @begin.subcommand 72 | def start( 73 | reload: "Make server autoreload (Dev only)" = False, 74 | test: "Switch in test mode with a test database" = False, 75 | ): 76 | """ Start byemail SMTP and HTTP servers """ 77 | 78 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 79 | loop = asyncio.get_event_loop() 80 | 81 | reloader.reloader_opt(lambda: main(loop, test), reload, 2, loop) 82 | 83 | 84 | @begin.subcommand 85 | def generatedkimkeys(): 86 | """ Generate DKIM specific keys """ 87 | # TODO check exist to avoid accidental rewrite 88 | private_command = [ 89 | "openssl", 90 | "genrsa", 91 | "-out", 92 | settings.DKIM_CONFIG["private_key"], 93 | "1024", 94 | ] 95 | public_command = [ 96 | "openssl", 97 | "rsa", 98 | "-in", 99 | settings.DKIM_CONFIG["private_key"], 100 | "-out", 101 | settings.DKIM_CONFIG["public_key"], 102 | "-pubout", 103 | ] 104 | 105 | print("Generating private key {}".format(settings.DKIM_CONFIG["private_key"])) 106 | process = subprocess.run(private_command) 107 | if process.returncode != 0: 108 | print("Error while generating private key. Process abort.") 109 | sys.exit(-1) 110 | 111 | print("Generating public key {}".format(settings.DKIM_CONFIG["private_key"])) 112 | process = subprocess.run(public_command) 113 | if process.returncode != 0: 114 | print("Error while generating public key. Process abort.") 115 | sys.exit(-1) 116 | 117 | 118 | MX_TPL = """{address_domain}. MX 10 {externalip}""" 119 | SPF_TPL = """{address_domain}. TXT \"v=spf1 a mx ip4:{externalip} -all\"""" 120 | DKIM_TPL = """{dkim_selector}._domainkey.{dkim_domain}. TXT \"v=DKIM1; k=rsa; s=email; p={publickey}\"""" 121 | DMARC_TPL = """_dmarc.{address_domain}. TXT \"v=DMARC1; p=none\"""" 122 | 123 | 124 | @begin.subcommand 125 | def dnsconfig(): 126 | """ Show configuration to apply to your DNS """ 127 | context = {} 128 | 129 | print("# This is the guessed configuration for your domain.") 130 | print("# Remember you should execute this command on the server where") 131 | print("# you start byemail.") 132 | 133 | for account in settings.ACCOUNTS: 134 | result = [] 135 | context["externalip"] = ( 136 | request.urlopen("https://api.ipify.org/").read().decode("utf8") 137 | ) 138 | context["address_domain"] = mailutils.parse_email(account["address"]).domain 139 | context["dkim_selector"] = settings.DKIM_CONFIG["selector"] 140 | context["dkim_domain"] = settings.DKIM_CONFIG["domain"] 141 | 142 | """with open(settings.DKIM_CONFIG['public_key']) as key: 143 | context['publickey'] = key.read().replace('\n', '')\ 144 | .replace('-----BEGIN PUBLIC KEY-----', '')\ 145 | .replace('-----END PUBLIC KEY-----', '')""" 146 | 147 | result.append(MX_TPL.format(**context)) 148 | result.append(SPF_TPL.format(**context)) 149 | # result.append(DKIM_TPL.format(**context)) 150 | result.append(DMARC_TPL.format(**context)) 151 | 152 | print( 153 | f"\n# For account {account['name']}, domain {context['address_domain']}\n" 154 | ) 155 | print("\n".join(result)) 156 | 157 | print() 158 | 159 | 160 | @begin.subcommand 161 | def init(): 162 | """ Initialize a new directory for byemail can be replayed """ 163 | print("Initialize directory...") 164 | 165 | # Copy settings template 166 | setting_tpl = path.join(path.realpath(path.dirname(__file__)), "settings_tpl.py") 167 | if not os.path.exists(path.join(".", "settings.py")): 168 | shutil.copy(setting_tpl, path.join(".", "settings.py")) 169 | 170 | vapid_exists = os.path.exists(path.join(".", settings.VAPID_PRIVATE_KEY)) 171 | 172 | if vapid_exists: 173 | answer = input("VAPID key already exists. Do you want to overwrite them? (y/N)") 174 | answer = answer.lower()[0] if answer else "n" 175 | 176 | if not vapid_exists or answer == "y": 177 | push.gen_application_server_keys() 178 | 179 | print("Done.") 180 | 181 | 182 | @begin.start 183 | def run(version=False): 184 | """ byemail """ 185 | if version: 186 | print(byemail.__version__) 187 | sys.exit(0) 188 | 189 | -------------------------------------------------------------------------------- /byemail/tests/test_smtp.py: -------------------------------------------------------------------------------- 1 | """ Tests for mailutils module """ 2 | import os 3 | import pytest 4 | import asyncio 5 | import smtplib 6 | from unittest import mock 7 | 8 | from byemail import smtp, mailutils 9 | from byemail.account import account_manager 10 | from byemail.storage import storage 11 | 12 | 13 | from . import commons 14 | from . import conftest 15 | 16 | BASEDIR = os.path.dirname(__file__) 17 | DATADIR = os.path.join(BASEDIR, "data") 18 | 19 | count = 0 20 | 21 | 22 | async def fake_send_middleware(msg, from_addr, to_addrs): 23 | global count 24 | print("FAKE middleware called") 25 | count += 1 26 | 27 | 28 | def test_send(loop, msg_test): 29 | 30 | msend = smtp.MsgSender(loop) 31 | 32 | from_addr = "test@example.com" 33 | to_addrs = ["spam@pouetpouetpouet.com", "byemail@yopmail.com", "other@yopmail.com"] 34 | 35 | with mock.patch("byemail.smtp.MsgSender._relay_to") as smtp_send: 36 | 37 | f = asyncio.Future(loop=loop) 38 | f.set_result( 39 | { 40 | "byemail@yopmail.com": ("250", "Delivered"), 41 | "other@yopmail.com": ("534", "Fail for any reason"), 42 | } 43 | ) 44 | smtp_send.return_value = f 45 | 46 | result = loop.run_until_complete(msend.send(msg_test, from_addr, to_addrs)) 47 | 48 | print(result) 49 | 50 | assert result == { 51 | "spam@pouetpouetpouet.com": {"status": "ERROR", "reason": "MX_NOT_FOUND"}, 52 | "byemail@yopmail.com": { 53 | "status": "DELIVERED", 54 | "smtp_info": ("250", "Delivered"), 55 | }, 56 | "other@yopmail.com": { 57 | "status": "ERROR", 58 | "reason": "SMTP_ERROR", 59 | "smtp_info": ("534", "Fail for any reason"), 60 | }, 61 | } 62 | 63 | with mock.patch("byemail.smtp.MsgSender._relay_to") as smtp_send: 64 | 65 | f = asyncio.Future(loop=loop) 66 | exc = smtplib.SMTPRecipientsRefused( 67 | { 68 | "byemail@yopmail.com": ("452", "Requested action not taken"), 69 | "other@yopmail.com": ("345", "Another reason"), 70 | } 71 | ) 72 | f.set_exception(exc) 73 | smtp_send.return_value = f 74 | 75 | result = loop.run_until_complete(msend.send(msg_test, from_addr, to_addrs)) 76 | 77 | print(result) 78 | assert result == { 79 | "spam@pouetpouetpouet.com": {"status": "ERROR", "reason": "MX_NOT_FOUND"}, 80 | "byemail@yopmail.com": { 81 | "status": "ERROR", 82 | "reason": "SMTP_ERROR", 83 | "smtp_info": ("452", "Requested action not taken"), 84 | }, 85 | "other@yopmail.com": { 86 | "status": "ERROR", 87 | "reason": "SMTP_ERROR", 88 | "smtp_info": ("345", "Another reason"), 89 | }, 90 | } 91 | 92 | 93 | def test_send_process(loop, msg_test): 94 | 95 | msend = smtp.MsgSender(loop) 96 | 97 | from_addr = "test@example.com" 98 | to_addrs = ["spam@pouetpouetpouet.com", "byemail@yopmail.com", "other@yopmail.com"] 99 | 100 | with mock.patch("byemail.smtp.MsgSender._relay_to") as smtp_send: 101 | 102 | f = asyncio.Future(loop=loop) 103 | f.set_result( 104 | { 105 | "byemail@yopmail.com": ("250", "Delivered"), 106 | "other@yopmail.com": ("534", "Fail for any reason"), 107 | } 108 | ) 109 | smtp_send.return_value = f 110 | 111 | result = loop.run_until_complete(msend.send(msg_test, from_addr, to_addrs)) 112 | 113 | print(result) 114 | 115 | assert result == { 116 | "spam@pouetpouetpouet.com": {"status": "ERROR", "reason": "MX_NOT_FOUND"}, 117 | "byemail@yopmail.com": { 118 | "status": "DELIVERED", 119 | "smtp_info": ("250", "Delivered"), 120 | }, 121 | "other@yopmail.com": { 122 | "status": "ERROR", 123 | "reason": "SMTP_ERROR", 124 | "smtp_info": ("534", "Fail for any reason"), 125 | }, 126 | } 127 | 128 | with mock.patch("byemail.smtp.MsgSender._relay_to") as smtp_send: 129 | 130 | f = asyncio.Future(loop=loop) 131 | exc = smtplib.SMTPRecipientsRefused( 132 | { 133 | "byemail@yopmail.com": ("452", "Requested action not taken"), 134 | "other@yopmail.com": ("345", "Another reason"), 135 | } 136 | ) 137 | f.set_exception(exc) 138 | smtp_send.return_value = f 139 | 140 | result = loop.run_until_complete(msend.send(msg_test, from_addr, to_addrs)) 141 | 142 | print(result) 143 | assert result == { 144 | "spam@pouetpouetpouet.com": {"status": "ERROR", "reason": "MX_NOT_FOUND"}, 145 | "byemail@yopmail.com": { 146 | "status": "ERROR", 147 | "reason": "SMTP_ERROR", 148 | "smtp_info": ("452", "Requested action not taken"), 149 | }, 150 | "other@yopmail.com": { 151 | "status": "ERROR", 152 | "reason": "SMTP_ERROR", 153 | "smtp_info": ("345", "Another reason"), 154 | }, 155 | } 156 | 157 | 158 | def test_receive(loop): 159 | 160 | msg_handler = smtp.MsgHandler(loop=loop) 161 | 162 | with mock.patch("byemail.smtp.storage") as storage_mock, mock.patch( 163 | "byemail.account.settings" 164 | ) as set_mock: 165 | 166 | set_mock.ACCOUNTS = [ 167 | { 168 | "name": "suzie", 169 | "password": "crepe", 170 | "accept": ["@shopping.example.net"], 171 | "address": "Suzie Q ", 172 | } 173 | ] 174 | 175 | # Reload accounts 176 | account_manager.load_accounts() 177 | 178 | fut = asyncio.Future(loop=loop) 179 | fut.set_result({}) 180 | 181 | storage_mock.store_mail.return_value = fut 182 | storage_mock.store_bad_msg.return_value = fut 183 | 184 | envelope = commons.objectview( 185 | dict( 186 | content=conftest.MAIL_TEST, 187 | mail_from="joe@football.example.com", 188 | rcpt_tos=["suzie@shopping.example.net"], 189 | ) 190 | ) 191 | 192 | session = commons.objectview(dict(peer="peer", host_name="hostname")) 193 | 194 | loop.run_until_complete(msg_handler.handle_DATA("127.0.0.1", session, envelope)) 195 | 196 | storage_mock.store_mail.assert_called_once() 197 | 198 | 199 | @pytest.mark.skipif("TRAVIS" in os.environ, reason="Not working on travis") 200 | def test_send_mail(loop, fake_account, msg_test, settings): 201 | 202 | loop.run_until_complete(storage.start()) 203 | 204 | from_addr = mailutils.parse_email("test@example.com") 205 | to_addrs = [mailutils.parse_email("bad@inbox.mailtrap.io")] 206 | 207 | # First with bad recipient 208 | result = loop.run_until_complete( 209 | smtp.send_mail(fake_account, msg_test, from_addr, to_addrs) 210 | ) 211 | 212 | print(result) 213 | 214 | assert result["delivery_status"] == { 215 | "bad@inbox.mailtrap.io": { 216 | "reason": "SMTP_ERROR", 217 | "smtp_info": "(554, b'5.5.1 Error: no inbox for this email')", 218 | "status": "ERROR", 219 | } 220 | } 221 | 222 | # Then good recipient 223 | to_addrs = [mailutils.parse_email("alt.n2-75zy2uk@yopmail.com")] 224 | 225 | result = loop.run_until_complete( 226 | smtp.send_mail(fake_account, msg_test, from_addr, to_addrs) 227 | ) 228 | 229 | print(result) 230 | 231 | assert result["delivery_status"] == { 232 | "alt.n2-75zy2uk@yopmail.com": { 233 | "status": "DELIVERED", 234 | "smtp_info": ("250", "Delivered"), 235 | } 236 | } 237 | 238 | 239 | def test_resend_mail(loop, fake_account, msg_test, settings): 240 | 241 | loop.run_until_complete(storage.start()) 242 | 243 | from_addr = mailutils.parse_email("test@example.com") 244 | 245 | to_addrs = [ 246 | mailutils.parse_email("byemail@yopmail.com"), 247 | mailutils.parse_email("other@yopmail.com"), 248 | ] 249 | 250 | with mock.patch("byemail.smtp.MsgSender._relay_to") as smtp_send: 251 | f = asyncio.Future(loop=loop) 252 | f.set_result( 253 | { 254 | "byemail@yopmail.com": ("250", "Delivered"), 255 | "other@yopmail.com": ("534", "Fail for any reason"), 256 | } 257 | ) 258 | smtp_send.return_value = f 259 | 260 | mail_to_resend = loop.run_until_complete( 261 | smtp.send_mail(fake_account, msg_test, from_addr, to_addrs) 262 | ) 263 | 264 | assert mail_to_resend["delivery_status"] == { 265 | "byemail@yopmail.com": { 266 | "status": "DELIVERED", 267 | "smtp_info": ("250", "Delivered"), 268 | }, 269 | "other@yopmail.com": { 270 | "status": "ERROR", 271 | "reason": "SMTP_ERROR", 272 | "smtp_info": ("534", "Fail for any reason"), 273 | }, 274 | } 275 | 276 | with mock.patch("byemail.smtp.MsgSender._relay_to") as smtp_send: 277 | f = asyncio.Future(loop=loop) 278 | f.set_result({}) 279 | smtp_send.return_value = f 280 | 281 | mail_to_resend = loop.run_until_complete( 282 | smtp.resend_mail(fake_account, mail_to_resend, [to_addrs[1]]) 283 | ) 284 | 285 | # Does the delivery status update ? 286 | assert mail_to_resend["delivery_status"] == { 287 | "byemail@yopmail.com": { 288 | "status": "DELIVERED", 289 | "smtp_info": ("250", "Delivered"), 290 | }, 291 | "other@yopmail.com": {"status": "DELIVERED", "smtp_info": ("250", "Delivered")}, 292 | } 293 | 294 | -------------------------------------------------------------------------------- /byemail/tests/test_storage.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import pytest 4 | import shutil 5 | import datetime 6 | import base64 7 | 8 | from copy import deepcopy 9 | 10 | from byemail.storage.sqldb import Backend as SQLBackend 11 | from byemail.storage.tinydb import Backend as TinyBackend 12 | from byemail import storage 13 | from byemail.mailutils import parse_email, extract_data_from_msg 14 | 15 | all_backends = [ 16 | { 17 | "class": SQLBackend, 18 | "conf": { 19 | "config": { 20 | "default": { 21 | "engine": "tortoise.backends.sqlite", 22 | "credentials": {"file_path": ":memory:"}, 23 | } 24 | } 25 | }, 26 | }, 27 | {"class": TinyBackend, "conf": {"datadir": "/tmp/tinydbtest"}}, 28 | ] 29 | 30 | shutil.rmtree("/tmp/tinydbtest", ignore_errors=True) 31 | 32 | 33 | @pytest.fixture(params=all_backends, ids=["sql", "tinydb"]) 34 | def backend(request, loop): 35 | 36 | b = request.param 37 | backend = b["class"](**b["conf"], loop=loop) 38 | 39 | loop.run_until_complete(backend.start()) 40 | yield backend 41 | loop.run_until_complete(backend.stop()) 42 | 43 | 44 | def test_mailbox(loop, fake_account, other_fake_account, backend): 45 | 46 | result = loop.run_until_complete(backend.get_mailboxes(account=fake_account)) 47 | 48 | previous_len = len(result) 49 | 50 | loop.run_until_complete( 51 | backend.get_or_create_mailbox( 52 | account=fake_account, address="address", name="name" 53 | ) 54 | ) 55 | result = loop.run_until_complete(backend.get_mailboxes(account=fake_account)) 56 | 57 | print(result) 58 | 59 | assert len(result) == (previous_len + 1), "Bad result count after mailbox creation" 60 | 61 | assert result[0]["account"] == fake_account.name 62 | assert result[0]["address"] == "address" 63 | assert result[0]["name"] == "name" 64 | 65 | uid = result[0]["uid"] 66 | 67 | result = loop.run_until_complete(backend.get_mailbox(fake_account, uid)) 68 | 69 | assert result["account"] == fake_account.name 70 | assert result["name"] == "name" 71 | 72 | # Check security 73 | with pytest.raises(storage.DoesntExists): 74 | result = loop.run_until_complete(backend.get_mailbox(other_fake_account, uid)) 75 | 76 | 77 | def test_incoming_mail( 78 | loop, fake_account, other_fake_account, backend, msg_test, fake_emails, settings 79 | ): 80 | run = loop.run_until_complete 81 | 82 | from_addr = msg_test["From"].addresses[0] 83 | to_addrs = [parse_email(fake_emails()), parse_email(fake_emails())] 84 | 85 | print(f"From {from_addr}") 86 | print(f"To {to_addrs}") 87 | 88 | run( 89 | backend.store_mail( 90 | msg=msg_test, 91 | account=fake_account, 92 | from_addr=from_addr, 93 | recipients=to_addrs, 94 | incoming=True, 95 | ) 96 | ) 97 | 98 | mailboxes = run(backend.get_mailboxes(fake_account)) 99 | 100 | delivered_count = 0 101 | 102 | for mailbox in mailboxes: 103 | mailbox = run(backend.get_mailbox(fake_account, mailbox["uid"])) 104 | 105 | if mailbox["address"] in from_addr.addr_spec: 106 | # Count message to be sure at least one have been recorded 107 | delivered_count += 1 108 | 109 | # Check mail 110 | mail = run(backend.get_mail(fake_account, mailbox["messages"][0]["uid"])) 111 | assert mail["from"].addr_spec == from_addr.addr_spec 112 | assert mail["status"] == "received" 113 | 114 | # Check mailbox data 115 | assert len(mailbox["messages"]) == 1 116 | assert mailbox["total"] == 1 117 | assert mailbox["last_message"] == mail["date"] 118 | 119 | unreads = run(backend.get_unreads(fake_account)) 120 | 121 | assert len(unreads) == 1 122 | 123 | assert unreads == [ 124 | {"mailbox": mailbox["uid"], "message": mailbox["messages"][0]["uid"]} 125 | ] 126 | 127 | # Check security 128 | with pytest.raises(storage.DoesntExists): 129 | mail = run( 130 | backend.get_mail(other_fake_account, mailbox["messages"][0]["uid"]) 131 | ) 132 | 133 | assert delivered_count == 1 134 | 135 | 136 | def test_get_attachment( 137 | loop, 138 | fake_account, 139 | other_fake_account, 140 | backend, 141 | msg_test_with_attachments, 142 | fake_emails, 143 | settings, 144 | ): 145 | run = loop.run_until_complete 146 | 147 | del msg_test_with_attachments["From"] 148 | 149 | msg_test_with_attachments["From"] = "test@bar.foo" 150 | 151 | from_addr = msg_test_with_attachments["From"].addresses[0] 152 | to_addrs = [parse_email(fake_emails())] 153 | 154 | out_mail = run( 155 | backend.store_mail( 156 | msg=msg_test_with_attachments, 157 | account=fake_account, 158 | from_addr=from_addr, 159 | recipients=to_addrs, 160 | incoming=True, 161 | ) 162 | ) 163 | 164 | print("out mail", out_mail) 165 | 166 | mailboxes = run(backend.get_mailboxes(fake_account)) 167 | 168 | for mailbox in mailboxes: 169 | mailbox = run(backend.get_mailbox(fake_account, mailbox["uid"])) 170 | 171 | if mailbox["address"] in from_addr.addr_spec: 172 | uid = mailbox["messages"][0]["uid"] 173 | 174 | attachment = run(backend.get_mail_attachment(fake_account, uid, 0)) 175 | assert attachment == ( 176 | { 177 | "content-id": None, 178 | "filename": "att1.txt", 179 | "index": 0, 180 | "type": "text/plain", 181 | }, 182 | "att1\n", 183 | ) 184 | 185 | attachment = run(backend.get_mail_attachment(fake_account, uid, 1)) 186 | assert attachment == ( 187 | { 188 | "content-id": None, 189 | "filename": "att2.txt", 190 | "index": 1, 191 | "type": "text/plain", 192 | }, 193 | "att2\n", 194 | ) 195 | 196 | # Check security 197 | with pytest.raises(storage.DoesntExists): 198 | attachment = run( 199 | backend.get_mail_attachment(other_fake_account, uid, 1) 200 | ) 201 | 202 | 203 | def test_read_mail( 204 | loop, fake_account, other_fake_account, backend, msg_test, fake_emails, settings 205 | ): 206 | run = loop.run_until_complete 207 | 208 | from_addr = msg_test["From"].addresses[0] 209 | to_addrs = [parse_email(fake_emails()), parse_email(fake_emails())] 210 | 211 | print(f"From {from_addr}") 212 | print(f"To {to_addrs}") 213 | 214 | run( 215 | backend.store_mail( 216 | msg=msg_test, 217 | account=fake_account, 218 | from_addr=from_addr, 219 | recipients=to_addrs, 220 | incoming=True, 221 | ) 222 | ) 223 | 224 | mailboxes = run(backend.get_mailboxes(fake_account)) 225 | 226 | for mailbox in mailboxes: 227 | mailbox = run(backend.get_mailbox(fake_account, mailbox["uid"])) 228 | 229 | if mailbox["address"] in from_addr.addr_spec: 230 | 231 | msg_uid = mailbox["messages"][0]["uid"] 232 | 233 | unreads = run(backend.get_unreads(fake_account)) 234 | 235 | assert len(unreads) >= 1 236 | 237 | assert {"mailbox": mailbox["uid"], "message": msg_uid} in unreads 238 | 239 | run(backend.mark_mail_read(fake_account, msg_uid)) 240 | 241 | unreads = run(backend.get_unreads(fake_account)) 242 | 243 | assert {"mailbox": mailbox["uid"], "message": msg_uid} not in unreads 244 | 245 | 246 | def test_session(loop, fake_account, backend): 247 | """ Test session in storage """ 248 | run = loop.run_until_complete 249 | 250 | session_key = "testsessionkey" 251 | 252 | session = {"fakedict": "fakevalue"} 253 | 254 | run(backend.save_user_session(session_key, session)) 255 | 256 | session_from_storage = run(backend.get_user_session(session_key)) 257 | 258 | assert "fakedict" in session_from_storage 259 | assert session_from_storage["fakedict"] == "fakevalue" 260 | 261 | session_from_storage["newvalue"] = 42 262 | session_from_storage["fakedict"] = "badvalue" 263 | 264 | run(backend.save_user_session(session_key, session_from_storage)) 265 | 266 | session_from_storage = run(backend.get_user_session(session_key)) 267 | 268 | assert "fakedict" in session_from_storage 269 | assert "newvalue" in session_from_storage 270 | assert session_from_storage["fakedict"] == "badvalue" 271 | assert session_from_storage["newvalue"] == 42 272 | 273 | 274 | def test_search_contact(loop, fake_account, backend, msg_test, fake_emails, settings): 275 | """ Test contact search feature """ 276 | run = loop.run_until_complete 277 | 278 | from_addr = msg_test["From"].addresses[0] 279 | to_addrs = [parse_email(fake_emails()), parse_email(fake_emails())] 280 | 281 | run( 282 | backend.store_mail( 283 | msg=msg_test, 284 | account=fake_account, 285 | from_addr=from_addr, 286 | recipients=to_addrs, 287 | incoming=True, 288 | ) 289 | ) 290 | 291 | search = run(backend.contacts_search(fake_account, "foot")) 292 | 293 | assert len(search) == 1 294 | assert search[0] == "joe@football.example.com" 295 | 296 | 297 | def test_store_bad_msg(loop, fake_account, backend, msg_test, fake_emails, settings): 298 | """ Test contact search feature """ 299 | run = loop.run_until_complete 300 | 301 | from_addr = msg_test["From"].addresses[0] 302 | to_addrs = [parse_email(fake_emails()), parse_email(fake_emails())] 303 | 304 | to_save = { 305 | "status": "error", 306 | "peer": "fake@peer.old", 307 | "host_name": "fake@hostname.old", 308 | "from": from_addr, 309 | "tos": to_addrs, 310 | "subject": msg_test["Subject"], 311 | "received": datetime.datetime.now().isoformat(), 312 | "data": base64.b64encode(msg_test.as_bytes()).decode("utf-8"), 313 | } 314 | 315 | run(backend.store_bad_msg(to_save)) 316 | 317 | -------------------------------------------------------------------------------- /byemail/archives/mailutils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import codecs 4 | from datetime import datetime 5 | import email 6 | import email.header 7 | import email.utils 8 | from email import charset 9 | from email.mime.text import MIMEText 10 | from email.utils import (make_msgid, getaddresses, 11 | parseaddr, formatdate, formataddr) 12 | import re 13 | from textwrap import TextWrapper 14 | 15 | _quote_re = re.compile('((([>] ?)*( |$)))?') 16 | _soft_re=re.compile(' $') 17 | 18 | EMAIL_REGEX = re.compile( 19 | r"(^[-!#$%&'*+/=?^_`{}|~0-9A-Z]+(\.[-!#$%&'*+/=?^_`{}|~0-9A-Z]+)*" # dot-atom 20 | r'|^"([\001-\010\013\014\016-\037!#-\[\]-\177]|\\[\001-011\013\014\016-\177])*"' # quoted-string 21 | r')@(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?$', re.IGNORECASE # domain 22 | ) 23 | 24 | def validEMail(value): 25 | return EMAIL_REGEX.match(value) 26 | 27 | 28 | class EmailAddressList(object): 29 | """Email address list class 30 | 31 | The purpose of this class is to make it easier to work with email address 32 | containing strings. 33 | 34 | >>> a1 = EmailAddressList('Name 1 <1@foo.com>') 35 | >>> a2 = EmailAddressList('2@foo.com') 36 | >>> a3 = EmailAddressList('3@foo.com, 4@foo.com') 37 | >>> str(a1 + a3) 38 | 'Name 1 <1@foo.com>, 3@foo.com, 4@foo.com' 39 | 40 | Duplicate entires are automatically removed: 41 | >>> str(a1 + a2 + a2 + a1) 42 | 'Name 1 <1@foo.com>, 2@foo.com' 43 | 44 | It is also easy to remove entries from a list: 45 | >>> str(a3 - '3@foo.com') 46 | '4@foo.com' 47 | """ 48 | __slots__ = ('_addrs', '_lookup') 49 | 50 | def __init__(self, *args): 51 | self._lookup = {} 52 | self._addrs = [] 53 | 54 | unicode_args = [] 55 | #for arg in args: 56 | # unicode_args.append(arg.decode('utf-8')) 57 | for name, email in getaddresses(map(str, args)): 58 | #for name, email in getaddresses(unicode_args): 59 | if email and not email in self._lookup: 60 | self._lookup[email] = name 61 | self._addrs.append((name, email)) 62 | 63 | def __repr__(self): 64 | return '<%s %r>' % (type(self).__name__, self._addrs) 65 | 66 | def __str__(self): 67 | return ', '.join([formataddr(addr) for addr in self._addrs]) 68 | 69 | def __getitem__(self, item): 70 | """ 71 | >>> a = EmailAddressList('3@foo.com, Name 4 <4@foo.com>') 72 | >>> a[1] 73 | (u'Name 4', u'4@foo.com') 74 | """ 75 | return self._addrs[item] 76 | 77 | def __len__(self): 78 | """ 79 | >>> a = EmailAddressList('3@foo.com, Name 4 <4@foo.com>') 80 | >>> len(a) 81 | 2 82 | """ 83 | return len(self._addrs) 84 | 85 | def __iter__(self): 86 | """ 87 | >>> a = EmailAddressList('3@foo.com, Name 4 <4@foo.com>') 88 | >>> list(a) 89 | [('', u'3@foo.com'), (u'Name 4', u'4@foo.com')] 90 | """ 91 | return iter(self._addrs) 92 | 93 | def __contains__(self, other): 94 | """ 95 | >>> addrs = EmailAddressList('a@example.com, b@example.com') 96 | >>> 'a@example.com' in addrs 97 | True 98 | >>> 'c@example.com' in addrs 99 | False 100 | """ 101 | if not isinstance(other, EmailAddressList): 102 | other = EmailAddressList(other) 103 | for email in other._lookup.keys(): 104 | if not email in self._lookup: 105 | return False 106 | return True 107 | 108 | def __add__(self, other): 109 | """ 110 | >>> l1 = EmailAddressList('a@example.com, b@example.com') 111 | >>> l2 = EmailAddressList('a@example.com, c@example.com') 112 | >>> str(l1 + l2) 113 | 'a@example.com, b@example.com, c@example.com' 114 | """ 115 | return EmailAddressList(self, other) 116 | 117 | def __sub__(self, other): 118 | """ 119 | >>> l1 = EmailAddressList('a@example.com, b@example.com') 120 | >>> l2 = EmailAddressList('a@example.com') 121 | >>> str(l1 - l2) 122 | 'b@example.com' 123 | """ 124 | new = EmailAddressList() 125 | if not isinstance(other, EmailAddressList): 126 | other = EmailAddressList(other) 127 | for name, email in self._addrs: 128 | if not email in other._lookup: 129 | new._lookup[email] = name 130 | new._addrs.append((name, email)) 131 | return new 132 | 133 | def parse_rfc2822_date(text): 134 | """Parse an rfc2822 date string into a datetime object.""" 135 | t = email.utils.mktime_tz(email.utils.parsedate_tz(text)) 136 | return datetime.utcfromtimestamp(t) 137 | 138 | def encode_header(value, charset=None): 139 | """Encodes mail headers. 140 | 141 | If `value` is a list each list item will be encoded separately and 142 | returned as a comma separated list. 143 | If `value` is a tuple it will be interpreted as (name, email). 144 | """ 145 | if isinstance(value, list): 146 | return ', \n\t'.join([encode_header(v, charset) 147 | for v in value]) 148 | elif isinstance(value, tuple): 149 | return '%s <%s>' % (email.header.Header(value[0], charset), value[1]) 150 | else: 151 | return email.header.Header(value, charset) 152 | 153 | def decode_header(text): 154 | """Decode a header value and return the value as a unicode string.""" 155 | if not text: 156 | return text 157 | res = [] 158 | for part, charset in email.header.decode_header(text): 159 | try: 160 | res.append(part.decode(charset or 'latin1', 'replace')) 161 | except LookupError: 162 | res.append(part.decode('utf-8', 'replace')) 163 | return ' '.join(res) 164 | 165 | def unwrap_flowed(text): 166 | """Unwrap paragraphs that have been wrapped with "soft" new lines 167 | according to rfc2646. 168 | 169 | >>> unwrap_flowed('Foo \\nBar') 170 | 'Foo Bar' 171 | >>> unwrap_flowed('Foo\\nBar') 172 | 'Foo\\r\\nBar' 173 | >>> unwrap_flowed('> Foo \\n> Bar') 174 | '> Foo Bar' 175 | >>> unwrap_flowed('>> Foo \\n>> Bar') 176 | '>> Foo Bar' 177 | >>> unwrap_flowed('> > Foo \\n> > Bar') 178 | '> > Foo Bar' 179 | >>> unwrap_flowed('>> > Foo \\n>> > Bar') 180 | '>> > Foo Bar' 181 | """ 182 | out = '' 183 | open_paragraph = False 184 | prev_level = -1 185 | for line in text.splitlines(): 186 | indent = _quote_re.match(line).group(1) or '' 187 | level = indent.count('>') 188 | 189 | # This should never happen with properly format="flowed" text 190 | if level != prev_level: 191 | out.rstrip(' ') 192 | open_paragraph = False 193 | prev_level = level 194 | if open_paragraph: 195 | out += line[len(indent):] 196 | elif out: 197 | out += '\r\n' + line 198 | else: 199 | out = line 200 | open_paragraph = _soft_re.search(line) 201 | return out 202 | 203 | def quote_text(text): 204 | """Quote text by prepending '>'-characters to each line""" 205 | lines = [] 206 | for line in text.splitlines(): 207 | if line.startswith('>'): 208 | line = '>' + line 209 | else: 210 | line = '> ' + line 211 | lines.append(line) 212 | return '\r\n'.join(lines) 213 | 214 | def reflow_quoted_text(text, width=72): 215 | """Reflow text with 'soft' (SP CR LF) newlines according to rfc2646 216 | 217 | Text paragraphs containing 'soft' newlines are reflowed for a maximum 218 | line length of @width characters. 219 | Only non-quoted text is reflowed (Lines not starting with '>'). 220 | 221 | >>> reflow_quoted_text('Foo \\nBar') 222 | 'Foo Bar' 223 | >>> reflow_quoted_text('> Foo \\n> Bar') 224 | '> Foo \\r\\n> Bar' 225 | >>> reflow_quoted_text('> Foo \\nBar ') 226 | '> Foo \\r\\nBar' 227 | >>> reflow_quoted_text('> Foo\\n\\n> Bar') 228 | '> Foo\\r\\n\\r\\n> Bar' 229 | >>> reflow_quoted_text('> Foo \\n' \ 230 | 'a b \\n' \ 231 | 'c d e g h i j k l m\\n' \ 232 | '> Bar', width=10) 233 | '> Foo \\r\\na b c d e \\r\\ng h i j k \\r\\nl m\\r\\n> Bar' 234 | """ 235 | wrapper = TextWrapper() 236 | wrapper.width = width 237 | lines = [] 238 | paragraph = [] 239 | for line in text.splitlines(): 240 | if line.startswith('>'): 241 | if paragraph: 242 | lines.append(' \r\n'.join(wrapper.wrap(''.join(paragraph)))) 243 | paragraph = [] 244 | lines.append(line) 245 | continue 246 | paragraph.append(line) 247 | if not line.endswith(' '): 248 | if paragraph: 249 | lines.append(' \r\n'.join(wrapper.wrap(''.join(paragraph)))) 250 | paragraph = [] 251 | if paragraph: 252 | lines.append(' \r\n'.join(wrapper.wrap(''.join(paragraph)))) 253 | return '\r\n'.join(lines) 254 | 255 | def wrap_flowed(text, width=72): 256 | """Wrap long lines with 'soft' (SP CR LF) newlines according to rfc2646. 257 | 258 | >>> wrap_flowed('''foo bar foo bar ''' \ 259 | '''foo bar foo bar''', width=10) 260 | 'foo bar \\r\\nfoo bar \\r\\nfoo bar \\r\\nfoo bar' 261 | """ 262 | wrapper = TextWrapper() 263 | wrapper.width = width 264 | lines = [] 265 | for line in text.splitlines(): 266 | lines.append(' \r\n'.join(wrapper.wrap(line))) 267 | return '\r\n'.join(lines) 268 | 269 | 270 | def decode_text_part(part): 271 | """Extract the payload from a text/plain mime part as a unicode string""" 272 | txt = part.get_payload(decode=True) 273 | charset = part.get_content_charset('latin1') 274 | # Make sure the charset is supported and fallback to 'ascii' if not 275 | try: 276 | codecs.lookup(charset) 277 | except LookupError: 278 | charset = 'ascii' 279 | return txt.decode(charset, 'replace') 280 | 281 | def decode_text_plain_part(part): 282 | payload = decode_text_part(part) 283 | format = part.get_param('format', None) 284 | # We need to remove all trailing spaces from messages 285 | # that are not format="flowed" to avoid improper text 286 | # re-wrapping 287 | if format != 'flowed': 288 | payload = '\n'.join([line.rstrip(' ') 289 | for line in payload.splitlines()]) 290 | return payload 291 | -------------------------------------------------------------------------------- /byemail/client/public/img/icons/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 148 | 149 | 150 | -------------------------------------------------------------------------------- /byemail/storage/tinydb.py: -------------------------------------------------------------------------------- 1 | import os 2 | import uuid 3 | import base64 4 | import datetime 5 | import asyncio 6 | 7 | from email import policy 8 | from email.parser import BytesParser 9 | from email.headerregistry import Address 10 | from email.errors import InvalidHeaderDefect 11 | 12 | import arrow 13 | 14 | from tinydb import TinyDB, Query 15 | from tinydb_serialization import Serializer, SerializationMiddleware 16 | 17 | from byemail import mailutils 18 | from byemail.conf import settings 19 | from byemail.storage import core, DoesntExists, MultipleResults 20 | 21 | 22 | class DateTimeSerializer(Serializer): 23 | OBJ_CLASS = datetime.datetime # The class this serializer handles 24 | 25 | def encode(self, obj): 26 | return arrow.get(obj).for_json() 27 | 28 | def decode(self, s): 29 | return arrow.get(s).datetime 30 | 31 | 32 | class AddressSerializer(Serializer): 33 | OBJ_CLASS = Address # The class this serializer handles 34 | 35 | def encode(self, obj): 36 | return "(_|_)".join([obj.addr_spec, obj.display_name]) 37 | 38 | def decode(self, s): 39 | addr_spec, display_name = s.split("(_|_)") 40 | try: 41 | return Address(display_name=display_name, addr_spec=addr_spec) 42 | except InvalidHeaderDefect: 43 | return "not decoded %s" % s 44 | 45 | 46 | class Backend(core.Backend): 47 | def __init__(self, datadir="data/", **kwargs): 48 | super().__init__(**kwargs) 49 | 50 | # Post init_settings things 51 | if not os.path.isdir(datadir): 52 | os.makedirs(datadir) 53 | 54 | # TODO put this in start() 55 | serialization = SerializationMiddleware() 56 | serialization.register_serializer(DateTimeSerializer(), "TinyDate") 57 | serialization.register_serializer(AddressSerializer(), "TinyAddress") 58 | 59 | self.db = TinyDB(os.path.join(datadir, "db.json"), storage=serialization) 60 | self.maildb = TinyDB(os.path.join(datadir, "maildb.json")) 61 | 62 | async def get(self, filter): 63 | """ Helper to get an item by filter """ 64 | results = self.db.search(filter) 65 | if len(results) < 1: 66 | raise DoesntExists() 67 | if len(results) > 1: 68 | raise MultipleResults() 69 | return results[0] 70 | 71 | async def get_or_create(self, filter, default): 72 | """ Helper to get or create an item by filter with default value """ 73 | try: 74 | result = await self.get(filter) 75 | return result 76 | except DoesntExists: 77 | self.db.insert(default) 78 | return default 79 | 80 | async def store_bad_msg(self, bad_msg): 81 | """ To handle msg that failed to parse """ 82 | bad_msg["type"] = "mail" 83 | 84 | self.db.insert(bad_msg) 85 | 86 | async def store_content(self, uid, content): 87 | """ Store raw message content """ 88 | b64_content = (base64.b64encode(content).decode("utf-8"),) 89 | return self.maildb.insert({"uid": uid, "content": b64_content}) 90 | 91 | async def get_content_msg(self, uid): 92 | """ Return EmailMessage instance for this message uid """ 93 | Mail = Query() 94 | 95 | results = self.maildb.search(Mail.uid == uid) 96 | if len(results) < 1: 97 | raise DoesntExists() 98 | if len(results) > 1: 99 | raise MultipleResults() 100 | b64_content = results[0]["content"][0] 101 | 102 | msg = BytesParser(policy=policy.default).parsebytes( 103 | base64.b64decode(b64_content) 104 | ) 105 | 106 | return msg 107 | 108 | async def get_or_create_mailbox(self, account, address, name): 109 | """ Create a mailbox """ 110 | 111 | Mailbox = Query() 112 | 113 | mailbox = await self.get_or_create( 114 | (Mailbox.type == "mailbox") 115 | & (Mailbox["address"] == address) 116 | & (Mailbox["account"] == account.name), 117 | { 118 | "uid": uuid.uuid4().hex, 119 | "account": account.name, 120 | "type": "mailbox", 121 | "address": address, 122 | "name": name, 123 | "last_message": None, 124 | "messages": [], 125 | }, 126 | ) 127 | return mailbox 128 | 129 | async def store_msg( 130 | self, msg, account, from_addr, to_addrs, incoming=True, extra_data=None 131 | ): 132 | """ Store message in database """ 133 | 134 | msg["type"] = "mail" 135 | 136 | msg["uid"] = uuid.uuid4().hex 137 | 138 | msg["incoming"] = incoming 139 | msg["unread"] = incoming 140 | msg["account"] = account.name 141 | 142 | if extra_data: 143 | msg.update(extra_data) 144 | 145 | eid = self.db.insert(msg) 146 | 147 | Mailbox = Query() 148 | 149 | # Mailbox to link mail 150 | mailboxes = [] 151 | if incoming: 152 | mailboxes.append( 153 | (msg["from"].addr_spec, msg["from"].display_name) 154 | ) # store only in `from` box 155 | else: 156 | if len(to_addrs) == 1: 157 | to = to_addrs[0] 158 | mailboxes.append((to.addr_spec, to.display_name)) 159 | else: # TODO Create group mailboxes 160 | for to in to_addrs: # Put in all recipients boxes 161 | mailboxes.append((to.addr_spec, to.display_name)) 162 | 163 | for mailbox_address, mailbox_name in mailboxes: 164 | # Get mailbox if exists or create it 165 | mailbox = await self.get_or_create_mailbox( 166 | account, mailbox_address, mailbox_name 167 | ) 168 | 169 | # Update last_message date 170 | if mailbox["last_message"] is None or mailbox["last_message"] < msg["date"]: 171 | mailbox["last_message"] = msg["date"] 172 | 173 | mailbox_message_data = {"id": eid, "uid": msg["uid"], "date": msg["date"]} 174 | 175 | mailbox["messages"].append(mailbox_message_data) 176 | 177 | self.db.update( 178 | mailbox, (Mailbox.type == "mailbox") & (Mailbox.uid == mailbox["uid"]) 179 | ) 180 | 181 | return msg 182 | 183 | async def get_mailboxes(self, account): 184 | """ Return all mailboxes in db with unread message count and total """ 185 | 186 | Mailbox = Query() 187 | Message = Query() 188 | 189 | mailboxes = list( 190 | self.db.search( 191 | (Mailbox.type == "mailbox") & (Mailbox["account"] == account.name) 192 | ) 193 | ) 194 | for mailbox in mailboxes: 195 | mailbox["total"] = len(mailbox["messages"]) 196 | mailbox["unreads"] = 0 197 | for message in mailbox["messages"]: 198 | msg = await self.get(Message.uid == message["uid"]) 199 | mailbox["unreads"] += 1 if msg.get("unread") else 0 200 | 201 | # TODO Hacky 202 | for m in mailboxes: 203 | if not isinstance(m["last_message"], datetime.datetime): 204 | m["last_message"] = arrow.get(m["last_message"]).datetime 205 | 206 | return mailboxes 207 | 208 | async def get_unreads(self, account, offset=0, limit=None): 209 | """ Return all unreads messages uids """ 210 | 211 | Mailbox = Query() 212 | Message = Query() 213 | 214 | mailboxes = list( 215 | self.db.search( 216 | (Mailbox.type == "mailbox") & (Mailbox["account"] == account.name) 217 | ) 218 | ) 219 | unreads = [] 220 | for mailbox in mailboxes: 221 | for message in mailbox["messages"]: 222 | msg = await self.get(Message.uid == message["uid"]) 223 | if msg["unread"]: 224 | unreads.append({"mailbox": mailbox["uid"], "message": msg["uid"]}) 225 | 226 | return unreads 227 | 228 | async def get_mailbox(self, account, mailbox_id): 229 | """ Return the selected mailboxx """ 230 | Mailbox = Query() 231 | Message = Query() 232 | 233 | mailbox = await self.get( 234 | (Mailbox.uid == mailbox_id) & (Mailbox["account"] == account.name) 235 | ) 236 | 237 | messages = [] 238 | mailbox["total"] = len(mailbox["messages"]) 239 | mailbox["unreads"] = 0 240 | for message in mailbox["messages"]: 241 | msg = await self.get(Message.uid == message["uid"]) 242 | msg = dict(msg) 243 | del msg["body"] 244 | mailbox["unreads"] += 1 if msg.get("unread") else 0 245 | messages.append(msg) 246 | 247 | mailbox["messages"] = messages 248 | 249 | # TODO reverse me 250 | for m in messages: 251 | if not isinstance(m["date"], datetime.datetime): 252 | m["date"] = arrow.get(m["date"]).datetime 253 | 254 | return mailbox 255 | 256 | async def get_mail(self, account, mail_uid): 257 | """ Get message by uid """ 258 | 259 | Message = Query() 260 | 261 | mail = await self.get( 262 | (Message.uid == mail_uid) & (Message["account"] == account.name) 263 | ) 264 | 265 | if not isinstance(mail["date"], datetime.datetime): 266 | mail["date"] = arrow.get(mail["date"]).datetime 267 | 268 | return mail 269 | 270 | async def get_mail_attachment(self, account, mail_uid, att_index): 271 | """ Return a specific mail attachment """ 272 | 273 | Message = Query() 274 | 275 | mail = await self.get( 276 | (Message.uid == mail_uid) & (Message["account"] == account.name) 277 | ) 278 | raw_mail = await self.get_content_msg(mail_uid) 279 | 280 | attachment = mail["attachments"][att_index] 281 | 282 | atts = list(raw_mail.iter_attachments()) 283 | stream = atts[att_index] 284 | 285 | content = stream.get_content() 286 | return attachment, content 287 | 288 | async def update_mail(self, account, mail): 289 | """ Update any mail """ 290 | 291 | Message = Query() 292 | 293 | self.db.update( 294 | mail, (Message.uid == mail["uid"]) & (Message["account"] == account.name) 295 | ) 296 | 297 | return mail 298 | 299 | async def mark_mail_read(self, account, mail_uid): 300 | """ Mark a mail as read for an account """ 301 | 302 | Message = Query() 303 | 304 | mail = await self.get( 305 | (Message.uid == mail_uid) & (Message["account"] == account.name) 306 | ) 307 | 308 | mail["unread"] = False 309 | 310 | await self.update_mail(account, mail) 311 | 312 | return mail 313 | 314 | async def save_user_session(self, session_key, session): 315 | """ Save modified user session """ 316 | Session = Query() 317 | 318 | session_from_storage = await self.get_user_session(session_key) 319 | session_from_storage.update(session) 320 | 321 | self.db.update( 322 | session_from_storage, 323 | (Session.type == "session") & (Session.key == session_key), 324 | ) 325 | 326 | async def get_user_session(self, session_key): 327 | """ Load user session """ 328 | 329 | Session = Query() 330 | return await self.get_or_create( 331 | (Session.type == "session") & (Session.key == session_key), 332 | {"type": "session", "key": session_key}, 333 | ) 334 | 335 | async def contacts_search(self, account, text): 336 | """ Search a contact from mailboxes """ 337 | 338 | Mailbox = Query() 339 | 340 | results = self.db.search( 341 | (Mailbox.type == "mailbox") 342 | & (Mailbox["account"] == account.name) 343 | & (Mailbox["address"].search(text)) 344 | ) 345 | 346 | return [r["address"] for r in results[:10]] 347 | 348 | --------------------------------------------------------------------------------