├── src └── mailadm │ ├── data │ ├── __init__.py │ ├── delta-chat-bw.png │ ├── delta-chat-red.png │ └── opensans-regular.ttf │ ├── __init__.py │ ├── app.py │ ├── util.py │ ├── web.py │ ├── gen_qr.py │ ├── mailcow.py │ ├── db.py │ ├── commands.py │ ├── bot.py │ ├── conn.py │ └── cmdline.py ├── doc ├── requirements.txt ├── _themes │ ├── .gitignore │ ├── flask │ │ ├── theme.conf │ │ ├── relations.html │ │ ├── layout.html │ │ └── static │ │ │ └── flasky.css_t │ ├── README │ ├── LICENSE │ └── flask_theme_support.py ├── Makefile ├── _static │ └── sphinxdoc.css ├── conf.py └── index.rst ├── assets └── avatar.jpg ├── setup.py ├── gunicorn.conf.py ├── .readthedocs.yaml ├── scripts ├── mailadm.sh ├── imap_test.py └── release.py ├── tests ├── test_gen_qr.py ├── test_util.py ├── test_mailcow.py ├── test_db.py ├── test_web.py ├── conftest.py ├── test_bot.py ├── test_conn.py └── test_cmdline.py ├── Dockerfile ├── .github └── workflows │ └── ci.yml ├── pyproject.toml ├── README.rst ├── tox.ini ├── setup.cfg ├── CHANGELOG.rst ├── .gitignore └── LICENSE /src/mailadm/data/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/requirements.txt: -------------------------------------------------------------------------------- 1 | sphinx==7.2.6 2 | -------------------------------------------------------------------------------- /doc/_themes/.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.pyo 3 | .DS_Store 4 | -------------------------------------------------------------------------------- /assets/avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deltachat/mailadm/HEAD/assets/avatar.jpg -------------------------------------------------------------------------------- /src/mailadm/data/delta-chat-bw.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deltachat/mailadm/HEAD/src/mailadm/data/delta-chat-bw.png -------------------------------------------------------------------------------- /src/mailadm/data/delta-chat-red.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deltachat/mailadm/HEAD/src/mailadm/data/delta-chat-red.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import setuptools 4 | 5 | if __name__ == "__main__": 6 | setuptools.setup() 7 | -------------------------------------------------------------------------------- /src/mailadm/data/opensans-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/deltachat/mailadm/HEAD/src/mailadm/data/opensans-regular.ttf -------------------------------------------------------------------------------- /gunicorn.conf.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | 4 | def on_starting(_server): 5 | logging.basicConfig(level=logging.INFO) 6 | from mailadm.app import init_threads 7 | 8 | init_threads() 9 | -------------------------------------------------------------------------------- /doc/_themes/flask/theme.conf: -------------------------------------------------------------------------------- 1 | [theme] 2 | inherit = basic 3 | stylesheet = flasky.css 4 | pygments_style = flask_theme_support.FlaskyStyle 5 | 6 | [options] 7 | index_logo = '' 8 | index_logo_height = 120px 9 | touch_icon = 10 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-22.04 5 | tools: 6 | python: "3.11" 7 | 8 | sphinx: 9 | configuration: doc/conf.py 10 | 11 | python: 12 | install: 13 | - requirements: doc/requirements.txt 14 | -------------------------------------------------------------------------------- /src/mailadm/__init__.py: -------------------------------------------------------------------------------- 1 | from pkg_resources import DistributionNotFound, get_distribution 2 | 3 | try: 4 | __version__ = get_distribution(__name__).version 5 | except DistributionNotFound: 6 | # package is not installed 7 | __version__ = "0.0.0.dev0-unknown" 8 | -------------------------------------------------------------------------------- /scripts/mailadm.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Runs a single `mailadm` command inside a temporary container. 3 | SCRIPTS=$(realpath -- $(dirname "$0")) 4 | DATA="$SCRIPTS/../docker-data" 5 | ENVFILE="$SCRIPTS/../.env" 6 | sudo docker run --mount type=bind,source="$DATA",target=/mailadm/docker-data --env-file "$ENVFILE" --rm mailadm-mailcow mailadm "$@" 7 | -------------------------------------------------------------------------------- /tests/test_gen_qr.py: -------------------------------------------------------------------------------- 1 | from mailadm.gen_qr import gen_qr 2 | from pyzbar.pyzbar import decode 3 | 4 | 5 | def test_gen_qr(db): 6 | with db.write_transaction() as conn: 7 | config = conn.config 8 | token = conn.add_token("burner1", expiry="1w", token="1w_7wDioPeeXyZx96v3", prefix="pp") 9 | 10 | image = gen_qr(config=config, token_info=token) 11 | qr_decoded = decode(image)[0] 12 | assert bytes(token.get_qr_uri(), encoding="ascii") == qr_decoded.data 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM docker.io/alpine:latest 2 | 3 | # Install Pillow (py3-pillow) from Alpine repository to avoid compiling it. 4 | RUN apk add py3-pip py3-pillow cmake clang clang-dev make gcc g++ libc-dev linux-headers cargo openssl-dev python3-dev libffi-dev 5 | RUN pip install --break-system-packages -U pip 6 | 7 | WORKDIR mailadm 8 | RUN mkdir src 9 | COPY setup.cfg pyproject.toml gunicorn.conf.py README.rst /mailadm/ 10 | COPY src src/ 11 | COPY assets assets/ 12 | 13 | RUN pip install --break-system-packages . 14 | -------------------------------------------------------------------------------- /src/mailadm/app.py: -------------------------------------------------------------------------------- 1 | """ 2 | help gunicorn and other WSGI servers to instantiate a web instance of mailadm 3 | """ 4 | 5 | import threading 6 | 7 | from mailadm.bot import get_admbot_db_path 8 | from mailadm.bot import main as run_bot 9 | 10 | from .db import DB, get_db_path 11 | from .web import create_app_from_db_path 12 | 13 | 14 | def init_threads(): 15 | botthread = threading.Thread( 16 | target=run_bot, 17 | args=(DB(get_db_path()), get_admbot_db_path()), 18 | daemon=True, 19 | name="bot", 20 | ) 21 | botthread.start() 22 | 23 | 24 | app = create_app_from_db_path() 25 | -------------------------------------------------------------------------------- /doc/_themes/flask/relations.html: -------------------------------------------------------------------------------- 1 |

Related Topics

2 | 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Set up Python ${{ matrix.python-version }} 15 | uses: actions/setup-python@v2 16 | with: 17 | python-version: ${{ matrix.python-version }} 18 | - name: Install Dependencies 19 | run: | 20 | sudo apt install -y libzbar0 21 | python -m pip install -U pip 22 | pip install tox tox-gh-actions 23 | - name: Run Tox 24 | run: MAILCOW_TOKEN=${{ secrets.MAILCOW_TOKEN }} MAILCOW_ENDPOINT=${{ vars.MAILCOW_ENDPOINT }} MAIL_DOMAIN=${{ vars.MAIL_DOMAIN }} tox 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools_scm"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.ruff] 6 | lint.select = [ 7 | "E", "W", # pycodestyle 8 | "F", # Pyflakes 9 | "N", # pep8-naming 10 | "I", # isort 11 | 12 | "YTT", # flake8-2020 13 | "C4", # flake8-comprehensions 14 | "ISC", # flake8-implicit-str-concat 15 | "G", # flake8-logging-format 16 | "ICN", # flake8-import-conventions 17 | "PT", # flake8-pytest-style 18 | "TID", # flake8-tidy-imports 19 | "DTZ", # flake8-datetimez 20 | "PIE", # flake8-pie 21 | "COM", # flake8-commas 22 | 23 | "PLC", # Pylint Convention 24 | "PLE", # Pylint Error 25 | "PLW", # Pylint Warning 26 | ] 27 | lint.ignore = ["PT001", "PT016", "PT011"] 28 | line-length = 100 29 | 30 | [tool.black] 31 | line-length = 100 32 | -------------------------------------------------------------------------------- /doc/_themes/flask/layout.html: -------------------------------------------------------------------------------- 1 | {%- extends "basic/layout.html" %} 2 | {%- block extrahead %} 3 | {{ super() }} 4 | {% if theme_touch_icon %} 5 | 6 | {% endif %} 7 | 8 | {% endblock %} 9 | {%- block relbar2 %}{% endblock %} 10 | {% block header %} 11 | {{ super() }} 12 | {% if pagename == 'index' %} 13 |
14 | {% endif %} 15 | {% endblock %} 16 | {%- block footer %} 17 | 21 | {% if pagename == 'index' %} 22 |
23 | {% endif %} 24 | {%- endblock %} 25 | -------------------------------------------------------------------------------- /src/mailadm/util.py: -------------------------------------------------------------------------------- 1 | import random 2 | import secrets 3 | import sys 4 | 5 | 6 | def gen_password(): 7 | return secrets.token_urlsafe(20) 8 | 9 | 10 | def get_human_readable_id(len=5, chars="2345789acdefghjkmnpqrstuvwxyz"): 11 | return "".join(random.choice(chars) for i in range(len)) 12 | 13 | 14 | def parse_expiry_code(code): 15 | if code == "never": 16 | return sys.maxsize 17 | 18 | if len(code) < 2: 19 | raise ValueError("expiry codes are at least 2 characters") 20 | val = int(code[:-1]) 21 | c = code[-1] 22 | if c == "y": 23 | return val * 365 * 24 * 60 * 60 24 | elif c == "w": 25 | return val * 7 * 24 * 60 * 60 26 | elif c == "d": 27 | return val * 24 * 60 * 60 28 | elif c == "h": 29 | return val * 60 * 60 30 | elif c == "s": 31 | return val 32 | else: 33 | raise ValueError(c + " is not a valid time unit. Try [y]ears, [w]eeks, [d]ays, or [h]ours") 34 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | mailadm 2 | ======= 3 | 4 | mailadm is an administration tool for creating and removing e-mail accounts 5 | (especially temporary accounts) on a `mailcow `_ mail 6 | server. This makes creating an email account as easy as scanning a QR code. 7 | 8 | The general idea of how it works: 9 | 10 | 1. Admins generate an "account creation token", which can be displayed as a QR 11 | code 12 | 2. A soon-to-be user uses an email client to scan the QR code, which sends a 13 | web request to mailadm 14 | 3. mailadm creates the account and sends the password to the Delta Chat app, 15 | the user is automatically logged in to the new account 16 | 17 | mail clients with mailadm support 18 | --------------------------------- 19 | 20 | So far only the `Delta Chat messenger `_ supports mailadm. 21 | 22 | setup & usage 23 | ------------- 24 | 25 | See the `doc/index.rst` file or https://mailadm.readthedocs.io for more info. 26 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py3,lint,doc 3 | isolated_build = True 4 | skipsdist = True 5 | 6 | [testenv] 7 | deps = 8 | pytest 9 | pytest-xdist 10 | pytest-timeout 11 | pyzbar 12 | pdbpp 13 | -e . 14 | commands = 15 | pytest --durations 6 -n 6 {posargs:tests} 16 | 17 | [testenv:py3] 18 | basepython = python3 19 | passenv = * 20 | 21 | [testenv:doc] 22 | deps = 23 | sphinx 24 | allowlist_externals = make 25 | changedir = doc 26 | commands = 27 | make html 28 | 29 | 30 | [testenv:lint] 31 | usedevelop = True 32 | basepython = python3 33 | deps = 34 | restructuredtext_lint 35 | pygments 36 | ruff 37 | black 38 | commands = 39 | rst-lint README.rst CHANGELOG.rst doc/index.rst 40 | ruff check src/ tests/ 41 | black --check --diff src/ tests/ 42 | 43 | [testenv:check-manifest] 44 | skip_install = True 45 | basepython = python3 46 | deps = check-manifest 47 | commands = check-manifest 48 | 49 | [gh-actions] 50 | python = 51 | 3: py3, lint, doc 52 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = mailadm 3 | version = 1.0.0 4 | author = holger, missytake 5 | author_email = holger@merlinux.eu, missytake@systemli.org 6 | description = web API and CLI tool for automatic e-mail user creation/deletion 7 | long_description = file: README.rst 8 | url = https://github.com/deltachat/mailadm 9 | license_files = LICENSE 10 | 11 | [options] 12 | zip_safe = False 13 | packages=find: 14 | include_package_data=True 15 | install_requires = 16 | deltachat 17 | click>=6.0 18 | flask 19 | pillow 20 | qrcode 21 | gunicorn 22 | requests 23 | imapclient 24 | 25 | [options.entry_points] 26 | console_scripts = 27 | mailadm = mailadm.cmdline:mailadm_main 28 | 29 | [options.packages.find] 30 | where=src 31 | 32 | [options.package_data] 33 | * = *.png, *.ttf 34 | 35 | [build_sphinx] 36 | source-dir = doc/ 37 | build-dir = doc/_build 38 | all_files = 1 39 | 40 | [upload_sphinx] 41 | upload-dir = doc/_build/html 42 | 43 | [bdist_wheel] 44 | universal = 1 45 | 46 | [devpi:upload] 47 | formats = sdist.tgz,bdist_wheel 48 | -------------------------------------------------------------------------------- /tests/test_util.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | import pytest 4 | from mailadm.util import get_human_readable_id, parse_expiry_code 5 | 6 | 7 | @pytest.mark.parametrize( 8 | ("code", "duration"), 9 | [ 10 | ("never", sys.maxsize), 11 | ("2y", 2 * 365 * 24 * 60 * 60), 12 | ("1w", 7 * 24 * 60 * 60), 13 | ("2w", 2 * 7 * 24 * 60 * 60), 14 | ("2d", 2 * 24 * 60 * 60), 15 | ("5h", 5 * 60 * 60), 16 | ("15h", 15 * 60 * 60), 17 | ("20s", 20), 18 | ("0h", 0), 19 | ], 20 | ) 21 | def test_parse_expiries(code, duration): 22 | res = parse_expiry_code(code) 23 | assert res == duration 24 | 25 | 26 | def test_parse_expiries_short(): 27 | with pytest.raises(ValueError): 28 | parse_expiry_code("h") 29 | 30 | 31 | def test_parse_expiries_wrong(): 32 | with pytest.raises(ValueError): 33 | parse_expiry_code("123h123d") 34 | with pytest.raises(ValueError): 35 | parse_expiry_code("12j") 36 | 37 | 38 | def test_human_readable_id(): 39 | s = get_human_readable_id(len=20) 40 | assert s.isalnum() 41 | -------------------------------------------------------------------------------- /tests/test_mailcow.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | import pytest 4 | from mailadm.mailcow import MailcowError 5 | 6 | 7 | class TestMailcow: 8 | def test_get_users(self, mailcow): 9 | mailcow.get_user_list() 10 | 11 | def test_add_del_user(self, mailcow, mailcow_domain): 12 | addr = "pytest.%s@%s" % (randint(0, 99999), mailcow_domain) 13 | mailcow.add_user_mailcow(addr, "asdf1234", "pytest") 14 | 15 | user = mailcow.get_user(addr) 16 | assert user.addr == addr 17 | assert user.token == "pytest" 18 | 19 | mailcow.del_user_mailcow(addr) 20 | assert mailcow.get_user(addr) is None 21 | 22 | def test_wrong_token(self, mailcow, mailcow_domain): 23 | mailcow.auth = {"X-API-Key": "asdf"} 24 | addr = "pytest.%s@%s" % (randint(0, 99999), mailcow_domain) 25 | with pytest.raises(MailcowError): 26 | mailcow.get_user_list() 27 | with pytest.raises(MailcowError): 28 | mailcow.add_user_mailcow(addr, "asdf1234", "pytest") 29 | with pytest.raises(MailcowError): 30 | mailcow.get_user(addr) 31 | with pytest.raises(MailcowError): 32 | mailcow.del_user_mailcow(addr) 33 | -------------------------------------------------------------------------------- /doc/_themes/README: -------------------------------------------------------------------------------- 1 | Flask Sphinx Styles 2 | =================== 3 | 4 | This repository contains sphinx styles for Flask and Flask related 5 | projects. To use this style in your Sphinx documentation, follow 6 | this guide: 7 | 8 | 1. put this folder as _themes into your docs folder. Alternatively 9 | you can also use git submodules to check out the contents there. 10 | 2. add this to your conf.py: 11 | 12 | sys.path.append(os.path.abspath('_themes')) 13 | html_theme_path = ['_themes'] 14 | html_theme = 'flask' 15 | 16 | The following themes exist: 17 | 18 | - 'flask' - the standard flask documentation theme for large 19 | projects 20 | - 'flask_small' - small one-page theme. Intended to be used by 21 | very small addon libraries for flask. 22 | 23 | The following options exist for the flask_small theme: 24 | 25 | [options] 26 | index_logo = '' filename of a picture in _static 27 | to be used as replacement for the 28 | h1 in the index.rst file. 29 | index_logo_height = 120px height of the index logo 30 | github_fork = '' repository name on github for the 31 | "fork me" badge 32 | -------------------------------------------------------------------------------- /scripts/imap_test.py: -------------------------------------------------------------------------------- 1 | import email 2 | import smtplib 3 | import urllib.parse 4 | 5 | import requests 6 | from imapclient import IMAPClient 7 | 8 | 9 | def receive_imap(host, user, password, num): 10 | conn = IMAPClient(host) 11 | conn.login(user, password) 12 | info = conn.select_folder("INBOX") 13 | print(info) 14 | messages = conn.fetch("1:*", [b"FLAGS"]) 15 | latest_msg = max(messages) 16 | requested = "FLAGS BODY.PEEK[]" 17 | for uid, data in conn.fetch([latest_msg], [b"FLAGS", b"BODY.PEEK[]"]).items(): 18 | body_bytes = data[b"BODY[]"] 19 | email_message = email.message_from_bytes(body_bytes) 20 | assert email_message["subject"] == str(num) 21 | print("received message num={}".format(num)) 22 | print(email_message) 23 | 24 | 25 | def send_self_mail(host, user, password, num): 26 | smtp = smtplib.SMTP(host, 587) 27 | smtp.set_debuglevel(1) 28 | smtp.starttls() 29 | smtp.login(user, password) 30 | msg = """\ 31 | From: {user} 32 | To: {user} 33 | Subject: {num} 34 | 35 | hi there 36 | """.format( 37 | user=user, num=num 38 | ) 39 | smtp.sendmail(user, [user], msg) 40 | print("send message num={}".format(num)) 41 | 42 | 43 | if __name__ == "__main__": 44 | import sys 45 | 46 | if sys.argv[1].startswith("https"): 47 | res = requests.post(sys.argv[1]) 48 | assert res.status_code == 200 49 | data = res.json() 50 | user = data["email"] 51 | password = data["password"] 52 | 53 | if len(sys.argv) >= 3: 54 | host = sys.argv[2] 55 | else: 56 | host = urllib.parse.urlparse(sys.argv[1]).hostname 57 | else: 58 | user, password, host = sys.argv[1:] 59 | num = 42 60 | send_self_mail(host, user, password, num) 61 | receive_imap(host, user, password, num) 62 | -------------------------------------------------------------------------------- /doc/_themes/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2010 by Armin Ronacher. 2 | 3 | Some rights reserved. 4 | 5 | Redistribution and use in source and binary forms of the theme, with or 6 | without modification, are permitted provided that the following conditions 7 | are met: 8 | 9 | * Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above 13 | copyright notice, this list of conditions and the following 14 | disclaimer in the documentation and/or other materials provided 15 | with the distribution. 16 | 17 | * The names of the contributors may not be used to endorse or 18 | promote products derived from this software without specific 19 | prior written permission. 20 | 21 | We kindly ask you to only use these themes in an unmodified manner just 22 | for Flask and Flask-related products, not for unrelated projects. If you 23 | like the visual style and want to use it for your own projects, please 24 | consider making some larger changes to the themes (such as changing 25 | font faces, sizes, colors or margins). 26 | 27 | THIS THEME IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 28 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 29 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 30 | ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 31 | LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 32 | CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 33 | SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 34 | INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 35 | CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 36 | ARISING IN ANY WAY OUT OF THE USE OF THIS THEME, EVEN IF ADVISED OF THE 37 | POSSIBILITY OF SUCH DAMAGE. 38 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Unreleased 2 | ------------- 3 | 4 | 1.0.0 5 | ----- 6 | 7 | - only expire accounts which are not actively used (unless they were supposed to live less than 27 days). 8 | - After a refactoring, mailadm requires you to run `setup-bot` now before starting mailadm - the [installation steps](https://mailadm.readthedocs.io/en/latest/) have been updated accordingly. 9 | - mailadm was successfully [security audited](https://delta.chat/en/2023-03-27-third-independent-security-audit) - the few issues which have been found were all fixed. Read the [full report here](https://delta.chat/assets/blog/MER-01-report.pdf). 10 | 11 | 0.12.0 12 | ------ 13 | 14 | - added the bot interface for giving mailadm commands via chat 15 | 16 | 0.11.0 17 | ------ 18 | 19 | - breaking change: mailadm now uses mailcow REST API for creating/manipulating e-mail accounts instead of fiddling with postfix/dovecot config 20 | - deprecate dovecot/postfix support 21 | - provider instructions for docker setup 22 | 23 | 0.10.5 24 | ------------- 25 | 26 | - fix permission issue in install and readme 27 | 28 | - make "mailadm-prune" systemd service run as vmail user 29 | and set group for mailadm.db as vmail user. 30 | 31 | 0.10.4 32 | ------------- 33 | 34 | - fix #15 using a mailadm user not named "mailadm" 35 | - fix various little errors 36 | 37 | 0.10.3 38 | ------------- 39 | 40 | - fix computing the user vmail dir 41 | 42 | 0.10.2 43 | ------------- 44 | 45 | - add mod-token command to modify parameters of a token 46 | 47 | 0.10.1 48 | ------------- 49 | 50 | - fixed tokens to not contain special chars 51 | 52 | 0.10.0 53 | ------------- 54 | 55 | - revamped configuration and documentation 56 | 57 | - now all config/state is in a sqlite database 58 | see also new "mailadm config" subcommand 59 | 60 | - mailadm now has root-less operations 61 | 62 | - gen-qr subcommand creates a QR code with basic explanation text 63 | 64 | - rename subcommand "add-local-user" to "add-user" 65 | because virtually all command line options are local-options 66 | 67 | - add "add-token/del-token" sub commands 68 | 69 | - rename subcommand "prune-expired" to "prune" 70 | because pruning is about expired accounts 71 | 72 | - use setuptools_scm for automatic versioning 73 | 74 | - added circle-ci config for running python tests 75 | 76 | 77 | 0.9.0 78 | --------------- 79 | 80 | initial release 81 | -------------------------------------------------------------------------------- /src/mailadm/web.py: -------------------------------------------------------------------------------- 1 | from flask import Flask, jsonify, request 2 | from requests.exceptions import ReadTimeout 3 | 4 | import mailadm.db 5 | from mailadm.conn import DBError 6 | from mailadm.mailcow import MailcowError 7 | 8 | 9 | def create_app_from_db_path(db_path=None): 10 | if db_path is None: 11 | db_path = mailadm.db.get_db_path() 12 | 13 | db = mailadm.db.DB(db_path) 14 | return create_app_from_db(db) 15 | 16 | 17 | def create_app_from_db(db): 18 | app = Flask("mailadm-account-server") 19 | app.db = db 20 | 21 | @app.route("/", methods=["POST"]) 22 | def new_email(): 23 | token = request.args.get("t") 24 | if token is None: 25 | return ( 26 | jsonify(type="error", status_code=403, reason="?t (token) parameter not specified"), 27 | 403, 28 | ) 29 | 30 | with db.write_transaction() as conn: 31 | token_info = conn.get_tokeninfo_by_token(token) 32 | if token_info is None: 33 | return ( 34 | jsonify( 35 | type="error", 36 | status_code=403, 37 | reason="token {} is invalid".format(token), 38 | ), 39 | 403, 40 | ) 41 | try: 42 | user_info = conn.add_email_account_tries(token_info, tries=10) 43 | return jsonify( 44 | email=user_info.addr, 45 | password=user_info.password, 46 | expiry=token_info.expiry, 47 | ttl=user_info.ttl, 48 | ) 49 | except (DBError, MailcowError) as e: 50 | if "does already exist" in str(e): 51 | return ( 52 | jsonify( 53 | type="error", 54 | status_code=409, 55 | reason="user already exists in mailcow", 56 | ), 57 | 409, 58 | ) 59 | if "UNIQUE constraint failed" in str(e): 60 | return ( 61 | jsonify( 62 | type="error", 63 | status_code=409, 64 | reason="user already exists in mailadm", 65 | ), 66 | 409, 67 | ) 68 | return jsonify(type="error", status_code=500, reason=str(e)), 500 69 | except ReadTimeout: 70 | return jsonify(type="error", status_code=504, reason="mailcow not reachable"), 504 71 | 72 | return app 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | _build/ 6 | *.swp 7 | playground/ 8 | 9 | # C extensions 10 | *.so 11 | 12 | .idea/.gitignore 13 | .idea/inspectionProfiles/profiles_settings.xml 14 | .idea/mailadm.iml 15 | .idea/modules.xml 16 | .idea/vcs.xml 17 | 18 | 19 | # Distribution / packaging 20 | .Python 21 | build/ 22 | develop-eggs/ 23 | dist/ 24 | downloads/ 25 | eggs/ 26 | .eggs/ 27 | lib/ 28 | lib64/ 29 | parts/ 30 | sdist/ 31 | var/ 32 | wheels/ 33 | pip-wheel-metadata/ 34 | share/python-wheels/ 35 | *.egg-info/ 36 | .installed.cfg 37 | *.egg 38 | MANIFEST 39 | 40 | # PyInstaller 41 | # Usually these files are written by a python script from a template 42 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 43 | *.manifest 44 | *.spec 45 | 46 | # Installer logs 47 | pip-log.txt 48 | pip-delete-this-directory.txt 49 | 50 | # Unit test / coverage reports 51 | htmlcov/ 52 | .tox/ 53 | .nox/ 54 | .coverage 55 | .coverage.* 56 | .cache 57 | nosetests.xml 58 | coverage.xml 59 | *.cover 60 | *.py,cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Django stuff: 69 | *.log 70 | local_settings.py 71 | db.sqlite3 72 | db.sqlite3-journal 73 | 74 | # Flask stuff: 75 | instance/ 76 | .webassets-cache 77 | 78 | # Scrapy stuff: 79 | .scrapy 80 | 81 | # Sphinx documentation 82 | docs/_build/ 83 | 84 | # PyBuilder 85 | target/ 86 | 87 | # Jupyter Notebook 88 | .ipynb_checkpoints 89 | 90 | # IPython 91 | profile_default/ 92 | ipython_config.py 93 | 94 | # pyenv 95 | .python-version 96 | 97 | # pipenv 98 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 99 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 100 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 101 | # install all needed dependencies. 102 | #Pipfile.lock 103 | 104 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 105 | __pypackages__/ 106 | 107 | # Celery stuff 108 | celerybeat-schedule 109 | celerybeat.pid 110 | 111 | # SageMath parsed files 112 | *.sage.py 113 | 114 | # Environments 115 | .env 116 | .venv 117 | env/ 118 | venv/ 119 | ENV/ 120 | env.bak/ 121 | venv.bak/ 122 | docker-data/ 123 | 124 | # Spyder project settings 125 | .spyderproject 126 | .spyproject 127 | 128 | # Rope project settings 129 | .ropeproject 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | 142 | .mailadm.lock 143 | mailadm.db 144 | -------------------------------------------------------------------------------- /src/mailadm/gen_qr.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pkg_resources 4 | import qrcode 5 | from PIL import Image, ImageDraw, ImageFont 6 | 7 | 8 | def gen_qr(config, token_info): 9 | info = "{prefix}******@{domain} {expiry}\n".format( 10 | domain=config.mail_domain, 11 | prefix=token_info.prefix, 12 | expiry=token_info.expiry, 13 | ) 14 | 15 | steps = ( 16 | "1. Install https://get.delta.chat\n" 17 | "2. From setup screen scan above QR code\n" 18 | "3. Choose nickname & avatar\n" 19 | "+ chat with any e-mail address ...\n" 20 | ) 21 | 22 | # load QR code 23 | url = token_info.get_qr_uri() 24 | qr = qrcode.QRCode( 25 | version=1, 26 | error_correction=qrcode.constants.ERROR_CORRECT_H, 27 | box_size=1, 28 | border=1, 29 | ) 30 | qr.add_data(url) 31 | qr.make(fit=True) 32 | qr_img = qr.make_image(fill_color="black", back_color="white") 33 | 34 | # paint all elements 35 | ttf_path = pkg_resources.resource_filename("mailadm", "data/opensans-regular.ttf") 36 | logo_red_path = pkg_resources.resource_filename("mailadm", "data/delta-chat-red.png") 37 | 38 | assert os.path.exists(ttf_path), ttf_path 39 | font_size = 16 40 | font = ImageFont.truetype(font=ttf_path, size=font_size) 41 | 42 | num_lines = (info + steps).count("\n") + 2 43 | 44 | size = width = 384 45 | qr_padding = 6 46 | text_margin_right = 12 47 | text_height = font_size * num_lines 48 | height = size + text_height + qr_padding * 2 49 | 50 | image = Image.new("RGBA", (width, height), "white") 51 | 52 | draw = ImageDraw.Draw(image) 53 | 54 | qr_final_size = width - (qr_padding * 2) 55 | 56 | # draw text 57 | font_left, _font_top, font_right, _font_bottom = font.getbbox(info.strip()) 58 | font_width = font_right - font_left 59 | info_pos = (width - font_width) // 2 60 | draw.multiline_text( 61 | (info_pos, size - qr_padding // 2), 62 | info, 63 | font=font, 64 | fill="red", 65 | align="right", 66 | ) 67 | draw.multiline_text( 68 | (text_margin_right, height - text_height + font_size * 1.0), 69 | steps, 70 | font=font, 71 | fill="black", 72 | align="left", 73 | ) 74 | 75 | # paste QR code 76 | image.paste( 77 | qr_img.resize((qr_final_size, qr_final_size), resample=Image.NEAREST), 78 | (qr_padding, qr_padding), 79 | ) 80 | 81 | # paste black and white logo 82 | # logo_bw_path = pkg_resources.resource_filename('mailadm', 'data/delta-chat-bw.png') 83 | # logo_img = Image.open(logo_bw_path) 84 | # logo = logo_img.resize((logo_width, logo_width), resample=Image.NEAREST) 85 | # image.paste(logo, (0, qr_final_size + qr_padding), mask=logo) 86 | 87 | # red background delta logo 88 | logo2_img = Image.open(logo_red_path) 89 | logo2_width = int(size / 6) 90 | logo2 = logo2_img.resize((logo2_width, logo2_width), resample=Image.NEAREST) 91 | pos = int((size / 2) - (logo2_width / 2)) 92 | image.paste(logo2, (pos, pos), mask=logo2) 93 | 94 | return image 95 | -------------------------------------------------------------------------------- /tests/test_db.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from mailadm.conn import DBError, TokenExhaustedError, UserNotFoundError 3 | from mailadm.util import gen_password 4 | 5 | 6 | def test_token(tmpdir, make_db): 7 | db = make_db(tmpdir) 8 | with db._get_connection(closing=True, write=True) as conn: 9 | assert not conn.get_token_list() 10 | conn.add_token(name="pytest:1w", prefix="xyz", expiry="1w", maxuse=5, token="1234567890123") 11 | conn.commit() 12 | with db._get_connection(closing=True) as conn: 13 | assert len(conn.get_token_list()) == 1 14 | entry = conn.get_tokeninfo_by_name("pytest:1w") 15 | assert entry.expiry == "1w" 16 | assert entry.prefix == "xyz" 17 | assert entry.usecount == 0 18 | 19 | entry = conn.get_tokeninfo_by_token("1234567890123") 20 | assert entry.name == "pytest:1w" 21 | assert entry.expiry == "1w" 22 | assert entry.prefix == "xyz" 23 | assert entry.maxuse == 5 24 | 25 | with db.write_transaction() as conn: 26 | assert conn.get_token_list() 27 | conn.del_token(name="pytest:1w") 28 | assert not conn.get_token_list() 29 | 30 | 31 | class TestTokenAccounts: 32 | MAXUSE = 10 33 | 34 | @pytest.fixture 35 | def conn(self, tmpdir, make_db): 36 | db = make_db(tmpdir.mkdir("conn")) 37 | conn = db._get_connection(write=True) 38 | conn.add_token( 39 | name="pytest:1h", 40 | prefix="xyz", 41 | expiry="1h", 42 | maxuse=self.MAXUSE, 43 | token="123456789012345", 44 | ) 45 | conn.commit() 46 | return conn 47 | 48 | def test_add_with_wrong_token(self, conn, mailcow_domain): 49 | now = 10000 50 | addr = "tmp.123@" + mailcow_domain 51 | with pytest.raises(DBError): 52 | conn.add_user_db(addr=addr, date=now, ttl=60 * 60, token_name="112l3kj123123") 53 | 54 | def test_add_maxuse(self, conn, mailcow_domain): 55 | now = 10000 56 | password = gen_password() 57 | for i in range(self.MAXUSE): 58 | addr = "tmp.%s@%s" % (i, mailcow_domain) 59 | conn.add_user_db(addr=addr, date=now, ttl=60 * 60, token_name="pytest:1h") 60 | 61 | token_info = conn.get_tokeninfo_by_name("pytest:1h") 62 | with pytest.raises(TokenExhaustedError): 63 | conn.add_email_account(token_info, addr="tmp.xx@" + mailcow_domain, password=password) 64 | 65 | def test_add_expire_del(self, conn, mailcow_domain): 66 | now = 10000 67 | addr = "tmp.123@" + mailcow_domain 68 | addr2 = "tmp.456@" + mailcow_domain 69 | addr3 = "tmp.789@" + mailcow_domain 70 | conn.add_user_db(addr=addr, date=now, ttl=60 * 60, token_name="pytest:1h") 71 | with pytest.raises(DBError): 72 | conn.add_user_db(addr=addr, date=now, ttl=60 * 60, token_name="pytest:1h") 73 | conn.add_user_db(addr=addr2, date=now, ttl=30 * 60, token_name="pytest:1h") 74 | conn.add_user_db(addr=addr3, date=now, ttl=32 * 60, token_name="pytest:1h") 75 | conn.commit() 76 | expired = conn.get_expired_users(sysdate=now + 31 * 60) 77 | assert len(expired) == 1 78 | assert expired[0].addr == addr2 79 | 80 | users = conn.get_user_list(token="pytest:1h") 81 | assert len(users) == 3 82 | conn.del_user_db(addr2) 83 | conn.commit() 84 | assert len(conn.get_user_list(token="pytest:1h")) == 2 85 | addrs = [u.addr for u in conn.get_user_list(token="pytest:1h")] 86 | assert addrs == [addr, addr3] 87 | with pytest.raises(UserNotFoundError): 88 | conn.del_user_db(addr2) 89 | assert conn.get_tokeninfo_by_name("pytest:1h").usecount == 3 90 | -------------------------------------------------------------------------------- /tests/test_web.py: -------------------------------------------------------------------------------- 1 | import random 2 | import time 3 | 4 | import mailadm 5 | from mailadm.web import create_app_from_db_path 6 | 7 | 8 | def test_new_user_random(request, db, monkeypatch, mailcow, mailcow_domain): 9 | token = "12319831923123" 10 | with db.write_transaction() as conn: 11 | conn.add_token(name="pytest", token=token, prefix="pytest.", expiry="1w") 12 | 13 | app = create_app_from_db_path(db.path) 14 | app.debug = True 15 | app = app.test_client() 16 | 17 | r = app.post("/?username=hello") 18 | assert r.status_code == 403 19 | assert r.json.get("reason") == "?t (token) parameter not specified" 20 | r = app.post("/?t=00000") 21 | assert r.status_code == 403 22 | assert r.json.get("reason") == "token 00000 is invalid" 23 | r = app.post("/?t=123123&username=hello") 24 | assert r.status_code == 403 25 | assert r.json.get("reason") == "token 123123 is invalid" 26 | 27 | # delete a@x.testrun.org and b@x.testrun.org in case earlier tests failed to clean them up 28 | user_a = "pytest.a@" + mailcow_domain 29 | user_b = "pytest.b@" + mailcow_domain 30 | 31 | def clean_up_test_users(): 32 | mailcow.del_user_mailcow(user_a) 33 | mailcow.del_user_mailcow(user_b) 34 | 35 | request.addfinalizer(clean_up_test_users) 36 | 37 | chars = list("ab") 38 | 39 | def get_human_readable_id(*args, **kwargs): 40 | return random.choice(chars) 41 | 42 | monkeypatch.setattr(mailadm.util, "get_human_readable_id", get_human_readable_id) 43 | 44 | r = app.post("/?t=" + token) 45 | assert r.status_code == 200 46 | assert r.json["email"].endswith(mailcow_domain) 47 | assert r.json["password"] 48 | email = r.json["email"] 49 | assert email in [user_a, user_b] 50 | 51 | r2 = app.post("/?t=" + token) 52 | assert r2.status_code == 200 53 | assert r2.json["email"] != email 54 | assert r2.json["email"] in [user_a, user_b] 55 | 56 | r3 = app.post("/?t=" + token) 57 | assert r3.status_code == 409 58 | assert r3.json.get("reason") == "user already exists in mailcow" 59 | 60 | mailcow.del_user_mailcow(email) 61 | mailcow.del_user_mailcow(r2.json["email"]) 62 | 63 | 64 | def test_env(db, monkeypatch): 65 | monkeypatch.setenv("MAILADM_DB", str(db.path)) 66 | from mailadm.app import app 67 | 68 | assert app.db.path == db.path 69 | 70 | 71 | def test_user_in_db(db, mailcow): 72 | with db.write_transaction() as conn: 73 | token = conn.add_token("pytest:web", expiry="1w", token="1w_7wDioPeeXyZx96v", prefix="") 74 | app = create_app_from_db_path(db.path) 75 | app.debug = True 76 | app = app.test_client() 77 | 78 | r = app.post("/?t=" + token.token) 79 | assert r.status_code == 200 80 | assert r.json["password"] 81 | addr = r.json["email"] 82 | 83 | assert mailcow.get_user(addr) 84 | with db.read_connection() as conn: 85 | assert conn.get_user_by_addr(addr) 86 | 87 | with db.write_transaction() as conn: 88 | conn.delete_email_account(addr) 89 | 90 | 91 | # we used to allow setting the username/password through the web 92 | # but the code has been removed, let's keep the test around 93 | def xxxtest_new_user_usermod(db, mailcow_domain): 94 | app = create_app_from_db_path(db.path) 95 | app.debug = True 96 | app = app.test_client() 97 | 98 | r = app.post("/?t=00000") 99 | assert r.status_code == 403 100 | 101 | r = app.post("/?t=123123123123123&username=hello") 102 | assert r.status_code == 200 103 | 104 | assert r.json["email"] == "hello@" + mailcow_domain 105 | assert len(r.json["password"]) >= 12 106 | 107 | now = time.time() 108 | r = app.post("/?t=123123123123123&username=hello2&password=l123123123123") 109 | assert r.status_code == 200 110 | assert r.json["email"] == "hello2@" + mailcow_domain 111 | assert r.json["password"] == "l123123123123" 112 | assert int(r.json["expires"]) > (now + 4 * 24 * 60 * 60) 113 | -------------------------------------------------------------------------------- /src/mailadm/mailcow.py: -------------------------------------------------------------------------------- 1 | from urllib.parse import quote 2 | 3 | import requests as r 4 | 5 | HTTP_TIMEOUT = 5 6 | 7 | 8 | class MailcowConnection: 9 | """Class to manage requests to the mailcow instance. 10 | 11 | :param mailcow_endpoint: the URL to the mailcow API 12 | :param mailcow_token: the access token to the mailcow API 13 | """ 14 | 15 | def __init__(self, mailcow_endpoint, mailcow_token): 16 | self.mailcow_endpoint = mailcow_endpoint 17 | self.auth = {"X-API-Key": mailcow_token} 18 | 19 | def add_user_mailcow(self, addr, password, token, quota=0): 20 | """HTTP Request to add a user to the mailcow instance. 21 | 22 | :param addr: the email address of the new account 23 | :param password: the password of the new account 24 | :param token: the mailadm token used for account creation 25 | :param quota: the maximum mailbox storage in MB. default: unlimited 26 | """ 27 | url = self.mailcow_endpoint + "add/mailbox" 28 | payload = { 29 | "local_part": addr.split("@")[0], 30 | "domain": addr.split("@")[1], 31 | "quota": quota, 32 | "password": password, 33 | "password2": password, 34 | "active": True, 35 | "force_pw_update": False, 36 | "tls_enforce_in": False, 37 | "tls_enforce_out": False, 38 | "tags": ["mailadm:" + token], 39 | } 40 | result = r.post(url, json=payload, headers=self.auth, timeout=HTTP_TIMEOUT) 41 | if not isinstance(result.json(), list) or result.json()[0].get("type") != "success": 42 | raise MailcowError(result.json()) 43 | 44 | def del_user_mailcow(self, addr): 45 | """HTTP Request to delete a user from the mailcow instance. 46 | 47 | :param addr: the email account to be deleted 48 | """ 49 | url = self.mailcow_endpoint + "delete/mailbox" 50 | result = r.post(url, json=[addr], headers=self.auth, timeout=HTTP_TIMEOUT) 51 | json = result.json() 52 | if not isinstance(json, list) or json[0].get("type" != "success"): 53 | raise MailcowError(json) 54 | 55 | def get_user(self, addr): 56 | """HTTP Request to get a specific mailcow user (not only mailadm-generated ones).""" 57 | url = self.mailcow_endpoint + "get/mailbox/" + quote(addr, safe="") 58 | result = r.get(url, headers=self.auth, timeout=HTTP_TIMEOUT) 59 | json = result.json() 60 | if json == {}: 61 | return None 62 | if isinstance(json, dict): 63 | if json.get("type") == "error": 64 | raise MailcowError(json) 65 | return MailcowUser(json) 66 | # some mailcow versions return all users, even if you only ask for a specific one: 67 | if isinstance(json, list): 68 | for user in [MailcowUser(user) for user in json]: 69 | if user.addr == addr: 70 | return user 71 | 72 | def get_user_list(self): 73 | """HTTP Request to get all mailcow users (not only mailadm-generated ones).""" 74 | url = self.mailcow_endpoint + "get/mailbox/all" 75 | 76 | # Using larger timeout here than for other requests, 77 | # because some mailcow instances may have a large number of users. 78 | result = r.get(url, headers=self.auth, timeout=30) 79 | json = result.json() 80 | if json == {}: 81 | return [] 82 | if isinstance(json, dict): 83 | if json.get("type") == "error": 84 | raise MailcowError(json) 85 | return [MailcowUser(user) for user in json] 86 | 87 | 88 | class MailcowUser(object): 89 | def __init__(self, json): 90 | self.addr = json.get("username") 91 | self.quota = json.get("quota") 92 | self.last_login = json.get("last_imap_login") 93 | for tag in json.get("tags", []): 94 | if "mailadm:" in tag: 95 | self.token = tag.removeprefix("mailadm:") 96 | break 97 | 98 | 99 | class MailcowError(Exception): 100 | """This is thrown if a Mailcow operation fails.""" 101 | -------------------------------------------------------------------------------- /scripts/release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import pathlib 5 | import re 6 | import subprocess 7 | from argparse import ArgumentParser 8 | 9 | rex = re.compile(r'version = (\S+)') 10 | 11 | 12 | def regex_matches(relpath, regex=rex): 13 | p = pathlib.Path(relpath) 14 | assert p.exists() 15 | for line in open(str(p)): 16 | m = regex.match(line) 17 | if m is not None: 18 | return m 19 | 20 | 21 | def read_toml_version(relpath): 22 | res = regex_matches(relpath, rex) 23 | if res is not None: 24 | return res.group(1) 25 | raise ValueError(f"no version found in {relpath}") 26 | 27 | 28 | def replace_toml_version(relpath, newversion): 29 | p = pathlib.Path(relpath) 30 | assert p.exists() 31 | tmp_path = str(p) + "_tmp" 32 | with open(tmp_path, "w") as f: 33 | for line in open(str(p)): 34 | m = rex.match(line) 35 | if m is not None: 36 | print(f"{relpath}: set version={newversion}") 37 | f.write(f'version = {newversion}\n') 38 | else: 39 | f.write(line) 40 | os.rename(tmp_path, str(p)) 41 | 42 | 43 | def main(): 44 | parser = ArgumentParser(prog="set_core_version") 45 | parser.add_argument("newversion") 46 | 47 | toml_list = [ 48 | "setup.cfg", 49 | ] 50 | try: 51 | opts = parser.parse_args() 52 | except SystemExit: 53 | print() 54 | for x in toml_list: 55 | print(f"{x}: {read_toml_version(x)}") 56 | print() 57 | raise SystemExit("need argument: new version, example: 1.25.0") 58 | 59 | newversion = opts.newversion 60 | if newversion.count(".") < 2: 61 | raise SystemExit("need at least two dots in version") 62 | 63 | print(read_toml_version("setup.cfg")) 64 | 65 | if "alpha" not in newversion: 66 | for line in open("CHANGELOG.rst"): 67 | if line.startswith(newversion): 68 | break 69 | else: 70 | raise SystemExit( 71 | f"CHANGELOG.rst contains no entry for version: {newversion}" 72 | ) 73 | 74 | for toml_filename in toml_list: 75 | replace_toml_version(toml_filename, newversion) 76 | 77 | print("adding changes to git index") 78 | if not os.environ.get("MAILCOW_TOKEN"): 79 | print() 80 | choice = input("no MAILCOW_TOKEN environment variable, do you want to skip CI? [Y/n] ") 81 | if choice.lower() == "n": 82 | print() 83 | raise SystemExit("Please provide a MAILCOW_TOKEN environment variable to run CI") 84 | else: 85 | subprocess.call(["tox"]) 86 | 87 | subprocess.call(["git", "add", "-u"]) 88 | print() 89 | print("showing changes:") 90 | print() 91 | subprocess.call(["git", "diff", "--staged"]) 92 | print() 93 | choice = input(f"commit these changes as 'new {newversion} release', tag it, and push it? [y/N] ") 94 | print() 95 | if choice.lower() == "y": 96 | subprocess.call(["git", "commit", "-m", f"'new {newversion} release'"]) 97 | subprocess.call(["git", "tag", "-a", f"{newversion}"]) 98 | subprocess.call(["git", "push", "origin", f"{newversion}"]) 99 | else: 100 | print(f"you can commit the changes yourself with: git commit -m 'new {newversion} release'") 101 | print("after commit, on master make sure to: ") 102 | print() 103 | print(f" git tag -a {newversion}") 104 | print(f" git push origin {newversion}") 105 | print() 106 | 107 | print() 108 | choice = input(f"build the package and upload it to pypi.org? [y/N] ") 109 | print() 110 | if choice.lower() == "y": 111 | subprocess.call(["python3", "setup.py", "sdist"]) 112 | subprocess.call(["twine", "upload", f"dist/mailadm-{newversion}.tar.gz"]) 113 | else: 114 | print("you can build and push to pypi.org with the following commands:") 115 | print() 116 | print(" python3 setup.py sdist") 117 | print(f" twine upload dist/mailadm-{newversion}.tar.gz") 118 | print() 119 | 120 | 121 | if __name__ == "__main__": 122 | main() 123 | -------------------------------------------------------------------------------- /src/mailadm/db.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import logging 3 | import os 4 | import sqlite3 5 | import time 6 | from pathlib import Path 7 | 8 | from .conn import Connection 9 | 10 | 11 | def get_db_path(): 12 | db_path = os.environ.get("MAILADM_DB", "/mailadm/docker-data/mailadm.db") 13 | try: 14 | sqlite3.connect(db_path) 15 | except sqlite3.OperationalError: 16 | raise RuntimeError("mailadm.db not found: MAILADM_DB not set") 17 | return Path(db_path) 18 | 19 | 20 | class DB: 21 | def __init__(self, path, autoinit=True, debug=False): 22 | self.path = path 23 | self.debug = debug 24 | self.ensure_tables() 25 | 26 | def _get_connection(self, write=False, transaction=False, closing=False): 27 | # we let the database serialize all writers at connection time 28 | # to play it very safe (we don't have massive amounts of writes). 29 | mode = "ro" 30 | if write: 31 | mode = "rw" 32 | if not self.path.exists(): 33 | mode = "rwc" 34 | uri = "file:%s?mode=%s" % (self.path, mode) 35 | sqlconn = sqlite3.connect( 36 | uri, 37 | timeout=60, 38 | isolation_level=None if transaction else "DEFERRED", 39 | uri=True, 40 | ) 41 | if self.debug: 42 | sqlconn.set_trace_callback(print) 43 | 44 | # Enable Write-Ahead Logging to avoid readers blocking writers and vice versa. 45 | if write: 46 | sqlconn.execute("PRAGMA journal_mode=wal") 47 | 48 | if transaction: 49 | start_time = time.time() 50 | while 1: 51 | try: 52 | sqlconn.execute("begin immediate") 53 | break 54 | except sqlite3.OperationalError: 55 | # another thread may be writing, give it a chance to finish 56 | time.sleep(0.1) 57 | if time.time() - start_time > 5: 58 | # if it takes this long, something is wrong 59 | raise 60 | conn = Connection(sqlconn, self.path, write=write) 61 | if closing: 62 | conn = contextlib.closing(conn) 63 | return conn 64 | 65 | @contextlib.contextmanager 66 | def write_transaction(self): 67 | conn = self._get_connection(closing=False, write=True, transaction=True) 68 | try: 69 | yield conn 70 | except Exception: 71 | conn.rollback() 72 | conn.close() 73 | raise 74 | else: 75 | conn.commit() 76 | conn.close() 77 | 78 | def write_connection(self, closing=True): 79 | return self._get_connection(closing=closing, write=True) 80 | 81 | def read_connection(self, closing=True): 82 | return self._get_connection(closing=closing, write=False) 83 | 84 | def init_config(self, mail_domain, web_endpoint, mailcow_endpoint, mailcow_token): 85 | with self.write_transaction() as conn: 86 | conn.set_config("mail_domain", mail_domain) 87 | conn.set_config("web_endpoint", web_endpoint) 88 | conn.set_config("mailcow_endpoint", mailcow_endpoint) 89 | conn.set_config("mailcow_token", mailcow_token) 90 | 91 | def is_initialized(self): 92 | with self.read_connection() as conn: 93 | return conn.is_initialized() 94 | 95 | def get_config(self): 96 | with self.read_connection() as conn: 97 | return conn.config 98 | 99 | CURRENT_DBVERSION = 1 100 | 101 | def ensure_tables(self): 102 | with self.read_connection() as conn: 103 | if conn.get_dbversion(): 104 | return 105 | with self.write_transaction() as conn: 106 | logging.info("DB: Creating tables %s", self.path) 107 | 108 | conn.execute( 109 | """ 110 | CREATE TABLE tokens ( 111 | name TEXT PRIMARY KEY, 112 | token TEXT NOT NULL UNIQUE, 113 | expiry TEXT NOT NULL, 114 | prefix TEXT, 115 | maxuse INTEGER default 50, 116 | usecount INTEGER default 0 117 | ) 118 | """, 119 | ) 120 | conn.execute( 121 | """ 122 | CREATE TABLE users ( 123 | addr TEXT PRIMARY KEY, 124 | date INTEGER, 125 | ttl INTEGER, 126 | token_name TEXT NOT NULL, 127 | FOREIGN KEY (token_name) REFERENCES tokens (name) 128 | ) 129 | """, 130 | ) 131 | conn.execute( 132 | """ 133 | CREATE TABLE config ( 134 | name TEXT PRIMARY KEY, 135 | value TEXT 136 | ) 137 | """, 138 | ) 139 | conn.set_config("dbversion", self.CURRENT_DBVERSION) 140 | -------------------------------------------------------------------------------- /doc/_themes/flask_theme_support.py: -------------------------------------------------------------------------------- 1 | # flasky extensions. flasky pygments style based on tango style 2 | from pygments.style import Style 3 | from pygments.token import Keyword, Name, Comment, String, Error, \ 4 | Number, Operator, Generic, Whitespace, Punctuation, Other, Literal 5 | 6 | 7 | class FlaskyStyle(Style): 8 | background_color = "#f8f8f8" 9 | default_style = "" 10 | 11 | styles = { 12 | # No corresponding class for the following: 13 | #Text: "", # class: '' 14 | Whitespace: "underline #f8f8f8", # class: 'w' 15 | Error: "#a40000 border:#ef2929", # class: 'err' 16 | Other: "#000000", # class 'x' 17 | 18 | Comment: "italic #8f5902", # class: 'c' 19 | Comment.Preproc: "noitalic", # class: 'cp' 20 | 21 | Keyword: "bold #004461", # class: 'k' 22 | Keyword.Constant: "bold #004461", # class: 'kc' 23 | Keyword.Declaration: "bold #004461", # class: 'kd' 24 | Keyword.Namespace: "bold #004461", # class: 'kn' 25 | Keyword.Pseudo: "bold #004461", # class: 'kp' 26 | Keyword.Reserved: "bold #004461", # class: 'kr' 27 | Keyword.Type: "bold #004461", # class: 'kt' 28 | 29 | Operator: "#582800", # class: 'o' 30 | Operator.Word: "bold #004461", # class: 'ow' - like keywords 31 | 32 | Punctuation: "bold #000000", # class: 'p' 33 | 34 | # because special names such as Name.Class, Name.Function, etc. 35 | # are not recognized as such later in the parsing, we choose them 36 | # to look the same as ordinary variables. 37 | Name: "#000000", # class: 'n' 38 | Name.Attribute: "#c4a000", # class: 'na' - to be revised 39 | Name.Builtin: "#004461", # class: 'nb' 40 | Name.Builtin.Pseudo: "#3465a4", # class: 'bp' 41 | Name.Class: "#000000", # class: 'nc' - to be revised 42 | Name.Constant: "#000000", # class: 'no' - to be revised 43 | Name.Decorator: "#888", # class: 'nd' - to be revised 44 | Name.Entity: "#ce5c00", # class: 'ni' 45 | Name.Exception: "bold #cc0000", # class: 'ne' 46 | Name.Function: "#000000", # class: 'nf' 47 | Name.Property: "#000000", # class: 'py' 48 | Name.Label: "#f57900", # class: 'nl' 49 | Name.Namespace: "#000000", # class: 'nn' - to be revised 50 | Name.Other: "#000000", # class: 'nx' 51 | Name.Tag: "bold #004461", # class: 'nt' - like a keyword 52 | Name.Variable: "#000000", # class: 'nv' - to be revised 53 | Name.Variable.Class: "#000000", # class: 'vc' - to be revised 54 | Name.Variable.Global: "#000000", # class: 'vg' - to be revised 55 | Name.Variable.Instance: "#000000", # class: 'vi' - to be revised 56 | 57 | Number: "#990000", # class: 'm' 58 | 59 | Literal: "#000000", # class: 'l' 60 | Literal.Date: "#000000", # class: 'ld' 61 | 62 | String: "#4e9a06", # class: 's' 63 | String.Backtick: "#4e9a06", # class: 'sb' 64 | String.Char: "#4e9a06", # class: 'sc' 65 | String.Doc: "italic #8f5902", # class: 'sd' - like a comment 66 | String.Double: "#4e9a06", # class: 's2' 67 | String.Escape: "#4e9a06", # class: 'se' 68 | String.Heredoc: "#4e9a06", # class: 'sh' 69 | String.Interpol: "#4e9a06", # class: 'si' 70 | String.Other: "#4e9a06", # class: 'sx' 71 | String.Regex: "#4e9a06", # class: 'sr' 72 | String.Single: "#4e9a06", # class: 's1' 73 | String.Symbol: "#4e9a06", # class: 'ss' 74 | 75 | Generic: "#000000", # class: 'g' 76 | Generic.Deleted: "#a40000", # class: 'gd' 77 | Generic.Emph: "italic #000000", # class: 'ge' 78 | Generic.Error: "#ef2929", # class: 'gr' 79 | Generic.Heading: "bold #000080", # class: 'gh' 80 | Generic.Inserted: "#00A000", # class: 'gi' 81 | Generic.Output: "#888", # class: 'go' 82 | Generic.Prompt: "#745334", # class: 'gp' 83 | Generic.Strong: "bold #000000", # class: 'gs' 84 | Generic.Subheading: "bold #800080", # class: 'gu' 85 | Generic.Traceback: "bold #a40000", # class: 'gt' 86 | } 87 | -------------------------------------------------------------------------------- /src/mailadm/commands.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from mailadm.conn import DBError 4 | from mailadm.gen_qr import gen_qr 5 | from mailadm.mailcow import MailcowError 6 | from mailadm.util import get_human_readable_id 7 | 8 | 9 | def add_token(db, name, expiry, maxuse, prefix, token) -> dict: 10 | """Adds a token to create users""" 11 | if token is None: 12 | token = expiry + "_" + get_human_readable_id(len=15) 13 | with db.write_transaction() as conn: 14 | try: 15 | info = conn.add_token( 16 | name=name, 17 | token=token, 18 | expiry=expiry, 19 | maxuse=maxuse, 20 | prefix=prefix, 21 | ) 22 | except DBError as e: 23 | return {"status": "error", "message": "failed to add token {}: {}".format(name, e)} 24 | except ValueError: 25 | return {"status": "error", "message": "maxuse must be a number"} 26 | tc = conn.get_tokeninfo_by_name(info.name) 27 | return {"status": "success", "message": dump_token_info(tc)} 28 | 29 | 30 | def add_user(db, token=None, addr=None, password=None, dryrun=False) -> {}: 31 | """Adds a new user to be managed by mailadm""" 32 | with db.write_transaction() as conn: 33 | if token is None: 34 | if "@" not in addr: 35 | # there is probably a more pythonic solution to this. 36 | # the goal is to display the error, whether the command came via CLI or delta bot. 37 | return {"status": "error", "message": "invalid email address: {}".format(addr)} 38 | 39 | token_info = conn.get_tokeninfo_by_addr(addr) 40 | if token_info is None: 41 | return { 42 | "status": "error", 43 | "message": "could not determine token for addr: {!r}".format(addr), 44 | } 45 | else: 46 | token_info = conn.get_tokeninfo_by_name(token) 47 | if token_info is None: 48 | return {"status": "error", "message": "token does not exist: {!r}".format(token)} 49 | try: 50 | user_info = conn.add_email_account(token_info, addr=addr, password=password) 51 | except DBError as e: 52 | return { 53 | "status": "error", 54 | "message": "failed to add e-mail account {}: {}".format(addr, e), 55 | } 56 | except MailcowError as e: 57 | return { 58 | "status": "error", 59 | "message": "failed to add e-mail account {}: {}".format(addr, e), 60 | } 61 | if dryrun: 62 | conn.delete_email_account(user_info.addr) 63 | return {"status": "dryrun", "message": user_info} 64 | return {"status": "success", "message": user_info} 65 | 66 | 67 | def prune(db, dryrun=False) -> {}: 68 | sysdate = int(time.time()) 69 | with db.read_connection() as conn: 70 | expired_users = conn.get_expired_users(sysdate) 71 | if not expired_users: 72 | return {"status": "success", "message": ["nothing to prune"]} 73 | if dryrun: 74 | result = {"status": "dryrun", "message": []} 75 | for user_info in expired_users: 76 | result["message"].append( 77 | "would delete %s (token %s)" % (user_info.addr, user_info.token_name), 78 | ) 79 | else: 80 | result = {"status": "success", "message": []} 81 | for user_info in expired_users: 82 | try: 83 | with db.read_connection() as conn: 84 | conn.get_mailcow_connection().del_user_mailcow(user_info.addr) 85 | with db.write_transaction() as conn: 86 | conn.del_user_db(user_info.addr) 87 | except (DBError, MailcowError) as e: 88 | result["status"] = "error" 89 | result["message"].append("failed to delete account %s: %s" % (user_info.addr, e)) 90 | continue 91 | result["message"].append( 92 | "pruned %s (token %s)" % (user_info.addr, user_info.token_name), 93 | ) 94 | return result 95 | 96 | 97 | def list_tokens(db) -> str: 98 | """Print token info for all tokens""" 99 | output = ["Existing tokens:\n"] 100 | with db.read_connection() as conn: 101 | for name in conn.get_token_list(): 102 | token_info = conn.get_tokeninfo_by_name(name) 103 | output.append(dump_token_info(token_info)) 104 | return "\n".join(output) 105 | 106 | 107 | def qr_from_token(db, tokenname): 108 | with db.read_connection() as conn: 109 | token_info = conn.get_tokeninfo_by_name(tokenname) 110 | config = conn.config 111 | 112 | if token_info is None: 113 | return {"status": "error", "message": "token {!r} does not exist".format(tokenname)} 114 | 115 | image = gen_qr(config, token_info) 116 | fn = "docker-data/dcaccount-%s-%s.png" % (config.mail_domain, token_info.name) 117 | image.save(fn) 118 | return {"status": "success", "filename": fn} 119 | 120 | 121 | def dump_token_info(token_info) -> str: 122 | """Format token info into a string""" 123 | return """token: {} 124 | address prefix: {} 125 | accounts expire after: {} 126 | token was used {} of {} times 127 | token: {} 128 | - url: {} 129 | - QR data: {} 130 | """.format( 131 | token_info.name, 132 | token_info.prefix, 133 | token_info.expiry, 134 | token_info.usecount, 135 | token_info.maxuse, 136 | token_info.token, 137 | token_info.get_web_url(), 138 | token_info.get_qr_uri(), 139 | ) 140 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | VERSION = $(shell python -c "import conf ; print(conf.version)") 5 | DOCZIP = devpi-$(VERSION).doc.zip 6 | # You can set these variables from the command line. 7 | SPHINXOPTS = 8 | SPHINXBUILD = sphinx-build 9 | PAPER = 10 | BUILDDIR = _build 11 | 12 | export HOME=/tmp/home 13 | export TESTHOME=$(HOME) 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | # This variable is not auto generated as the order is important. 23 | USER_MAN_CHAPTERS = commands\ 24 | user\ 25 | indices\ 26 | packages\ 27 | # userman/index.rst\ 28 | # userman/devpi_misc.rst\ 29 | # userman/devpi_concepts.rst\ 30 | 31 | 32 | #export DEVPI_CLIENTDIR=$(CURDIR)/.tmp_devpi_user_man/client 33 | #export DEVPISERVER_SERVERDIR=$(CURDIR)/.tmp_devpi_user_man/server 34 | 35 | chapter = commands 36 | 37 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp \ 38 | epub latex latexpdf text man changes linkcheck doctest gettext install 39 | 40 | help: 41 | @echo "Please use \`make ' where is one of" 42 | @echo " html to make standalone HTML files" 43 | @echo " dirhtml to make HTML files named index.html in directories" 44 | @echo " singlehtml to make a single large HTML file" 45 | @echo " pickle to make pickle files" 46 | @echo " json to make JSON files" 47 | @echo " htmlhelp to make HTML files and a HTML help project" 48 | @echo " qthelp to make HTML files and a qthelp project" 49 | @echo " devhelp to make HTML files and a Devhelp project" 50 | @echo " epub to make an epub" 51 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 52 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 53 | @echo " text to make text files" 54 | @echo " man to make manual pages" 55 | @echo " texinfo to make Texinfo files" 56 | @echo " info to make Texinfo files and run them through makeinfo" 57 | @echo " gettext to make PO message catalogs" 58 | @echo " changes to make an overview of all changed/added/deprecated items" 59 | @echo " linkcheck to check all external links for integrity" 60 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 61 | @echo 62 | 63 | clean: 64 | -rm -rf $(BUILDDIR)/* 65 | 66 | version: 67 | @echo "version $(VERSION)" 68 | 69 | doczip: html 70 | python doczip.py $(DOCZIP) _build/html 71 | 72 | html: 73 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 74 | @echo 75 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 76 | 77 | dirhtml: 78 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 79 | @echo 80 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 81 | 82 | singlehtml: 83 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 84 | @echo 85 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 86 | 87 | pickle: 88 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 89 | @echo 90 | @echo "Build finished; now you can process the pickle files." 91 | 92 | json: 93 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 94 | @echo 95 | @echo "Build finished; now you can process the JSON files." 96 | 97 | htmlhelp: 98 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 99 | @echo 100 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 101 | ".hhp project file in $(BUILDDIR)/htmlhelp." 102 | 103 | qthelp: 104 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 105 | @echo 106 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 107 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 108 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/devpi.qhcp" 109 | @echo "To view the help file:" 110 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/devpi.qhc" 111 | 112 | devhelp: 113 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 114 | @echo 115 | @echo "Build finished." 116 | @echo "To view the help file:" 117 | @echo "# mkdir -p $$HOME/.local/share/devhelp/devpi" 118 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/devpi" 119 | @echo "# devhelp" 120 | 121 | epub: 122 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 123 | @echo 124 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 125 | 126 | latex: 127 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 128 | @echo 129 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 130 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 131 | "(use \`make latexpdf' here to do that automatically)." 132 | 133 | latexpdf: 134 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 135 | @echo "Running LaTeX files through pdflatex..." 136 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 137 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 138 | 139 | text: 140 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 141 | @echo 142 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 143 | 144 | man: 145 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 146 | @echo 147 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 148 | 149 | texinfo: 150 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 151 | @echo 152 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 153 | @echo "Run \`make' in that directory to run these through makeinfo" \ 154 | "(use \`make info' here to do that automatically)." 155 | 156 | info: 157 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 158 | @echo "Running Texinfo files through makeinfo..." 159 | make -C $(BUILDDIR)/texinfo info 160 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 161 | 162 | gettext: 163 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 164 | @echo 165 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 166 | 167 | changes: 168 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 169 | @echo 170 | @echo "The overview file is in $(BUILDDIR)/changes." 171 | 172 | linkcheck: 173 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 174 | @echo 175 | @echo "Link check complete; look for any errors in the above output " \ 176 | "or in $(BUILDDIR)/linkcheck/output.txt." 177 | 178 | doctest: 179 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 180 | @echo "Testing of doctests in the sources finished, look at the " \ 181 | "results in $(BUILDDIR)/doctest/output.txt." 182 | 183 | -------------------------------------------------------------------------------- /doc/_static/sphinxdoc.css: -------------------------------------------------------------------------------- 1 | /* 2 | * sphinxdoc.css_t 3 | * ~~~~~~~~~~~~~~~ 4 | * 5 | * Sphinx stylesheet -- sphinxdoc theme. Originally created by 6 | * Armin Ronacher for Werkzeug. 7 | * 8 | * :copyright: Copyright 2007-2010 by the Sphinx team, see AUTHORS. 9 | * :license: BSD, see LICENSE for details. 10 | * 11 | */ 12 | 13 | @import url("basic.css"); 14 | 15 | /* -- page layout ----------------------------------------------------------- */ 16 | 17 | body { 18 | font-family: 'Lucida Grande', 'Lucida Sans Unicode', 'Geneva', 19 | 'Verdana', sans-serif; 20 | font-size: 1.1em; 21 | letter-spacing: -0.01em; 22 | line-height: 150%; 23 | text-align: center; 24 | background-color: #BFD1D4; 25 | color: black; 26 | padding: 0; 27 | border: 1px solid #aaa; 28 | 29 | margin: 0px 80px 0px 80px; 30 | min-width: 740px; 31 | } 32 | 33 | div.document { 34 | background-color: white; 35 | text-align: left; 36 | background-image: url(contents.png); 37 | background-repeat: repeat-x; 38 | } 39 | 40 | div.bodywrapper { 41 | margin: 0 240px 0 0; 42 | border-right: 1px solid #ccc; 43 | } 44 | 45 | div.body { 46 | margin: 0; 47 | padding: 0.5em 20px 20px 20px; 48 | } 49 | 50 | div.related { 51 | font-size: 0.8em; 52 | } 53 | 54 | div.related ul { 55 | background-image: url(navigation.png); 56 | height: 2em; 57 | border-top: 1px solid #ddd; 58 | border-bottom: 1px solid #ddd; 59 | } 60 | 61 | div.related ul li { 62 | margin: 0; 63 | padding: 0; 64 | height: 2em; 65 | float: left; 66 | } 67 | 68 | div.related ul li.right { 69 | float: right; 70 | margin-right: 5px; 71 | } 72 | 73 | div.related ul li a { 74 | margin: 0; 75 | padding: 0 5px 0 5px; 76 | line-height: 1.75em; 77 | color: #EE9816; 78 | } 79 | 80 | div.related ul li a:hover { 81 | color: #3CA8E7; 82 | } 83 | 84 | div.sphinxsidebarwrapper { 85 | padding: 0; 86 | } 87 | 88 | div.sphinxsidebar { 89 | margin: 0; 90 | padding: 0.5em 15px 15px 0; 91 | width: 210px; 92 | float: right; 93 | font-size: 1em; 94 | text-align: left; 95 | } 96 | 97 | div.sphinxsidebar h3, div.sphinxsidebar h4 { 98 | margin: 1em 0 0.5em 0; 99 | font-size: 1em; 100 | padding: 0.1em 0 0.1em 0.5em; 101 | color: white; 102 | border: 1px solid #86989B; 103 | background-color: #AFC1C4; 104 | } 105 | 106 | div.sphinxsidebar h3 a { 107 | color: white; 108 | } 109 | 110 | div.sphinxsidebar ul { 111 | padding-left: 1.5em; 112 | margin-top: 7px; 113 | padding: 0; 114 | line-height: 130%; 115 | } 116 | 117 | div.sphinxsidebar ul ul { 118 | margin-left: 20px; 119 | } 120 | 121 | div.footer { 122 | background-color: #E3EFF1; 123 | color: #86989B; 124 | padding: 3px 8px 3px 0; 125 | clear: both; 126 | font-size: 0.8em; 127 | text-align: right; 128 | } 129 | 130 | div.footer a { 131 | color: #86989B; 132 | text-decoration: underline; 133 | } 134 | 135 | /* -- body styles ----------------------------------------------------------- */ 136 | 137 | p { 138 | margin: 0.8em 0 0.5em 0; 139 | } 140 | 141 | a { 142 | color: #CA7900; 143 | text-decoration: none; 144 | } 145 | 146 | a:hover { 147 | color: #2491CF; 148 | } 149 | 150 | div.body a { 151 | text-decoration: underline; 152 | } 153 | 154 | h1 { 155 | margin: 0; 156 | padding: 0.7em 0 0.3em 0; 157 | font-size: 1.5em; 158 | color: #11557C; 159 | } 160 | 161 | h2 { 162 | margin: 1.3em 0 0.2em 0; 163 | font-size: 1.35em; 164 | padding: 0; 165 | } 166 | 167 | h3 { 168 | margin: 1em 0 -0.3em 0; 169 | font-size: 1.2em; 170 | } 171 | 172 | div.body h1 a, div.body h2 a, div.body h3 a, div.body h4 a, div.body h5 a, div.body h6 a { 173 | color: black!important; 174 | } 175 | 176 | h1 a.anchor, h2 a.anchor, h3 a.anchor, h4 a.anchor, h5 a.anchor, h6 a.anchor { 177 | display: none; 178 | margin: 0 0 0 0.3em; 179 | padding: 0 0.2em 0 0.2em; 180 | color: #aaa!important; 181 | } 182 | 183 | h1:hover a.anchor, h2:hover a.anchor, h3:hover a.anchor, h4:hover a.anchor, 184 | h5:hover a.anchor, h6:hover a.anchor { 185 | display: inline; 186 | } 187 | 188 | h1 a.anchor:hover, h2 a.anchor:hover, h3 a.anchor:hover, h4 a.anchor:hover, 189 | h5 a.anchor:hover, h6 a.anchor:hover { 190 | color: #777; 191 | background-color: #eee; 192 | } 193 | 194 | a.headerlink { 195 | color: #c60f0f!important; 196 | font-size: 1em; 197 | margin-left: 6px; 198 | padding: 0 4px 0 4px; 199 | text-decoration: none!important; 200 | } 201 | 202 | a.headerlink:hover { 203 | background-color: #ccc; 204 | color: white!important; 205 | } 206 | 207 | cite, code, tt { 208 | font-family: 'Consolas', 'Deja Vu Sans Mono', 209 | 'Bitstream Vera Sans Mono', monospace; 210 | font-size: 0.95em; 211 | letter-spacing: 0.01em; 212 | } 213 | 214 | tt { 215 | background-color: #f2f2f2; 216 | border-bottom: 1px solid #ddd; 217 | color: #333; 218 | } 219 | 220 | tt.descname, tt.descclassname, tt.xref { 221 | border: 0; 222 | } 223 | 224 | hr { 225 | border: 1px solid #abc; 226 | margin: 2em; 227 | } 228 | 229 | a tt { 230 | border: 0; 231 | color: #CA7900; 232 | } 233 | 234 | a tt:hover { 235 | color: #2491CF; 236 | } 237 | 238 | pre { 239 | font-family: 'Consolas', 'Deja Vu Sans Mono', 240 | 'Bitstream Vera Sans Mono', monospace; 241 | font-size: 0.95em; 242 | letter-spacing: 0.015em; 243 | line-height: 120%; 244 | padding: 0.5em; 245 | border: 1px solid #ccc; 246 | background-color: #f8f8f8; 247 | } 248 | 249 | pre a { 250 | color: inherit; 251 | text-decoration: underline; 252 | } 253 | 254 | td.linenos pre { 255 | padding: 0.5em 0; 256 | } 257 | 258 | div.quotebar { 259 | background-color: #f8f8f8; 260 | max-width: 250px; 261 | float: right; 262 | padding: 2px 7px; 263 | border: 1px solid #ccc; 264 | } 265 | 266 | div.topic { 267 | background-color: #f8f8f8; 268 | } 269 | 270 | table { 271 | border-collapse: collapse; 272 | margin: 0 -0.5em 0 -0.5em; 273 | } 274 | 275 | table td, table th { 276 | padding: 0.2em 0.5em 0.2em 0.5em; 277 | } 278 | 279 | div.admonition, div.warning { 280 | font-size: 0.9em; 281 | margin: 1em 0 1em 0; 282 | border: 1px solid #86989B; 283 | background-color: #f7f7f7; 284 | padding: 0; 285 | } 286 | 287 | div.admonition p, div.warning p { 288 | margin: 0.5em 1em 0.5em 1em; 289 | padding: 0; 290 | } 291 | 292 | div.admonition pre, div.warning pre { 293 | margin: 0.4em 1em 0.4em 1em; 294 | } 295 | 296 | div.admonition p.admonition-title, 297 | div.warning p.admonition-title { 298 | margin: 0; 299 | padding: 0.1em 0 0.1em 0.5em; 300 | color: white; 301 | border-bottom: 1px solid #86989B; 302 | font-weight: bold; 303 | background-color: #AFC1C4; 304 | } 305 | 306 | div.warning { 307 | border: 1px solid #940000; 308 | } 309 | 310 | div.warning p.admonition-title { 311 | background-color: #CF0000; 312 | border-bottom-color: #940000; 313 | } 314 | 315 | div.admonition ul, div.admonition ol, 316 | div.warning ul, div.warning ol { 317 | margin: 0.1em 0.5em 0.5em 3em; 318 | padding: 0; 319 | } 320 | 321 | div.versioninfo { 322 | margin: 1em 0 0 0; 323 | border: 1px solid #ccc; 324 | background-color: #DDEAF0; 325 | padding: 8px; 326 | line-height: 1.3em; 327 | font-size: 0.9em; 328 | } 329 | 330 | .viewcode-back { 331 | font-family: 'Lucida Grande', 'Lucida Sans Unicode', 'Geneva', 332 | 'Verdana', sans-serif; 333 | } 334 | 335 | div.viewcode-block:target { 336 | background-color: #f4debf; 337 | border-top: 1px solid #ac9; 338 | border-bottom: 1px solid #ac9; 339 | } 340 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import collections 2 | import grp 3 | import os 4 | import pwd 5 | import time 6 | from pathlib import Path 7 | from random import randint 8 | 9 | import deltachat 10 | import mailadm.bot 11 | import mailadm.db 12 | import pytest 13 | from _pytest.pytester import LineMatcher 14 | 15 | 16 | @pytest.fixture(autouse=True) 17 | def _nocfg(monkeypatch): 18 | # tests can still set this env var but we want to isolate tests by default 19 | monkeypatch.delenv("MAILADM_DB", raising=False) 20 | 21 | def getpwnam(name): 22 | raise KeyError(name) 23 | 24 | monkeypatch.setattr(pwd, "getpwnam", getpwnam) 25 | 26 | 27 | class ClickRunner: 28 | def __init__(self, main): 29 | from click.testing import CliRunner 30 | 31 | self.runner = CliRunner() 32 | self._main = main 33 | self._rootargs = [] 34 | 35 | def set_basedir(self, account_dir): 36 | self._rootargs.insert(0, "--basedir") 37 | self._rootargs.insert(1, account_dir) 38 | 39 | def run_ok(self, args, fnl=None, input=None): 40 | __tracebackhide__ = True 41 | argv = self._rootargs + args 42 | # we use our nextbackup helper to cache account creation 43 | # unless --no-test-cache is specified 44 | res = self.runner.invoke(self._main, argv, catch_exceptions=False, input=input) 45 | if res.exit_code != 0: 46 | print(res.output) 47 | raise Exception("cmd exited with %d: %s" % (res.exit_code, argv)) 48 | return _perform_match(res.output, fnl) 49 | 50 | def run_fail(self, args, fnl=None, input=None, code=None): 51 | __tracebackhide__ = True 52 | argv = self._rootargs + args 53 | res = self.runner.invoke(self._main, argv, catch_exceptions=False, input=input) 54 | if res.exit_code == 0 or (code is not None and res.exit_code != code): 55 | print(res.output) 56 | raise Exception( 57 | "got exit code {!r}, expected {!r}, output: {}".format( 58 | res.exit_code, 59 | code, 60 | res.output, 61 | ), 62 | ) 63 | return _perform_match(res.output, fnl) 64 | 65 | 66 | def _perform_match(output, fnl): 67 | __tracebackhide__ = True 68 | if fnl: 69 | lm = LineMatcher(output.splitlines()) 70 | lines = [x.strip() for x in fnl.strip().splitlines()] 71 | try: 72 | lm.fnmatch_lines(lines) 73 | except Exception: 74 | print(output) 75 | raise 76 | return output 77 | 78 | 79 | @pytest.fixture 80 | def cmd(): 81 | """invoke a command line subcommand.""" 82 | from mailadm.cmdline import mailadm_main 83 | 84 | return ClickRunner(mailadm_main) 85 | 86 | 87 | @pytest.fixture 88 | def db(tmpdir, make_db): 89 | path = tmpdir.ensure("base", dir=1) 90 | return make_db(path) 91 | 92 | 93 | def prepare_account(addr, mailcow, db_path): 94 | password = mailcow.auth["X-API-Key"] 95 | mailcow.add_user_mailcow(addr, password, "admbot") 96 | ac = deltachat.Account(str(db_path)) 97 | ac.run_account(addr, password) 98 | return ac 99 | 100 | 101 | @pytest.fixture() 102 | def admingroup(admbot, botadmin, db): 103 | admchat = admbot.create_group_chat("admins", [], verified=True) 104 | with db.write_transaction() as conn: 105 | conn.set_config("admingrpid", admchat.id) 106 | botplugin = mailadm.bot.AdmBot(db, admbot) 107 | admbot.add_account_plugin(botplugin) 108 | qr = admchat.get_join_qr() 109 | chat = botadmin.qr_join_chat(qr) 110 | while "added by" not in chat.get_messages()[len(chat.get_messages()) - 1].text: 111 | print(chat.get_messages()[len(chat.get_messages()) - 1].text) 112 | time.sleep(1) 113 | chat.admbot = admbot 114 | chat.botadmin = botadmin 115 | chat.botplugin = botplugin 116 | return chat 117 | 118 | 119 | @pytest.fixture() 120 | def admbot(mailcow, db, tmpdir, mailcow_domain): 121 | addr = "pytest-admbot-%s@%s" % (randint(0, 99999), mailcow_domain) 122 | tmpdir = Path(str(tmpdir)) 123 | admbot_db_path = str(mailadm.bot.get_admbot_db_path(db_path=tmpdir.joinpath("admbot.db"))) 124 | ac = prepare_account(addr, mailcow, admbot_db_path) 125 | ac._evlogger = ac.add_account_plugin(deltachat.events.FFIEventLogger(ac)) 126 | ac.run_account(show_ffi=True) 127 | yield ac 128 | ac.shutdown() 129 | ac.wait_shutdown() 130 | mailcow.del_user_mailcow(addr) 131 | 132 | 133 | @pytest.fixture 134 | def botadmin(mailcow, db, tmpdir, mailcow_domain): 135 | addr = "pytest-admin-%s@%s" % (randint(0, 99999), mailcow_domain) 136 | tmpdir = Path(str(tmpdir)) 137 | db_path = mailadm.bot.get_admbot_db_path(tmpdir.joinpath("botadmin.db")) 138 | ac = prepare_account(addr, mailcow, db_path) 139 | yield ac 140 | ac.shutdown() 141 | ac.wait_shutdown() 142 | mailcow.del_user_mailcow(addr) 143 | 144 | 145 | @pytest.fixture 146 | def supportuser(mailcow, db, tmpdir, mailcow_domain): 147 | addr = "pytest-supportuser-%s@%s" % (randint(0, 99999), mailcow_domain) 148 | tmpdir = Path(str(tmpdir)) 149 | db_path = mailadm.bot.get_admbot_db_path(tmpdir.joinpath("supportuser.db")) 150 | ac = prepare_account(addr, mailcow, db_path) 151 | yield ac 152 | ac.shutdown() 153 | ac.wait_shutdown() 154 | mailcow.del_user_mailcow(addr) 155 | 156 | 157 | @pytest.fixture 158 | def mailcow_endpoint(): 159 | if not os.environ.get("MAILCOW_ENDPOINT"): 160 | pytest.skip("Please set the mailcow API URL with the environment variable MAILCOW_ENDPOINT") 161 | return os.environ.get("MAILCOW_ENDPOINT") 162 | 163 | 164 | @pytest.fixture 165 | def mailcow_auth(): 166 | if not os.environ.get("MAILCOW_TOKEN"): 167 | pytest.skip("Please set a mailcow API Key with the environment variable MAILCOW_TOKEN") 168 | return {"X-API-Key": os.environ.get("MAILCOW_TOKEN")} 169 | 170 | 171 | @pytest.fixture 172 | def mailcow_domain(): 173 | return os.environ.get("MAIL_DOMAIN", "x.testrun.org") 174 | 175 | 176 | @pytest.fixture 177 | def mailcow(db): 178 | with db.read_connection() as conn: 179 | return conn.get_mailcow_connection() 180 | 181 | 182 | @pytest.fixture 183 | def make_db(monkeypatch, mailcow_auth, mailcow_endpoint, mailcow_domain): 184 | def make_db(basedir, init=True): 185 | basedir = Path(str(basedir)) 186 | db_path = basedir.joinpath("mailadm.db") 187 | db = mailadm.db.DB(db_path, debug=True) 188 | if init: 189 | db.init_config( 190 | mail_domain=mailcow_domain, 191 | web_endpoint="https://example.org/new_email", 192 | mailcow_endpoint=mailcow_endpoint, 193 | mailcow_token=mailcow_auth.get("X-API-Key"), 194 | ) 195 | 196 | # re-route all queries for sysfiles to the tmpdir 197 | ttype = collections.namedtuple("pwentry", ["pw_name", "pw_dir", "pw_uid", "pw_gid"]) 198 | 199 | def getpwnam(name): 200 | if name == "vmail": 201 | p = basedir.joinpath("path_vmaildir") 202 | if not p.exists(): 203 | p.mkdir() 204 | elif name == "mailadm": 205 | p = basedir 206 | else: 207 | raise KeyError("don't know user {!r}".format(name)) 208 | return ttype(name, p, 10000, 10000) # uid/gid should play no role for testing 209 | 210 | monkeypatch.setattr(pwd, "getpwnam", getpwnam) 211 | 212 | gtype = collections.namedtuple("grpentry", ["gr_name", "gr_mem"]) 213 | 214 | def getgrnam(name): 215 | if name == "vmail": 216 | gr_mem = ["mailadm"] 217 | else: 218 | gr_mem = [] 219 | return gtype(name, gr_mem) 220 | 221 | monkeypatch.setattr(grp, "getgrnam", getgrnam) 222 | 223 | return db 224 | 225 | return make_db 226 | -------------------------------------------------------------------------------- /tests/test_bot.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import deltachat 4 | import pytest 5 | from deltachat.capi import lib as dclib 6 | 7 | TIMEOUT = 45 8 | 9 | 10 | @pytest.mark.timeout(TIMEOUT) 11 | class TestAdminGroup: 12 | def test_help(self, admingroup): 13 | admingroup.send_text("/help") 14 | while "/add-user" not in admingroup.get_messages()[len(admingroup.get_messages()) - 1].text: 15 | print(admingroup.get_messages()[len(admingroup.get_messages()) - 1].text) 16 | time.sleep(1) 17 | reply = admingroup.get_messages()[len(admingroup.get_messages()) - 1] 18 | assert reply.text.startswith("/add-user addr password token") 19 | 20 | def test_list_tokens(self, admingroup): 21 | command = admingroup.send_text("/list-tokens") 22 | while "Existing" not in admingroup.get_messages()[len(admingroup.get_messages()) - 1].text: 23 | print(admingroup.get_messages()[len(admingroup.get_messages()) - 1].text) 24 | time.sleep(1) 25 | reply = admingroup.get_messages()[len(admingroup.get_messages()) - 1] 26 | assert reply.text.startswith("Existing tokens:") 27 | assert reply.quote == command 28 | 29 | def test_wrong_number_of_arguments(self, admingroup): 30 | command = admingroup.send_text("/add-token pytest") 31 | while "Sorry" not in admingroup.get_messages()[len(admingroup.get_messages()) - 1].text: 32 | print(admingroup.get_messages()[len(admingroup.get_messages()) - 1].text) 33 | time.sleep(1) 34 | reply = admingroup.get_messages()[len(admingroup.get_messages()) - 1] 35 | assert reply.quote == command 36 | print(reply.text) 37 | 38 | @pytest.mark.skip("This test works in real life, but not under test conditions somehow") 39 | def test_check_privileges(self, admingroup): 40 | direct = admingroup.botadmin.create_chat(admingroup.admbot.get_config("addr")) 41 | direct.send_text("/list-tokens") 42 | while "Sorry, I" not in direct.get_messages()[-1].text: 43 | print(direct.get_messages()[-1].text) 44 | time.sleep(0.1) 45 | assert direct.get_messages()[-1].text == "Sorry, I only take commands from the admin group." 46 | 47 | 48 | @pytest.mark.timeout(TIMEOUT * 2) 49 | class TestSupportGroup: 50 | def test_support_group_relaying(self, admingroup, supportuser): 51 | class SupportGroupUserPlugin: 52 | def __init__(self, account, supportuser): 53 | self.account = account 54 | self.account.add_account_plugin(deltachat.events.FFIEventLogger(self.account)) 55 | self.supportuser = supportuser 56 | 57 | @deltachat.account_hookimpl 58 | def ac_incoming_message(self, message: deltachat.Message): 59 | message.create_chat() 60 | 61 | assert len(message.chat.get_contacts()) == 2 62 | assert message.override_sender_name == self.supportuser.get_config("addr") 63 | 64 | if message.text == "Can I ask you a support question?": 65 | message.chat.send_text("I hope the user can't read this") 66 | print("\n botadmin to supportgroup: I hope the user can't read this\n") 67 | reply = deltachat.Message.new_empty(self.account, "text") 68 | reply.set_text("Yes of course you can ask us :)") 69 | reply.quote = message 70 | message.chat.send_msg(reply) 71 | print("\n botadmin to supportgroup: Yes of course you can ask us :)\n") 72 | else: 73 | print("\n botadmin received:", message.text, "\n") 74 | 75 | supportchat = supportuser.create_chat(admingroup.admbot.get_config("addr")) 76 | question = "Can I ask you a support question?" 77 | supportchat.send_text(question) 78 | print("\n supportuser to supportchat: Can I ask you a support question?\n") 79 | admin = admingroup.botadmin 80 | admin.add_account_plugin(SupportGroupUserPlugin(admin, supportuser)) 81 | while len(admin.get_chats()) < 2: 82 | time.sleep(0.1) 83 | # AcceptChatPlugin will send 2 messages to the support group now 84 | support_group_name = supportuser.get_config("addr") + " support group" 85 | for chat in admin.get_chats(): 86 | print(chat.get_name() + str(chat.id)) 87 | supportgroup = next( 88 | filter(lambda chat: chat.get_name() == support_group_name, admin.get_chats()), 89 | ) 90 | while "Yes of" not in supportchat.get_messages()[len(supportchat.get_messages()) - 1].text: 91 | print(supportchat.get_messages()[len(supportchat.get_messages()) - 1].text) 92 | time.sleep(1) 93 | botreply = supportchat.get_messages()[1] 94 | assert botreply.text == "Yes of course you can ask us :)" 95 | supportchat.send_text("Okay, I will think of something :)") 96 | print("\n supportuser to supportchat: Okay, I will think of something :)\n") 97 | while "Okay," not in supportgroup.get_messages()[len(supportgroup.get_messages()) - 1].text: 98 | print(supportchat.get_messages()[len(supportchat.get_messages()) - 1].text) 99 | time.sleep(1) 100 | assert "I hope the user can't read this" not in [ 101 | msg.text for msg in supportchat.get_messages() 102 | ] 103 | 104 | def test_invite_bot_to_group(self, admingroup, supportuser): 105 | botcontact = supportuser.create_contact(admingroup.admbot.get_config("addr")) 106 | false_group = supportuser.create_group_chat("invite bot", [botcontact]) 107 | false_group.send_text("Welcome, bot!") 108 | while "left by %s" % (botcontact.addr,) not in false_group.get_messages()[-1].text: 109 | print(false_group.get_messages()[-1].text) 110 | time.sleep(0.1) 111 | assert len(false_group.get_contacts()) == 1 112 | sorry_message = "Sorry, you can not contact me in groups. Please use a 1:1 chat." 113 | assert false_group.get_messages()[-2].text == sorry_message 114 | 115 | def test_bot_receives_system_message(self, admingroup): 116 | def get_group_chats(account): 117 | group_chats = [] 118 | for chat in ac.get_chats(): 119 | if chat.is_group(): 120 | group_chats.append(chat) 121 | return group_chats 122 | 123 | ac = admingroup.admbot 124 | num_chats = len(get_group_chats(ac)) 125 | # put system message in admbot's INBOX 126 | dev_msg = deltachat.Message.new_empty(ac, "text") 127 | dev_msg.set_text("This shouldn't create a support group") 128 | dclib.dc_add_device_msg(ac._dc_context, bytes("test_device_msg", "ascii"), dev_msg._dc_msg) 129 | # assert that admbot didn't create a support group 130 | assert num_chats == len(get_group_chats(ac)) 131 | 132 | def test_did_bot_create_support_group(self, admingroup, supportuser): 133 | # send first message to support user to test that it isn't seen as support group later 134 | bot = admingroup.admbot 135 | supportchat_bot_side = bot.create_chat(supportuser.get_config("addr")) 136 | supportchat_bot_side.send_text("Your account will expire soon!") 137 | 138 | # create support group 139 | supportchat = supportuser.create_chat(admingroup.admbot.get_config("addr")) 140 | question = "Can I ask you a support question?" 141 | supportchat.send_text(question) 142 | support_group_name = supportuser.get_config("addr") + " support group" 143 | 144 | # wait for supportgroup to be created 145 | while 1: 146 | try: 147 | supportgroup = next( 148 | filter(lambda chat: chat.get_name() == support_group_name, bot.get_chats()), 149 | ) 150 | print(supportgroup.get_messages()[0].get_sender_contact().addr) 151 | except (StopIteration, IndexError): 152 | time.sleep(0.1) 153 | else: 154 | break 155 | print(bot.get_self_contact().addr) 156 | 157 | assert admingroup.botplugin.is_support_group(supportgroup) 158 | assert not admingroup.botplugin.is_support_group(admingroup) 159 | assert not admingroup.botplugin.is_support_group(supportchat_bot_side) 160 | 161 | def test_support_user_help(self, admingroup, supportuser): 162 | supchat = supportuser.create_chat(admingroup.admbot.get_config("addr")) 163 | supchat.send_text("/help") 164 | while "use this chat to talk to the admins." not in supchat.get_messages()[-1].text: 165 | time.sleep(0.1) 166 | supchat.send_text("/list-tokens") 167 | while "Sorry, I" not in supchat.get_messages()[-1].text: 168 | print(supchat.get_messages()[-1].text) 169 | time.sleep(0.1) 170 | assert supchat.get_messages()[-1].text == "Sorry, I only take commands in the admin group." 171 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # mailadm documentation build configuration file, created by 4 | # sphinx-quickstart on Mon Jun 3 16:11:22 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.insert(0, os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # If your documentation needs a minimal Sphinx version, state it here. 24 | #needs_sphinx = '1.0' 25 | 26 | # Add any Sphinx extension module names here, as strings. They can be extensions 27 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 28 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.intersphinx', 'sphinx.ext.todo', 'sphinx.ext.viewcode'] 29 | 30 | # Add any paths that contain templates here, relative to this directory. 31 | templates_path = ['_templates'] 32 | 33 | # The suffix of source filenames. 34 | source_suffix = '.rst' 35 | 36 | # The encoding of source files. 37 | #source_encoding = 'utf-8-sig' 38 | 39 | # The master toctree document. 40 | master_doc = 'index' 41 | 42 | # General information about the project. 43 | project = u'mailadm' 44 | copyright = u'2020, holger krekel and merlinux GmbH see http://merlinux.eu' 45 | 46 | # The version info for the project you're documenting, acts as replacement for 47 | # |version| and |release|, also used in various other places throughout the 48 | # built documents. 49 | # 50 | # The short X.Y version. 51 | version = '1.0' 52 | # The full version, including alpha/beta/rc tags. 53 | release = '1.0.0' 54 | 55 | # The language for content autogenerated by Sphinx. Refer to documentation 56 | # for a list of supported languages. 57 | #language = None 58 | 59 | # There are two options for replacing |today|: either, you set today to some 60 | # non-false value, then it is used: 61 | #today = '' 62 | # Else, today_fmt is used as the format for a strftime call. 63 | #today_fmt = '%B %d, %Y' 64 | 65 | # List of patterns, relative to source directory, that match files and 66 | # directories to ignore when looking for source files. 67 | exclude_patterns = ['sketch', '_build', "attic"] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | sys.path.append(os.path.abspath('_themes')) 93 | html_theme_path = ['_themes'] 94 | 95 | # The theme to use for HTML and HTML Help pages. See the documentation for 96 | # a list of builtin themes. 97 | html_theme = 'flask' 98 | 99 | # Theme options are theme-specific and customize the look and feel of a theme 100 | # further. For a list of options available for each theme, see the 101 | # documentation. 102 | html_theme_options = { 103 | 'index_logo': None 104 | } 105 | 106 | # Add any paths that contain custom themes here, relative to this directory. 107 | #html_theme_path = ["_themes"] 108 | 109 | # The name for this set of Sphinx documents. If None, it defaults to 110 | # " v documentation". 111 | #html_title = None 112 | 113 | # A shorter title for the navigation bar. Default is the same as html_title. 114 | #html_short_title = None 115 | 116 | # The name of an image file (relative to this directory) to place at the top 117 | # of the sidebar. 118 | # html_logo = "_static/logo.svg" 119 | 120 | # The name of an image file (within the static path) to use as favicon of the 121 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 122 | # pixels large. 123 | #html_favicon = None 124 | 125 | # Add any paths that contain custom static files (such as style sheets) here, 126 | # relative to this directory. They are copied after the builtin static files, 127 | # so a file named "default.css" will overwrite the builtin "default.css". 128 | html_static_path = ['_static'] 129 | 130 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 131 | # using the given strftime format. 132 | #html_last_updated_fmt = '%b %d, %Y' 133 | 134 | # If true, SmartyPants will be used to convert quotes and dashes to 135 | # typographically correct entities. 136 | #html_use_smartypants = True 137 | 138 | # Custom sidebar templates, maps document names to template names. 139 | #html_sidebars = {} 140 | 141 | # Additional templates that should be rendered to pages, maps page names to 142 | # template names. 143 | #html_additional_pages = {} 144 | 145 | # If false, no module index is generated. 146 | #html_domain_indices = True 147 | 148 | # If false, no index is generated. 149 | #html_use_index = True 150 | 151 | # If true, the index is split into individual pages for each letter. 152 | #html_split_index = False 153 | 154 | # If true, links to the reST sources are added to the pages. 155 | html_show_sourcelink = False 156 | 157 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 158 | html_show_sphinx = False 159 | 160 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 161 | #html_show_copyright = True 162 | 163 | # If true, an OpenSearch description file will be output, and all pages will 164 | # contain a tag referring to it. The value of this option must be the 165 | # base URL from which the finished HTML is served. 166 | # html_use_opensearch = 'https://doc.mailadm.net' 167 | 168 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 169 | #html_file_suffix = None 170 | 171 | # Output file base name for HTML help builder. 172 | htmlhelp_basename = 'mailadmdoc' 173 | 174 | # -- Options for LaTeX output -------------------------------------------------- 175 | 176 | latex_elements = { 177 | # The paper size ('letterpaper' or 'a4paper'). 178 | #'papersize': 'letterpaper', 179 | 180 | # The font size ('10pt', '11pt' or '12pt'). 181 | #'pointsize': '10pt', 182 | 183 | # Additional stuff for the LaTeX preamble. 184 | #'preamble': '', 185 | } 186 | 187 | # Grouping the document tree into LaTeX files. List of tuples 188 | # (source start file, target name, title, author, documentclass [howto/manual]). 189 | latex_documents = [ 190 | ('index', 'mailadm.tex', u'mailadm Documentation', 191 | u'holger krekel', 'manual'), 192 | ] 193 | 194 | # The name of an image file (relative to this directory) to place at the top of 195 | # the title page. 196 | #latex_logo = None 197 | 198 | # For "manual" documents, if this is true, then toplevel headings are parts, 199 | # not chapters. 200 | #latex_use_parts = False 201 | 202 | # If true, show page references after internal links. 203 | #latex_show_pagerefs = False 204 | 205 | # If true, show URL addresses after external links. 206 | #latex_show_urls = False 207 | 208 | # Documents to append as an appendix to all manuals. 209 | #latex_appendices = [] 210 | 211 | # If false, no module index is generated. 212 | #latex_domain_indices = True 213 | 214 | 215 | # -- Options for manual page output -------------------------------------------- 216 | 217 | # One entry per manual page. List of tuples 218 | # (source start file, name, description, authors, manual section). 219 | man_pages = [ 220 | ('index', 'mailadm', u'mailadm Documentation', 221 | [u'holger krekel'], 1) 222 | ] 223 | 224 | # If true, show URL addresses after external links. 225 | #man_show_urls = False 226 | 227 | 228 | # -- Options for Texinfo output ------------------------------------------------ 229 | 230 | # Grouping the document tree into Texinfo files. List of tuples 231 | # (source start file, target name, title, author, 232 | # dir menu entry, description, category) 233 | texinfo_documents = [ 234 | ('index', 'mailadm', u'mailadm Documentation', 235 | u'holger krekel', 'mailadm', 'One line description of project.', 236 | 'Miscellaneous'), 237 | ] 238 | 239 | # Documents to append as an appendix to all manuals. 240 | #texinfo_appendices = [] 241 | 242 | # If false, no module index is generated. 243 | #texinfo_domain_indices = True 244 | 245 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 246 | #texinfo_show_urls = 'footnote' 247 | 248 | 249 | # Example configuration for intersphinx: refer to the Python standard library. 250 | intersphinx_mapping = {'http://docs.python.org/': None} 251 | -------------------------------------------------------------------------------- /tests/test_conn.py: -------------------------------------------------------------------------------- 1 | import time 2 | from random import randint 3 | 4 | import deltachat 5 | import mailadm 6 | import pytest 7 | import requests 8 | from mailadm.conn import DBError, InvalidInputError 9 | from mailadm.mailcow import MailcowError 10 | from mailadm.util import parse_expiry_code 11 | 12 | 13 | @pytest.fixture 14 | def conn(db): 15 | with db.write_transaction() as conn: 16 | yield conn 17 | 18 | 19 | def test_token_twice(conn): 20 | conn.add_token("pytest:burner1", expiry="1w", token="1w_7wDioPeeXyZx96v3", prefix="pp") 21 | with pytest.raises(DBError): 22 | conn.add_token("pytest:burner2", expiry="1w", token="1w_7wDioPeeXyZx96v3", prefix="xp") 23 | 24 | 25 | def test_add_del_user(conn, mailcow): 26 | token = conn.add_token("pytest:burner1", expiry="1w", token="1w_7wDioPeeXyZx96v3", prefix="pp") 27 | usecount = conn.get_tokeninfo_by_name(token.name).usecount 28 | addr = conn.add_email_account_tries(token, tries=10).addr 29 | assert usecount + 1 == conn.get_tokeninfo_by_name(token.name).usecount 30 | 31 | assert mailcow.get_user(addr) 32 | assert conn.get_user_by_addr(addr) 33 | 34 | conn.delete_email_account(addr) 35 | 36 | with pytest.raises(TypeError): 37 | conn.get_user_by_addr(addr) 38 | assert not mailcow.get_user(addr) 39 | 40 | 41 | def test_token_info(conn): 42 | conn.add_token("pytest:burner1", expiry="1w", token="1w_7wDioPeeXyZx96v3", prefix="pp") 43 | conn.add_token("pytest:burner2", expiry="10w", token="10w_7wDioPeeXyZx96v3", prefix="xp") 44 | 45 | assert conn.get_tokeninfo_by_token("1w_7wDio111111") is None 46 | ti = conn.get_tokeninfo_by_token("1w_7wDioPeeXyZx96v3") 47 | assert ti.expiry == "1w" 48 | assert ti.prefix == "pp" 49 | assert ti.name == "pytest:burner1" 50 | conn.del_token("pytest:burner2") 51 | assert not conn.get_tokeninfo_by_token("10w_7wDioPeeXyZx96v3") 52 | assert not conn.get_tokeninfo_by_name("pytest:burner2") 53 | 54 | 55 | @pytest.mark.parametrize( 56 | ("localpart", "result"), 57 | [ 58 | ("weirdinput.{}@test1@", False), 59 | ("weirdinput.{}@a", False), 60 | ("weirdinput.{}@x.testrun.org@test4@", False), 61 | ("weirdinput.{}/../../@", False), 62 | ("weirdinput.{}?test6@", False), 63 | ("weirdinput.{}#test7@", False), 64 | ("weirdinput./{}@", False), 65 | ("weirdinput.%2f{}@", False), 66 | ("weirdinput.{}\\test9\\\\@", False), 67 | ("weirdinput-{}@", True), 68 | ("weirdinput.{}", False), 69 | ("weirdinput+{}@", False), 70 | ("¹weirdinput.{}@", False), 71 | ("-weirdinput.{}@", False), 72 | ("_weirdinput.{}@", False), 73 | (".weirdinput.{}@", False), 74 | ("½weirdinput.{}@", False), 75 | ("weirdinput_{}@", True), 76 | ], 77 | ) 78 | def test_is_email_valid(conn, localpart, result, mailcow_domain): 79 | addr = localpart.format(randint(0, 99999)) + mailcow_domain 80 | assert conn.is_valid_email(addr) == result 81 | 82 | 83 | @pytest.mark.parametrize( 84 | ("token_name", "result"), 85 | [ 86 | ("../test", False), 87 | ("../../test", False), 88 | ("../../abc/../test", False), 89 | (".abc/../test/fixed", False), 90 | ("/test/foo", False), 91 | ("./test/baz", False), 92 | (".testbaz", False), 93 | ("test%baz", False), 94 | ("test?secret=asdf", False), 95 | ("test#somewhere", False), 96 | ("baz123-._@", True), 97 | ], 98 | ) 99 | def test_token_sanitization(conn, token_name, result): 100 | if result: 101 | conn.add_token(token_name, expiry="1w", token="1w_7wDioPeeXyZx96va3", prefix="pp") 102 | else: 103 | with pytest.raises(InvalidInputError): 104 | conn.add_token(token_name, expiry="1w", token="1w_7wDioPeeXyZx96va3", prefix="pp") 105 | 106 | 107 | def test_email_tmp_gen(conn, mailcow): 108 | conn.add_token("pytest:burner1", expiry="1w", token="1w_7wDioPeeXyZx96v3", prefix="tmp.") 109 | token_info = conn.get_tokeninfo_by_name("pytest:burner1") 110 | user_info = conn.add_email_account(token_info=token_info) 111 | 112 | assert user_info.token_name == "pytest:burner1" 113 | localpart, domain = user_info.addr.split("@") 114 | assert localpart.startswith("tmp.") 115 | assert domain == conn.config.mail_domain 116 | 117 | username = localpart[4:] 118 | assert len(username) == 5 119 | for c in username: 120 | assert c in "2345789acdefghjkmnpqrstuvwxyz" 121 | 122 | mailcow.del_user_mailcow(user_info.addr) 123 | 124 | 125 | def test_adduser_mailcow_error(db): 126 | """Test that DB doesn't change if mailcow doesn't work""" 127 | with db.write_transaction() as conn: 128 | token_info = conn.add_token( 129 | "pytest:burner1", 130 | expiry="1w", 131 | token="1w_7wDioPeeXyZx96v3", 132 | prefix="tmp.", 133 | maxuse=1, 134 | ) 135 | 136 | with db.write_transaction() as conn: 137 | conn.set_config("mailcow_token", "wrong") 138 | with pytest.raises(MailcowError): 139 | conn.add_email_account(token_info) 140 | 141 | with db.write_transaction() as conn: 142 | token_info = conn.get_tokeninfo_by_name(token_info.name) 143 | token_info.check_exhausted() 144 | assert conn.get_user_list(token=token_info.name) == [] 145 | 146 | 147 | def test_adduser_db_error(conn, monkeypatch, mailcow_domain): 148 | """Test that no mailcow user is created if there is a DB error""" 149 | token_info = conn.add_token( 150 | "pytest:burner1", 151 | expiry="1w", 152 | token="1w_7wDioPeeXyZx96v3", 153 | prefix="tmp.", 154 | ) 155 | addr = "pytest.%s@%s" % (randint(0, 99999), mailcow_domain) 156 | 157 | def add_user_db(*args, **kwargs): 158 | raise DBError 159 | 160 | monkeypatch.setattr(mailadm.conn.Connection, "add_user_db", add_user_db) 161 | 162 | with pytest.raises(DBError): 163 | conn.add_email_account(token_info, addr=addr) 164 | 165 | url = "%sget/mailbox/%s" % (conn.config.mailcow_endpoint, addr) 166 | auth = {"X-API-Key": conn.config.mailcow_token} 167 | result = requests.get(url, headers=auth) 168 | assert result.status_code == 200 169 | if result.json() is not {} and not isinstance(result.json(), list): 170 | for user in result.json(): 171 | assert user["username"] != addr 172 | 173 | 174 | def test_adduser_mailcow_exists(conn, mailcow, mailcow_domain): 175 | """Test that no user is created if Mailcow user already exists""" 176 | token_info = conn.add_token("pytest:burner1", expiry="1w", token="1w_7wDioPeeXyZx", prefix="p.") 177 | addr = "%s@%s" % (randint(0, 99999), mailcow_domain) 178 | 179 | mailcow.add_user_mailcow(addr, "asdf1234", token_info.name) 180 | with pytest.raises(MailcowError): 181 | conn.add_email_account(token_info, addr=addr) 182 | for user in conn.get_user_list(): 183 | assert user.token_name == "created in mailcow" 184 | 185 | mailcow.del_user_mailcow(addr) 186 | 187 | 188 | @pytest.mark.parametrize( 189 | ("expiry", "time_overdue", "time_since_last_login", "to_be_pruned"), 190 | [ 191 | ("1h", "1h", "0s", True), 192 | ("1s", "1s", "0s", True), 193 | ("5w", "2w", "1w", False), 194 | ("2y", "2y", "20d", False), 195 | ("2y", "1d", "30w", True), 196 | ("90d", "30d", "2d", False), 197 | ("30d", "2y", "1w", False), 198 | ], 199 | ) 200 | def test_soft_expiry( 201 | conn, 202 | mailcow_domain, 203 | tmp_db_path, 204 | monkeypatch, 205 | expiry, 206 | time_overdue, 207 | time_since_last_login, 208 | to_be_pruned, 209 | mailcow, 210 | ): 211 | """Test whether a user expires depending on last login""" 212 | to_expire = time.time() - parse_expiry_code(expiry) - parse_expiry_code(time_overdue) 213 | # create an account which is living for longer than its expiry time so far 214 | with monkeypatch.context() as m: 215 | m.setattr(time, "time", lambda: to_expire) 216 | token_info = conn.add_token("pytest:soft", expiry=expiry, token="1w_7wDioPeex", prefix="p.") 217 | addr = "pytest.%s@%s" % (randint(0, 99999), mailcow_domain) 218 | mailadm_user = conn.add_email_account(token_info, addr=addr) 219 | 220 | # login with IMAP 221 | ac = deltachat.Account(tmp_db_path) 222 | ac.run_account(addr=mailadm_user.addr, password=mailadm_user.password) 223 | 224 | # make mailadm think that it's in the future and user hasn't been logged in for a while 225 | future = time.time() + parse_expiry_code(time_since_last_login) 226 | 227 | expired_users = conn.get_expired_users(future) 228 | expired = mailadm_user.addr in [user.addr for user in expired_users] 229 | assert expired == to_be_pruned 230 | 231 | mailcow.del_user_mailcow(addr) 232 | 233 | 234 | def test_delete_user_mailcow_missing(conn, mailcow, mailcow_domain): 235 | """Test if a mailadm user is deleted successfully if mailcow user is already missing""" 236 | token_info = conn.add_token("pytest:burner1", expiry="1w", token="1w_7wDioPeeXyZx", prefix="p.") 237 | addr = "pytest.%s@%s" % (randint(0, 99999), mailcow_domain) 238 | 239 | conn.add_email_account(token_info, addr=addr) 240 | mailcow.del_user_mailcow(addr) 241 | conn.delete_email_account(addr) 242 | 243 | 244 | def test_db_version(conn): 245 | version = conn.get_dbversion() 246 | assert isinstance(version, int) 247 | -------------------------------------------------------------------------------- /tests/test_cmdline.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import os 3 | import time 4 | from random import randint 5 | 6 | import pytest 7 | from mailadm.mailcow import MailcowError 8 | 9 | 10 | @pytest.fixture 11 | def mycmd(cmd, make_db, tmpdir, monkeypatch, mailcow_domain, mailcow_endpoint): 12 | db = make_db(tmpdir.mkdir("mycmd"), init=False) 13 | monkeypatch.setenv("MAILADM_DB", str(db.path)) 14 | monkeypatch.setenv("ADMBOT_DB", str(tmpdir.mkdir("admbot")) + "admbot.db") 15 | cmd.db = db 16 | if not os.environ["MAILCOW_TOKEN"]: 17 | raise KeyError("Please set mailcow API Key with the environment variable MAILCOW_TOKEN") 18 | cmd.run_ok( 19 | [ 20 | "init", 21 | "--mailcow-endpoint", 22 | mailcow_endpoint, 23 | "--mail-domain", 24 | mailcow_domain, 25 | "--web-endpoint", 26 | "https://example.org/new_email", 27 | ], 28 | ) 29 | return cmd 30 | 31 | 32 | def test_bare(cmd): 33 | cmd.run_ok( 34 | [], 35 | """ 36 | *account creation* 37 | """, 38 | ) 39 | 40 | 41 | class TestInitAndInstall: 42 | def test_init(self, cmd, monkeypatch, tmpdir): 43 | monkeypatch.setenv("MAILADM_DB", tmpdir.join("mailadm.db").strpath) 44 | cmd.run_ok( 45 | [ 46 | "init", 47 | "--mailcow-endpoint", 48 | "unfortunately-required", 49 | "--mailcow-token", 50 | "unfortunately-required", 51 | ], 52 | ) 53 | 54 | 55 | class TestConfig: 56 | def test_config_simple(self, mycmd): 57 | mycmd.run_ok( 58 | ["config"], 59 | """ 60 | dbversion* 61 | """, 62 | ) 63 | 64 | 65 | class TestQR: 66 | def test_gen_qr(self, mycmd, tmpdir, monkeypatch, mailcow_domain): 67 | mycmd.run_ok( 68 | ["add-token", "oneweek", "--token=1w_Zeeg1RSOK4e3Nh0V", "--prefix", "", "--expiry=1w"], 69 | ) 70 | mycmd.run_ok( 71 | ["list-tokens"], 72 | """ 73 | *oneweek* 74 | """, 75 | ) 76 | monkeypatch.chdir(tmpdir) 77 | os.system("mkdir docker-data") 78 | mycmd.run_ok( 79 | ["gen-qr", "oneweek"], 80 | """ 81 | *dcaccount-*-oneweek.png* 82 | """, 83 | ) 84 | p = tmpdir.join("docker-data/dcaccount-%s-oneweek.png" % (mailcow_domain,)) 85 | assert p.exists() 86 | 87 | def test_gen_qr_no_token(self, mycmd): 88 | mycmd.run_fail( 89 | ["gen-qr", "notexistingtoken"], 90 | """ 91 | *Error*not* 92 | """, 93 | ) 94 | 95 | 96 | class TestTokens: 97 | def test_uninitialized(self, cmd): 98 | cmd.run_fail( 99 | ["list-tokens"], 100 | """ 101 | *MAILADM_DB not set* 102 | """, 103 | ) 104 | 105 | def test_tokens(self, mycmd): 106 | mycmd.run_ok( 107 | ["add-token", "oneweek", "--token=1w_Zeeg1RSOK4e3Nh0V", "--prefix", "", "--expiry=1w"], 108 | ) 109 | mycmd.run_ok( 110 | ["list-tokens"], 111 | """ 112 | *oneweek* 113 | *https://example.org* 114 | *DCACCOUNT* 115 | """, 116 | ) 117 | 118 | @pytest.mark.parametrize("i", range(3)) 119 | def test_tokens_add(self, mycmd, i): 120 | mycmd.run_ok( 121 | ["add-token", "test1", "--expiry=1d", "--prefix=tmpy."], 122 | """ 123 | *DCACCOUNT*&n=test1 124 | """, 125 | ) 126 | out = mycmd.run_ok( 127 | ["list-tokens"], 128 | """ 129 | *of 50 times* 130 | *DCACCOUNT*&n=test1 131 | """, 132 | ) 133 | for line in out.splitlines(): 134 | parts = line.split(":") 135 | if len(parts) >= 2 and parts[0].strip() == "token": 136 | token = parts[1].strip().replace("_", "") 137 | assert token.isalnum() 138 | break 139 | else: 140 | pytest.fail() 141 | 142 | mycmd.run_ok( 143 | ["del-token", "test1"], 144 | """ 145 | *deleted*test1* 146 | """, 147 | ) 148 | out = mycmd.run_ok(["list-tokens"]) 149 | assert "test1" not in out 150 | 151 | def test_tokens_add_maxuse(self, mycmd): 152 | mycmd.run_ok( 153 | ["add-token", "test1", "--maxuse=10"], 154 | """ 155 | *of 10 times* 156 | *DCACCOUNT*&n=test1 157 | """, 158 | ) 159 | mycmd.run_ok( 160 | ["list-tokens"], 161 | """ 162 | *of 10 times* 163 | *DCACCOUNT*&n=test1 164 | """, 165 | ) 166 | mycmd.run_ok(["mod-token", "--maxuse=1000", "test1"]) 167 | mycmd.run_ok( 168 | ["list-tokens"], 169 | """ 170 | *of 1000 times* 171 | *DCACCOUNT*&n=test1 172 | """, 173 | ) 174 | 175 | 176 | class TestUsers: 177 | def test_adduser_help(self, mycmd): 178 | mycmd.run_ok( 179 | ["add-user", "-h"], 180 | """ 181 | *add*user* 182 | """, 183 | ) 184 | 185 | def test_add_del_user(self, mycmd, mailcow_domain): 186 | mycmd.run_ok(["add-token", "test1", "--expiry=1d", "--prefix", "pytest."]) 187 | addr = "pytest.%s@%s" % (randint(0, 99999), mailcow_domain) 188 | mycmd.run_ok( 189 | ["add-user", addr], 190 | """ 191 | *Created*pytest*@* 192 | """, 193 | ) 194 | mycmd.run_ok( 195 | ["list-users"], 196 | """ 197 | *{addr}* 198 | """.format( 199 | addr=addr, 200 | ), 201 | ) 202 | mycmd.run_fail( 203 | ["add-user", addr], 204 | """ 205 | *failed to add*pytest* account does already exist* 206 | """, 207 | ) 208 | mycmd.run_ok( 209 | ["del-user", addr], 210 | """ 211 | *deleted*pytest*@* 212 | """, 213 | ) 214 | mycmd.run_ok( 215 | ["add-user", addr, "--dryrun"], 216 | """ 217 | *Would create pytest*@* 218 | """, 219 | ) 220 | mycmd.run_fail( 221 | ["del-user", addr], 222 | """ 223 | *failed to delete*pytest*@*does not exist* 224 | """, 225 | ) 226 | 227 | def test_adduser_and_expire(self, mycmd, monkeypatch, mailcow_domain): 228 | mycmd.run_ok(["add-token", "test1", "--expiry=1d", "--prefix", "pytest."]) 229 | addr = "pytest.%s@%s" % (randint(0, 49999), mailcow_domain) 230 | mycmd.run_ok( 231 | ["add-user", addr], 232 | """ 233 | *Created*pytest*@* 234 | """, 235 | ) 236 | 237 | to_expire = time.time() - datetime.timedelta(weeks=1).total_seconds() - 1 238 | 239 | # create an old account that should expire 240 | with monkeypatch.context() as m: 241 | m.setattr(time, "time", lambda: to_expire) 242 | addr2 = "pytest.%s@%s" % (randint(50000, 99999), mailcow_domain) 243 | mycmd.run_ok( 244 | ["add-user", addr2], 245 | """ 246 | *Created*pytest*@* 247 | """, 248 | ) 249 | 250 | out = mycmd.run_ok(["list-users"]) 251 | assert addr2 in out 252 | 253 | mycmd.run_ok(["prune"]) 254 | 255 | out = mycmd.run_ok(["list-users"]) 256 | assert addr in out 257 | assert addr2 not in out 258 | 259 | mycmd.run_ok(["del-user", addr]) 260 | 261 | def test_two_tokens_users(self, mycmd, mailcow_domain): 262 | mycmd.run_ok(["add-token", "test1", "--expiry=1d", "--prefix=tmpy."]) 263 | mycmd.run_ok(["add-token", "test2", "--expiry=1d", "--prefix=tmpx."]) 264 | mycmd.run_fail(["add-user", "x@" + mailcow_domain]) 265 | addr = "tmpy.%s@%s" % (randint(0, 49999), mailcow_domain) 266 | addr2 = "tmpx.%s@%s" % (randint(50000, 99999), mailcow_domain) 267 | mycmd.run_ok(["add-user", addr]) 268 | mycmd.run_ok(["add-user", addr2]) 269 | mycmd.run_ok( 270 | ["list-users"], 271 | """ 272 | tmpy.*test1* 273 | tmpx.*test2* 274 | """, 275 | ) 276 | out = mycmd.run_ok(["list-users", "--token", "test1"]) 277 | assert addr in out 278 | assert addr2 not in out 279 | out = mycmd.run_ok(["list-users", "--token", "test2"]) 280 | assert addr not in out 281 | assert addr2 in out 282 | mycmd.run_ok(["del-user", addr]) 283 | mycmd.run_ok(["del-user", addr2]) 284 | 285 | 286 | class TestSetupBot: 287 | def test_account_already_exists(self, mycmd, mailcow, mailcow_domain): 288 | print(os.environ.items()) 289 | delete_later = True 290 | try: 291 | mailcow.add_user_mailcow("mailadm@" + mailcow_domain, "asdf1234", "pytest") 292 | except MailcowError as e: 293 | if "object_exists" in str(e): 294 | delete_later = False 295 | mycmd.run_fail( 296 | ["setup-bot"], 297 | """ 298 | *mailadm@* already exists; delete the account in mailcow or specify* 299 | """, 300 | ) 301 | if delete_later: 302 | mailcow.del_user_mailcow("mailadm@" + mailcow_domain) 303 | 304 | def test_specify_addr_not_password(self, mycmd): 305 | mycmd.run_fail( 306 | ["setup-bot", "--email", "bot@example.org"], 307 | """ 308 | *You need to provide --password if you want to use an existing account* 309 | """, 310 | ) 311 | 312 | def test_specify_password_not_addr(self, mycmd): 313 | mycmd.run_fail( 314 | ["setup-bot", "--password", "asdf"], 315 | """ 316 | *Please also provide --email to use an email account for the mailadm* 317 | """, 318 | ) 319 | 320 | def test_wrong_credentials(self, mycmd): 321 | mycmd.run_fail( 322 | ["setup-bot", "--email", "bot@testrun.org", "--password", "asdf"], 323 | """ 324 | *Cannot login as "bot@testrun.org". Please check if the email address and the password* 325 | """, 326 | ) 327 | -------------------------------------------------------------------------------- /doc/_themes/flask/static/flasky.css_t: -------------------------------------------------------------------------------- 1 | /* 2 | * flasky.css_t 3 | * ~~~~~~~~~~~~ 4 | * 5 | * :copyright: Copyright 2010 by Armin Ronacher. 6 | * :license: Flask Design License, see LICENSE for details. 7 | */ 8 | 9 | {% set page_width = '1020px' %} 10 | {% set sidebar_width = '220px' %} 11 | /* orange of logo is #d67c29 but we use black for links for now */ 12 | {% set link_color = '#000' %} 13 | {% set link_hover_color = '#000' %} 14 | {% set base_font = 'sans-serif' %} 15 | {% set header_font = 'serif' %} 16 | 17 | @import url("basic.css"); 18 | 19 | /* -- page layout ----------------------------------------------------------- */ 20 | 21 | body { 22 | font-family: {{ base_font }}; 23 | font-size: 17px; 24 | background-color: white; 25 | color: #000; 26 | margin: 0; 27 | padding: 0; 28 | } 29 | 30 | div.document { 31 | width: {{ page_width }}; 32 | margin: 30px auto 0 auto; 33 | } 34 | 35 | div.documentwrapper { 36 | float: left; 37 | width: 100%; 38 | } 39 | 40 | div.bodywrapper { 41 | margin: 0 0 0 {{ sidebar_width }}; 42 | } 43 | 44 | div.sphinxsidebar { 45 | width: {{ sidebar_width }}; 46 | } 47 | 48 | hr { 49 | border: 0; 50 | border-top: 1px solid #B1B4B6; 51 | } 52 | 53 | div.body { 54 | background-color: #ffffff; 55 | color: #3E4349; 56 | padding: 0 30px 0 30px; 57 | } 58 | 59 | img.floatingflask { 60 | padding: 0 0 10px 10px; 61 | float: right; 62 | } 63 | 64 | div.footer { 65 | width: {{ page_width }}; 66 | margin: 20px auto 30px auto; 67 | font-size: 14px; 68 | color: #888; 69 | text-align: right; 70 | } 71 | 72 | div.footer a { 73 | color: #888; 74 | } 75 | 76 | div.related { 77 | display: none; 78 | } 79 | 80 | div.sphinxsidebar a { 81 | color: #444; 82 | text-decoration: none; 83 | border-bottom: 1px dotted #999; 84 | } 85 | 86 | div.sphinxsidebar a:hover { 87 | border-bottom: 1px solid #999; 88 | } 89 | 90 | div.sphinxsidebar { 91 | font-size: 14px; 92 | line-height: 1.5; 93 | } 94 | 95 | div.sphinxsidebarwrapper { 96 | padding: 18px 10px; 97 | } 98 | 99 | div.sphinxsidebarwrapper p.logo { 100 | padding: 0 0 20px 0; 101 | margin: 0; 102 | text-align: center; 103 | } 104 | 105 | div.sphinxsidebar h3, 106 | div.sphinxsidebar h4 { 107 | font-family: {{ header_font }}; 108 | color: #444; 109 | font-size: 24px; 110 | font-weight: normal; 111 | margin: 0 0 5px 0; 112 | padding: 0; 113 | } 114 | 115 | div.sphinxsidebar h4 { 116 | font-size: 20px; 117 | } 118 | 119 | div.sphinxsidebar h3 a { 120 | color: #444; 121 | } 122 | 123 | div.sphinxsidebar p.logo a, 124 | div.sphinxsidebar h3 a, 125 | div.sphinxsidebar p.logo a:hover, 126 | div.sphinxsidebar h3 a:hover { 127 | border: none; 128 | } 129 | 130 | div.sphinxsidebar p { 131 | color: #555; 132 | margin: 10px 0; 133 | } 134 | 135 | div.sphinxsidebar ul { 136 | margin: 10px 0; 137 | padding: 0; 138 | color: #000; 139 | } 140 | 141 | div.sphinxsidebar input { 142 | border: 1px solid #ccc; 143 | font-family: {{ base_font }}; 144 | font-size: 1em; 145 | } 146 | 147 | /* -- body styles ----------------------------------------------------------- */ 148 | 149 | a { 150 | color: {{ link_color }}; 151 | text-decoration: underline; 152 | } 153 | 154 | a:hover { 155 | color: {{ link_hover_color }}; 156 | text-decoration: underline; 157 | } 158 | 159 | a.reference.internal em { 160 | font-style: normal; 161 | } 162 | 163 | div.body h1, 164 | div.body h2, 165 | div.body h3, 166 | div.body h4, 167 | div.body h5, 168 | div.body h6 { 169 | font-family: {{ header_font }}; 170 | font-weight: normal; 171 | margin: 30px 0px 10px 0px; 172 | padding: 0; 173 | } 174 | 175 | {% if theme_index_logo %} 176 | div.indexwrapper h1 { 177 | text-indent: -999999px; 178 | background: url({{ theme_index_logo }}) no-repeat center center; 179 | height: {{ theme_index_logo_height }}; 180 | } 181 | {% else %} 182 | div.indexwrapper div.body h1 { 183 | font-size: 200%; 184 | } 185 | {% endif %} 186 | div.body h1 { margin-top: 0; padding-top: 0; font-size: 240%; } 187 | div.body h2 { font-size: 180%; } 188 | div.body h3 { font-size: 150%; } 189 | div.body h4 { font-size: 130%; } 190 | div.body h5 { font-size: 100%; } 191 | div.body h6 { font-size: 100%; } 192 | 193 | a.headerlink { 194 | color: #ddd; 195 | padding: 0 4px; 196 | text-decoration: none; 197 | } 198 | 199 | a.headerlink:hover { 200 | color: #444; 201 | background: #eaeaea; 202 | } 203 | 204 | div.body p, div.body dd, div.body li { 205 | line-height: 1.4em; 206 | } 207 | 208 | div.admonition { 209 | background: #fafafa; 210 | margin: 20px -30px; 211 | padding: 10px 30px; 212 | border-top: 1px solid #ccc; 213 | border-bottom: 1px solid #ccc; 214 | } 215 | 216 | div.admonition tt.xref, div.admonition a tt { 217 | border-bottom: 1px solid #fafafa; 218 | } 219 | 220 | dd div.admonition { 221 | margin-left: -60px; 222 | padding-left: 60px; 223 | } 224 | 225 | div.admonition p.admonition-title { 226 | font-family: {{ header_font }}; 227 | font-weight: normal; 228 | font-size: 24px; 229 | margin: 0 0 10px 0; 230 | padding: 0; 231 | line-height: 1; 232 | } 233 | 234 | div.admonition p.last { 235 | margin-bottom: 0; 236 | } 237 | 238 | div.highlight { 239 | background-color: white; 240 | } 241 | 242 | dt:target, .highlight { 243 | background: #FAF3E8; 244 | } 245 | 246 | div.note { 247 | background-color: #eee; 248 | border: 1px solid #ccc; 249 | } 250 | 251 | div.seealso { 252 | background-color: #ffc; 253 | border: 1px solid #ff6; 254 | } 255 | 256 | div.topic { 257 | background-color: #eee; 258 | } 259 | 260 | p.admonition-title { 261 | display: inline; 262 | } 263 | 264 | p.admonition-title:after { 265 | content: ":"; 266 | } 267 | 268 | pre, tt, code { 269 | font-family: 'Consolas', 'Menlo', 'Deja Vu Sans Mono', 'Bitstream Vera Sans Mono', monospace; 270 | font-size: 0.9em; 271 | background: #eee; 272 | } 273 | 274 | img.screenshot { 275 | } 276 | 277 | tt.descname, tt.descclassname { 278 | font-size: 0.95em; 279 | } 280 | 281 | tt.descname { 282 | padding-right: 0.08em; 283 | } 284 | 285 | img.screenshot { 286 | -moz-box-shadow: 2px 2px 4px #eee; 287 | -webkit-box-shadow: 2px 2px 4px #eee; 288 | box-shadow: 2px 2px 4px #eee; 289 | } 290 | 291 | table.docutils { 292 | border: 1px solid #888; 293 | -moz-box-shadow: 2px 2px 4px #eee; 294 | -webkit-box-shadow: 2px 2px 4px #eee; 295 | box-shadow: 2px 2px 4px #eee; 296 | } 297 | 298 | table.docutils td, table.docutils th { 299 | border: 1px solid #888; 300 | padding: 0.25em 0.7em; 301 | } 302 | 303 | table.field-list, table.footnote { 304 | border: none; 305 | -moz-box-shadow: none; 306 | -webkit-box-shadow: none; 307 | box-shadow: none; 308 | } 309 | 310 | table.footnote { 311 | margin: 15px 0; 312 | width: 100%; 313 | border: 1px solid #eee; 314 | background: #fdfdfd; 315 | font-size: 0.9em; 316 | } 317 | 318 | table.footnote + table.footnote { 319 | margin-top: -15px; 320 | border-top: none; 321 | } 322 | 323 | table.field-list th { 324 | padding: 0 0.8em 0 0; 325 | } 326 | 327 | table.field-list td { 328 | padding: 0; 329 | } 330 | 331 | table.footnote td.label { 332 | width: 0px; 333 | padding: 0.3em 0 0.3em 0.5em; 334 | } 335 | 336 | table.footnote td { 337 | padding: 0.3em 0.5em; 338 | } 339 | 340 | dl { 341 | margin: 0; 342 | padding: 0; 343 | } 344 | 345 | dl dd { 346 | margin-left: 30px; 347 | } 348 | 349 | blockquote { 350 | margin: 0 0 0 30px; 351 | padding: 0; 352 | } 353 | 354 | ul, ol { 355 | margin: 10px 0 10px 30px; 356 | padding: 0; 357 | } 358 | 359 | pre { 360 | background: #eee; 361 | padding: 7px 30px; 362 | margin: 15px -30px; 363 | line-height: 1.3em; 364 | } 365 | 366 | dl pre, blockquote pre, li pre { 367 | margin-left: -60px; 368 | padding-left: 60px; 369 | } 370 | 371 | dl dl pre { 372 | margin-left: -90px; 373 | padding-left: 90px; 374 | } 375 | 376 | tt { 377 | background-color: #ecf0f3; 378 | color: #222; 379 | /* padding: 1px 2px; */ 380 | } 381 | 382 | tt.xref, a tt { 383 | background-color: #FBFBFB; 384 | border-bottom: 1px solid white; 385 | } 386 | 387 | a.reference { 388 | text-decoration: none; 389 | border-bottom: 1px dotted {{ link_color }}; 390 | } 391 | 392 | a.reference:hover { 393 | border-bottom: 1px solid {{ link_hover_color }}; 394 | } 395 | 396 | a.footnote-reference { 397 | text-decoration: none; 398 | font-size: 0.7em; 399 | vertical-align: top; 400 | border-bottom: 1px dotted {{ link_color }}; 401 | } 402 | 403 | a.footnote-reference:hover { 404 | border-bottom: 1px solid {{ link_hover_color }}; 405 | } 406 | 407 | a:hover tt { 408 | background: #EEE; 409 | } 410 | 411 | 412 | @media screen and (max-width: 870px) { 413 | 414 | div.sphinxsidebar { 415 | display: none; 416 | } 417 | 418 | div.document { 419 | width: 100%; 420 | 421 | } 422 | 423 | div.documentwrapper { 424 | margin-left: 0; 425 | margin-top: 0; 426 | margin-right: 0; 427 | margin-bottom: 0; 428 | } 429 | 430 | div.bodywrapper { 431 | margin-top: 0; 432 | margin-right: 0; 433 | margin-bottom: 0; 434 | margin-left: 0; 435 | } 436 | 437 | ul { 438 | margin-left: 0; 439 | } 440 | 441 | .document { 442 | width: auto; 443 | } 444 | 445 | .footer { 446 | width: auto; 447 | } 448 | 449 | .bodywrapper { 450 | margin: 0; 451 | } 452 | 453 | .footer { 454 | width: auto; 455 | } 456 | 457 | .github { 458 | display: none; 459 | } 460 | 461 | 462 | 463 | } 464 | 465 | 466 | 467 | @media screen and (max-width: 875px) { 468 | 469 | body { 470 | margin: 0; 471 | padding: 20px 30px; 472 | } 473 | 474 | div.documentwrapper { 475 | float: none; 476 | background: white; 477 | } 478 | 479 | div.sphinxsidebar { 480 | display: block; 481 | float: none; 482 | width: 102.5%; 483 | margin: 50px -30px -20px -30px; 484 | padding: 10px 20px; 485 | background: #333; 486 | color: white; 487 | } 488 | 489 | div.sphinxsidebar h3, div.sphinxsidebar h4, div.sphinxsidebar p, 490 | div.sphinxsidebar h3 a, div.sphinxsidebar ul { 491 | color: white; 492 | } 493 | 494 | div.sphinxsidebar a { 495 | color: #aaa; 496 | } 497 | 498 | div.sphinxsidebar p.logo { 499 | display: none; 500 | } 501 | 502 | div.document { 503 | width: 100%; 504 | margin: 0; 505 | } 506 | 507 | div.related { 508 | display: block; 509 | margin: 0; 510 | padding: 10px 0 20px 0; 511 | } 512 | 513 | div.related ul, 514 | div.related ul li { 515 | margin: 0; 516 | padding: 0; 517 | } 518 | 519 | div.footer { 520 | display: none; 521 | } 522 | 523 | div.bodywrapper { 524 | margin: 0; 525 | } 526 | 527 | div.body { 528 | min-height: 0; 529 | padding: 0; 530 | } 531 | 532 | .rtd_doc_footer { 533 | display: none; 534 | } 535 | 536 | .document { 537 | width: auto; 538 | } 539 | 540 | .footer { 541 | width: auto; 542 | } 543 | 544 | .footer { 545 | width: auto; 546 | } 547 | 548 | .github { 549 | display: none; 550 | } 551 | } 552 | 553 | /* misc. */ 554 | 555 | .revsys-inline { 556 | display: none!important; 557 | } 558 | -------------------------------------------------------------------------------- /src/mailadm/bot.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import mimetypes 3 | import os 4 | import sqlite3 5 | import sys 6 | import time 7 | from threading import Event 8 | 9 | import deltachat 10 | from deltachat import account_hookimpl 11 | from deltachat.capi import lib as dclib 12 | 13 | from mailadm.commands import add_token, add_user, list_tokens, prune, qr_from_token 14 | from mailadm.db import DB, get_db_path 15 | 16 | 17 | class SetupPlugin: 18 | def __init__(self, admingrpid): 19 | self.member_added = Event() 20 | self.admingrpid = admingrpid 21 | self.message_sent = Event() 22 | 23 | @account_hookimpl 24 | def ac_member_added(self, chat: deltachat.Chat, contact, actor, message): 25 | if chat.id == self.admingrpid and chat.num_contacts() == 2: 26 | self.member_added.set() 27 | 28 | @account_hookimpl 29 | def ac_message_delivered(self, message: deltachat.Message): 30 | if not message.is_system_message(): 31 | self.message_sent.set() 32 | 33 | 34 | class AdmBot: 35 | def __init__(self, db: DB, account: deltachat.Account): 36 | self.db = db 37 | self.account = account 38 | with self.db.read_connection() as conn: 39 | config = conn.config 40 | self.admingrpid = int(config.admingrpid) 41 | self.admingroup = account.get_chat_by_id(self.admingrpid) 42 | self.mail_domain = config.mail_domain 43 | 44 | @account_hookimpl 45 | def ac_incoming_message(self, message: deltachat.Message): 46 | """This method is called on every incoming message and decides what to do with it.""" 47 | logging.info("new message from %s: %s", message.get_sender_contact().addr, message.text) 48 | if self.is_admin_group_message(message): 49 | if message.text.startswith("/"): 50 | logging.info("%s seems to be a command.", message.text) 51 | self.handle_command(message) 52 | else: 53 | logging.debug("ignoring message, it's just admins discussing in the admin group") 54 | elif self.is_support_group(message.chat): 55 | if message.quote: 56 | if message.quote.get_sender_contact().addr == self.account.get_config("addr"): 57 | self.forward_reply_to_support_user(message) 58 | elif message.text.startswith("/"): 59 | logging.info("ignoring command, it wasn't given in the admin group") 60 | message.chat.send_text("Sorry, I only take commands in the admin group.") 61 | else: 62 | logging.debug("ignoring message, it's just admins discussing in a support group") 63 | else: 64 | chat = message.create_chat() 65 | if chat.is_group(): 66 | logging.info( 67 | "%s added me to a group, I'm leaving it.", 68 | message.get_sender_contact().addr, 69 | ) 70 | chat.send_text("Sorry, you can not contact me in groups. Please use a 1:1 chat.") 71 | chat.remove_contact(self.account.get_self_contact()) # leave group 72 | elif message.text[0:5] == "/help": 73 | chat.send_text("You can use this chat to talk to the admins.") 74 | elif message.text[0] == "/": 75 | logging.info("ignoring command, it wasn't given in the admin group") 76 | chat.send_text("Sorry, I only take commands in the admin group.") 77 | else: 78 | logging.info("forwarding the message to a support group.") 79 | self.forward_to_support_group(message) 80 | 81 | def is_support_group(self, chat: deltachat.Chat) -> bool: 82 | """Checks whether the group was created by the bot.""" 83 | return ( 84 | chat.is_group() 85 | and chat.get_messages()[0].get_sender_contact() == self.account.get_self_contact() 86 | ) 87 | 88 | def forward_to_support_group(self, message: deltachat.Message): 89 | """forward a support request to a support group; create one if it doesn't exist yet.""" 90 | support_user = message.get_sender_contact().addr 91 | admins = self.admingroup.get_contacts() 92 | admins.remove(self.account.get_self_contact()) 93 | group_name = support_user + " support group" 94 | for chat in self.account.get_chats(): 95 | if chat.get_name() == group_name: 96 | support_group = chat 97 | break 98 | else: 99 | logging.info("creating new support group: '%s'", group_name) 100 | support_group = self.account.create_group_chat(group_name, admins) 101 | support_group.set_profile_image("assets/avatar.jpg") 102 | message.set_override_sender_name(support_user) 103 | support_group.send_msg(message) 104 | 105 | def forward_reply_to_support_user(self, message: deltachat.Message): 106 | """an admin replied in a support group; forward their reply to the user.""" 107 | recipient = message.quote.override_sender_name 108 | logging.info("I'm forwarding the admin reply to the support user %s.", recipient) 109 | chat = self.account.create_chat(recipient) 110 | chat.send_msg(message) 111 | 112 | def is_admin_group_message(self, command: deltachat.Message): 113 | """Checks whether the incoming message was in the admin group.""" 114 | if command.chat.is_group() and self.admingrpid == command.chat.id: 115 | if ( 116 | command.chat.is_protected() 117 | and command.is_encrypted() 118 | and int(command.chat.num_contacts()) >= 2 119 | ): 120 | if command.get_sender_contact() in command.chat.get_contacts(): 121 | return True 122 | else: 123 | logging.info( 124 | "%s is not allowed to give commands to mailadm.", 125 | command.get_sender_contact(), 126 | ) 127 | else: 128 | logging.info( 129 | "The admin group is broken. Try `mailadm setup-bot`. Group ID: %s", 130 | str(self.admingrpid), 131 | ) 132 | raise ValueError 133 | else: 134 | return False 135 | 136 | def handle_command(self, message: deltachat.Message): 137 | """execute the command and reply to the admin.""" 138 | arguments = message.text.split(" ") 139 | image_path = None 140 | 141 | if arguments[0] == "/add-token": 142 | text, image_path = self.add_token(arguments) 143 | 144 | elif arguments[0] == "/gen-qr": 145 | text, image_path = self.gen_qr(arguments) 146 | 147 | elif arguments[0] == "/add-user": 148 | text = self.add_user(arguments) 149 | 150 | elif arguments[0] == "/list-users": 151 | text = self.list_users(arguments) 152 | 153 | elif arguments[0] == "/list-tokens": 154 | text = list_tokens(self.db) 155 | 156 | else: 157 | text = ( 158 | "/add-user addr password token\n" 159 | "/add-token name expiry maxuse (prefix)\n" 160 | "/gen-qr token\n" 161 | "/list-users (token)\n" 162 | "/list-tokens" 163 | ) 164 | 165 | if image_path: 166 | msg = deltachat.Message.new_empty(self.account, "image") 167 | mime_type = mimetypes.guess_type(image_path)[0] 168 | msg.set_file(image_path, mime_type) 169 | else: 170 | msg = deltachat.Message.new_empty(self.account, "text") 171 | msg.set_text(text) 172 | msg.quote = message 173 | sent_id = dclib.dc_send_msg(self.account._dc_context, self.admingroup.id, msg._dc_msg) 174 | assert sent_id == msg.id 175 | 176 | def add_token(self, arguments: [str]): 177 | """add a token via bot command""" 178 | if len(arguments) == 4: 179 | arguments.append("") # add empty prefix 180 | if len(arguments) < 4: 181 | result = { 182 | "status": "error", 183 | "message": "Sorry, you need to tell me more precisely what you want. For " 184 | "example:\n\n/add-token oneweek 1w 50\n\nThis would create a token" 185 | " which creates up to 50 accounts which each are valid for one " 186 | "week.", 187 | } 188 | else: 189 | result = add_token( 190 | self.db, 191 | name=arguments[1], 192 | expiry=arguments[2], 193 | maxuse=arguments[3], 194 | prefix=arguments[4], 195 | token=None, 196 | ) 197 | if result["status"] == "error": 198 | return "ERROR: " + result.get("message"), None 199 | text = result.get("message") 200 | fn = qr_from_token(self.db, arguments[1])["filename"] 201 | return text, fn 202 | 203 | def gen_qr(self, arguments: [str]): 204 | """generate a QR code via bot command""" 205 | if len(arguments) != 2: 206 | return "Sorry, which token do you want a QR code for?", None 207 | else: 208 | return "", qr_from_token(self.db, tokenname=arguments[1]).get("filename") 209 | 210 | def add_user(self, arguments: [str]): 211 | """add a user via bot command""" 212 | if len(arguments) < 4: 213 | try: 214 | with self.db.read_connection() as conn: 215 | token_name = conn.get_token_list()[0] 216 | except IndexError: 217 | return "You need to create a token with /add-token first." 218 | result = { 219 | "status": "error", 220 | "message": "Sorry, you need to tell me more precisely what you want. For " 221 | "example:\n\n/add-user test@%s p4$$w0rd %s\n\nThis would " 222 | "create a user with the '%s' token and the password " 223 | "'p4$$w0rd'." % (self.mail_domain, token_name, token_name), 224 | } 225 | else: 226 | result = add_user(self.db, addr=arguments[1], password=arguments[2], token=arguments[3]) 227 | if result.get("status") == "success": 228 | user = result.get("message") 229 | return "successfully created %s with password %s" % (user.addr, user.password) 230 | else: 231 | return result.get("message") 232 | 233 | def list_users(self, arguments: [str]): 234 | """list users per bot command""" 235 | token = arguments[1] if len(arguments) > 1 else None 236 | with self.db.read_connection() as conn: 237 | users = conn.get_user_list(token=token) 238 | lines = ["%s [%s]" % (user.addr, user.token_name) for user in users] 239 | return "\n".join(lines) 240 | 241 | 242 | def get_admbot_db_path(db_path=None): 243 | if not db_path: 244 | db_path = os.environ.get("ADMBOT_DB", "/mailadm/docker-data/admbot.db") 245 | try: 246 | sqlite3.connect(db_path) 247 | except sqlite3.OperationalError: 248 | raise RuntimeError("admbot.db not found: ADMBOT_DB not set") 249 | return db_path 250 | 251 | 252 | def main(mailadm_db, admbot_db_path): 253 | try: 254 | ac = deltachat.Account(admbot_db_path) 255 | with mailadm_db.read_connection() as conn: 256 | if "admingrpid" not in [item[0] for item in conn.get_config_items()]: 257 | # the file=sys.stderr seems to be necessary so the output is shown in `docker logs` 258 | print( 259 | "To complete the mailadm setup, please run: mailadm setup-bot", 260 | file=sys.stderr, 261 | ) 262 | os._exit(1) 263 | displayname = conn.config.mail_domain + " administration" 264 | ac.set_avatar("assets/avatar.jpg") 265 | ac.run_account(account_plugins=[AdmBot(mailadm_db, ac)], show_ffi=True) 266 | ac.set_config("mvbox_move", "1") 267 | ac.set_config("show_emails", "2") 268 | ac.set_config("displayname", displayname) 269 | while 1: 270 | for logmsg in prune(mailadm_db).get("message"): 271 | logging.info("%s", logmsg) 272 | for _second in range(600): 273 | if not ac._event_thread.is_alive(): 274 | logging.error("dc core event thread died, exiting now") 275 | os._exit(1) 276 | time.sleep(1) 277 | except Exception: 278 | logging.exception("bot received an unexpected error, exiting now") 279 | os._exit(1) 280 | 281 | 282 | if __name__ == "__main__": 283 | mailadm_db = DB(get_db_path()) 284 | admbot_db_path = get_admbot_db_path() 285 | main(mailadm_db, admbot_db_path) 286 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | mailadm: managing token-based temporary e-mail accounts 2 | ======================================================== 3 | 4 | `mailadm `_ is automated e-mail account management tooling 5 | for use by `Delta Chat `_. 6 | 7 | The ``mailadm`` command line tool allows to add or remove tokens which are 8 | typically presented to users as QR tokens. This QR code can then be scanned in 9 | the Setup screen from all Delta Chat apps. After scanning the user is asked if 10 | they want to create a temporary account. 11 | 12 | The account creation happens via the mailadm web interface 13 | and creates a random user id (the local part of an e-mail). 14 | 15 | Mailadm keeps all configuration, token and user state in a single 16 | sqlite database. It comes with an example install script that 17 | can be modified for distributions. 18 | 19 | 20 | Quick Start 21 | ----------- 22 | 23 | .. note:: 24 | 25 | To use mailadm, you need admin access to a `Mailcow 26 | `_ instance. You can run mailadm as a docker 27 | container, either on the same machine as mailcow or somewhere else. 28 | 29 | First get a git copy of the mailadm repository and change into it. 30 | 31 | .. code:: bash 32 | 33 | $ git clone https://github.com/deltachat/mailadm 34 | $ cd mailadm 35 | $ mkdir docker-data 36 | 37 | Now you need to configure some environment variables in a file called ``.env``: 38 | 39 | * ``MAIL_DOMAIN``: the domain part of the email addresses your users will have. 40 | * ``WEB_ENDPOINT``: the web endpoint of mailadm; make sure mailadm receives 41 | POST requests at this address. 42 | * ``MAILCOW_ENDPOINT``: the API endpoint of your mailcow instance. 43 | * ``MAILCOW_TOKEN``: the access token for the mailcow API; you can generate it 44 | in the mailcow admin interface. 45 | 46 | In the end, your ``.env`` file should look similar to this: 47 | 48 | .. code:: bash 49 | 50 | MAIL_DOMAIN=example.org 51 | WEB_ENDPOINT=http://mailadm.example.org/new_email 52 | MAILCOW_ENDPOINT=https://mailcow-web.example.org/api/v1/ 53 | MAILCOW_TOKEN=932848-324B2E-787E98-FCA29D-89789A 54 | 55 | Now you can build the docker container: 56 | 57 | .. code:: bash 58 | 59 | $ sudo docker build . -t mailadm-mailcow 60 | 61 | Initialize the database with the configuration from ``.env``: 62 | 63 | .. code:: bash 64 | 65 | $ scripts/mailadm.sh init 66 | 67 | And setup the bot mailadm will use to receive commands and support requests 68 | from your users: 69 | 70 | .. code:: bash 71 | 72 | $ scripts/mailadm.sh setup-bot 73 | 74 | Then you are asked to scan a QR code to join the Admin Group, a verified Delta 75 | Chat group. Anyone in the group issue commands to mailadm via Delta Chat. You 76 | can send "/help" to the group to learn how to use it. 77 | 78 | Now, as everything is configured, we can start the mailadm container for good: 79 | 80 | .. code:: bash 81 | 82 | $ sudo docker run -d -p 3691:3691 --restart=unless-stopped --mount type=bind,source=$PWD/docker-data,target=/mailadm/docker-data --name mailadm mailadm-mailcow gunicorn --timeout 60 -b :3691 -w 4 mailadm.app:app 83 | 84 | .. note:: 85 | 86 | As the web endpoint also transmits passwords, it is highly recommended to 87 | protect the WEB_ENDPOINT with HTTPS, for example through an nginx reverse 88 | proxy. In this case, WEB_ENDPOINT needs to be the outward facing address, 89 | in this example maybe something like 90 | `https://mailadm.example.org/new_email/`. 91 | 92 | First Steps 93 | ----------- 94 | 95 | ``mailadm`` CLI commands are run inside the docker container - that means that 96 | we need to type ``scripts/mailadm.sh`` in front of every 97 | ``mailadm`` command. This can be abbreviated by running 98 | ``alias mailadm="$PWD/scripts/mailadm.sh"`` once, and adding the 99 | line to your ``~/.bashrc``:: 100 | 101 | $ echo "alias mailadm=$PWD/scripts/mailadm.sh" >> ~/.bashrc 102 | 103 | These docs assume that you have this alias configured. 104 | 105 | Adding a First Token and User 106 | +++++++++++++++++++++++++++++ 107 | 108 | You can now add a first token:: 109 | 110 | $ mailadm add-token oneday --expiry 1d --prefix="tmp." 111 | added token 'oneday' 112 | token:oneday 113 | prefix = tmp. 114 | expiry = 1d 115 | maxuse = 50 116 | usecount = 0 117 | token = 1d_r84EW3N8hEKk 118 | http://localhost:3691/new_email?t=1d_r84EW3N8hEKk&n=oneday 119 | DCACCOUNT:http://localhost:3691/new_email?t=1d_r84EW3N8hEKk&n=oneday 120 | 121 | Then we can add a user:: 122 | 123 | $ mailadm add-user --token oneday tmp.12345@example.org 124 | added addr 'tmp.12345@example.org' with token 'oneday' 125 | 126 | .. _testing-the-web-app: 127 | 128 | Testing the Web App 129 | +++++++++++++++++++ 130 | 131 | Let's find out the URL again for creating new users:: 132 | 133 | $ mailadm list-tokens 134 | token:oneday 135 | prefix = tmp. 136 | expiry = 1d 137 | maxuse = 50 138 | usecount = 1 139 | token = 1d_r84EW3N8hEKk 140 | http://localhost:3691/?t=1d_r84EW3N8hEKk&n=oneday 141 | DCACCOUNT:http://localhost:3691/new_email?t=1d_r84EW3N8hEKk&n=oneday 142 | 143 | The second last line is the one we can use with curl:: 144 | 145 | $ curl -X POST 'http://localhost:3691/?t=1d_r84EW3N8hEKk&n=oneday' 146 | {"email":"tmp.km5y5@example.org","expiry":"1d","password":"cg8VL5f0jH2U","ttl":86400} 147 | 148 | We got an e-mail account through the web API, nice. 149 | 150 | Note that we are using a localhost-url whereas in reality your ``WEB_ENDPOINT`` 151 | will be a full https-URL. All in all the architecture looks pretty much like 152 | this:: 153 | 154 | Delta Chat 155 | | 156 | | scans QR code; sends POST request 157 | V 158 | NGINX Reverse Proxy (Let's Encrypt) 159 | | 160 | | proxy_pass 161 | V 162 | gunicorn Python HTTP Server (e.g. in Docker) 163 | | 164 | | executes 165 | V 166 | mailadm web API ------> creates user in mailadm.db 167 | | 168 | | HTTP POST request /api/v1/add/mailbox 169 | V 170 | mailcow API 171 | | 172 | | creates account 173 | V 174 | mailcow user management 175 | 176 | The Bot Interface 177 | ----------------- 178 | 179 | You don't have to login with SSH every time you want to create tokens. You can 180 | also use the bot interface to give commands to mailadm in a verified Delta 181 | group, the "admin group chat". 182 | 183 | During installation, you are asked to scan a QR code to join the Admin Group, a 184 | verified Delta Chat group. Anyone in the group issue commands to mailadm via 185 | Delta Chat. You can send "/help" to the group to learn how to use it. 186 | 187 | Re-Initializing the Admin Group 188 | +++++++++++++++++++++++++++++++ 189 | 190 | If you ever lose access to the Admin Group, or want to change the email account 191 | the bot uses, you can just re-run ``mailadm setup-bot`` to invalidate the old 192 | Admin Group and create a new one. 193 | 194 | By default your bot is called ``mailadm@yourdomain.tld``, but you can use the 195 | ``mailadm setup-bot --email`` command if you want to use a different address. 196 | If you want to use an existing account for the mailadm bot, you can specify 197 | credentials with ``--email`` and ``--password``. If it is an existing email 198 | account, it doesn't need to be on your mailcow server. 199 | 200 | The bot is initialized during installation. If you want to re-setup the bot 201 | account or admin group, you need to stop mailadm first:: 202 | 203 | $ sudo docker stop mailadm 204 | $ mailadm setup-bot 205 | $ sudo docker start mailadm 206 | 207 | QR Code Generation 208 | ++++++++++++++++++ 209 | 210 | Once you have mailadm configured and integrated with 211 | nginx and mailcow, you can generate a QR code:: 212 | 213 | $ mailadm gen-qr oneday 214 | dcaccount-testrun.org-oneday.png written for token 'oneday' 215 | 216 | This creates a .png file with the QR code in the ``docker-data/`` directory. 217 | Now you can download it to your computer with ``scp`` or ``rsync``. 218 | 219 | You can print or hand out this QR code file and people can scan it with 220 | their Delta Chat to get a temporary account which is valid for one day. 221 | 222 | .. _configuration-details: 223 | 224 | Configuration Details 225 | --------------------- 226 | 227 | During setup, but also every time after you changed a config option, you need 228 | to run ``mailadm init`` to apply them, and restart the mailadm process/container. 229 | 230 | ``mailadm init``, saves the configuration in the database. ``mailadm init`` 231 | should be called from inside the docker container. Best practice is to save the 232 | environment variables in a ``.env`` file, and pass it to ``docker run`` with 233 | the ``--env-file .env`` argument. ``mailadm.sh`` script does this for you.:: 234 | 235 | $ mailadm init 236 | 237 | mailadm has 4 config options: 238 | 239 | MAIL_DOMAIN 240 | +++++++++++ 241 | 242 | This is the domain part of the email addresses your mailadm instance creates 243 | later. For addresses like ``tmp.12345@example.org``, your ``MAIL_DOMAIN`` value 244 | in ``.env`` needs to look like:: 245 | 246 | MAIL_DOMAIN=example.org 247 | 248 | WEB_ENDPOINT 249 | ++++++++++++ 250 | 251 | The ``WEB_ENDPOINT`` is used for generating the URLs which are later encoded in 252 | the account creation QR codes. For mailadm to work, it must be reachable with 253 | ``curl -X POST "$WEB_ENDPOINT?t=$TOKEN"`` (see testing-the-web-app_). For 254 | example:: 255 | 256 | WEB_ENDPOINT=http://mailadm.example.org/new_email 257 | 258 | MAILCOW_ENDPOINT 259 | ++++++++++++++++ 260 | 261 | mailadm needs to talk to the mailcow API to create and delete accounts. For 262 | this, add `/api/v1/` to the URL of the mailcow admin interface, e.g.:: 263 | 264 | MAILCOW_ENDPOINT=https://mailcow-web.example.org/api/v1/ 265 | 266 | MAILCOW_TOKEN 267 | +++++++++++++ 268 | 269 | To authenticate with the mailcow API, mailadm needs an API token. You can generate 270 | it in the mailcow admin interface, under "API". Note that you need to allow API access 271 | from the IP address of the server where you're running mailadm, or enable "Skip 272 | IP check for API" to allow API access from everywhere. 273 | 274 | When you have activated the API, you can pass the token to mailadm like this:: 275 | 276 | MAILCOW_TOKEN=932848-324B2E-787E98-FCA29D-89789A 277 | 278 | 279 | Upgrading Mailadm 280 | ----------------- 281 | 282 | You can build the new container like this: 283 | 284 | .. code:: bash 285 | 286 | cd mailadm 287 | git pull origin 1.0.0 # or whatever version you want to upgrade to 288 | sudo docker build . -t mailadm-mailcow 289 | 290 | Then stop and remove the old container: 291 | 292 | .. code:: bash 293 | 294 | sudo docker stop mailadm && sudo docker rm mailadm 295 | 296 | And finally you can start the new container: 297 | 298 | .. code:: bash 299 | 300 | sudo docker run -d -p 3691:3691 --restart=unless-stopped --mount type=bind,source=$PWD/docker-data,target=/mailadm/docker-data --name mailadm mailadm-mailcow gunicorn --timeout 60 -b :3691 -w 4 mailadm.app:app 301 | 302 | That's it :) you can look in the logs if everything is running fine: 303 | 304 | .. code:: bash 305 | 306 | sudo docker logs -ft mailadm 307 | 308 | Setup Development Environment 309 | ----------------------------- 310 | 311 | To setup your development environment, you need to do something like this: 312 | 313 | .. code:: bash 314 | 315 | git clone https://github.com/deltachat/mailadm 316 | python3 -m venv venv 317 | . venv/bin/activate 318 | pip install pytest tox pytest-xdist pytest-timeout pyzbar black ruff 319 | sudo apt install -y libzbar0 320 | pip install . 321 | 322 | With ``tox`` you can run the tests - many of them need access to a mailcow 323 | instance though. If you have access to a mailcow instance, you can pass a 324 | ``MAILCOW_TOKEN``, ``MAIL_DOMAIN``, and ``MAILCOW_ENDPOINT`` via the command 325 | line to run them. 326 | 327 | Mailadm HTTP API 328 | ---------------- 329 | 330 | ``/``, method: ``POST``: Create a temporary account with a specified token. 331 | 332 | Attributes: 333 | 334 | * ``?t=`` a valid mailadm token 335 | 336 | Successful Response:: 337 | 338 | { 339 | "status_code": 200, 340 | "email": "addr@example.org", 341 | "password": "p4$$w0rd", 342 | "expiry": "1h", 343 | "ttl": 3600, 344 | } 345 | 346 | Example for an error:: 347 | 348 | { 349 | "status_code": 403, 350 | "type": "error", 351 | "reason": "?t (token) parameter not specified", 352 | } 353 | 354 | Possible errors: 355 | 356 | .. list-table:: 357 | :widths: 10 90 358 | 359 | * - 403 360 | - ?t (token) parameter not specified 361 | * - 403 362 | - token $t is invalid 363 | * - 409 364 | - user already exists in mailcow 365 | * - 409 366 | - user already exists in mailadm 367 | * - 500 368 | - internal server error, can have different reasons 369 | * - 504 370 | - mailcow not reachable 371 | 372 | Migrating from a pre-mailcow setup 373 | ---------------------------------- 374 | 375 | mailadm used to be built on top of a standard postfix/dovecot setup; with 376 | mailcow many things are simplified. The migration can be a bit tricky though. 377 | 378 | What you need to do: 379 | 380 | * create all existing dovecot accounts in mailcow 381 | * create a master password for dovecot 382 | * do an IMAP sync to migrate the inboxes of all the dovecot accounts to mailcow (see 383 | https://mailcow.github.io/mailcow-dockerized-docs/post_installation/firststeps-sync_jobs_migration/) 384 | * migrate the mailadm database (maybe the ``mailadm migrate-db`` command works 385 | for you; but better make a backup beforehand) 386 | * re-configure mailadm with your mailcow credentials (see configuration-details_) 387 | 388 | If you get ``NOT NULL constraint failed: users.hash_pw`` errors when you try to 389 | create a user, you probably need to migrate your database. You can use 390 | ``scripts/migrate-pre-mailcow-db.py`` for this; it's not well tested though, so 391 | make a backup first and try it out. 392 | 393 | -------------------------------------------------------------------------------- /src/mailadm/conn.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import sqlite3 3 | import time 4 | 5 | import mailadm.util 6 | 7 | from .mailcow import MailcowConnection, MailcowError 8 | 9 | 10 | class DBError(Exception): 11 | """error during an operation on the database.""" 12 | 13 | 14 | class TokenExhaustedError(DBError): 15 | """A token has reached its max-use limit.""" 16 | 17 | 18 | class UserNotFoundError(DBError): 19 | """user not found in database.""" 20 | 21 | 22 | class InvalidInputError(DBError): 23 | """Raised when user-specified input was invalid""" 24 | 25 | 26 | class Connection: 27 | def __init__(self, sqlconn, path, write): 28 | self._sqlconn = sqlconn 29 | self.path_mailadm_db = path 30 | self._write = write 31 | 32 | def log(self, msg): 33 | logging.info("%s", msg) 34 | 35 | def close(self): 36 | self._sqlconn.close() 37 | 38 | def commit(self): 39 | self._sqlconn.commit() 40 | 41 | def rollback(self): 42 | self._sqlconn.rollback() 43 | 44 | def execute(self, query, params=()): 45 | cur = self.cursor() 46 | try: 47 | cur.execute(query, params) 48 | except sqlite3.IntegrityError as e: 49 | raise DBError(e) 50 | return cur 51 | 52 | def cursor(self): 53 | return self._sqlconn.cursor() 54 | 55 | # 56 | # configuration and meta information 57 | # 58 | def get_dbversion(self): 59 | q = "SELECT value from config WHERE name='dbversion'" 60 | c = self._sqlconn.cursor() 61 | try: 62 | return int(c.execute(q).fetchone()[0]) 63 | except sqlite3.OperationalError: 64 | return None 65 | 66 | @property 67 | def config(self): 68 | items = self.get_config_items() 69 | if items: 70 | d = dict(items) 71 | # remove deprecated config keys 72 | try: 73 | del d["vmail_user"] 74 | except KeyError: 75 | pass 76 | try: 77 | del d["path_virtual_mailboxes"] 78 | except KeyError: 79 | pass 80 | return Config(**d) 81 | 82 | def is_initialized(self): 83 | items = self.get_config_items() 84 | return len(items) > 1 85 | 86 | def get_config_items(self): 87 | q = "SELECT name, value from config" 88 | c = self._sqlconn.cursor() 89 | try: 90 | return c.execute(q).fetchall() 91 | except sqlite3.OperationalError: 92 | return None 93 | 94 | def set_config(self, name, value): 95 | ok = [ 96 | "dbversion", 97 | "mail_domain", 98 | "web_endpoint", 99 | "mailcow_endpoint", 100 | "mailcow_token", 101 | "admingrpid", 102 | ] 103 | assert name in ok, name 104 | q = "INSERT OR REPLACE INTO config (name, value) VALUES (?, ?)" 105 | self.cursor().execute(q, (name, value)).fetchone() 106 | return value 107 | 108 | # 109 | # token management 110 | # 111 | 112 | def get_token_list(self): 113 | q = "SELECT name from tokens" 114 | return [x[0] for x in self.execute(q).fetchall()] 115 | 116 | def add_token(self, name, token, expiry, prefix, maxuse=50): 117 | if "/" in name or "#" in name or "?" in name or "%" in name: 118 | raise InvalidInputError("no /, ?, %, or # allowed in the token name") 119 | if name[0] == ".": 120 | raise InvalidInputError("token name can't start with a dot (.)") 121 | q = "INSERT INTO tokens (name, token, prefix, expiry, maxuse) VALUES (?, ?, ?, ?, ?)" 122 | self.execute(q, (name, token, prefix, expiry, int(maxuse))) 123 | self.log("added token {!r}".format(name)) 124 | return self.get_tokeninfo_by_name(name) 125 | 126 | def mod_token(self, name, expiry=None, prefix=None, maxuse=None): 127 | token_info = self.get_tokeninfo_by_name(name) 128 | expiry = expiry if expiry is not None else token_info.expiry 129 | maxuse = maxuse if maxuse is not None else token_info.maxuse 130 | prefix = prefix if prefix is not None else token_info.prefix 131 | q = "REPLACE INTO tokens (name, token, prefix, expiry, maxuse) VALUES (?, ?, ?, ?, ?)" 132 | self.execute(q, (name, token_info.token, prefix, expiry, maxuse)) 133 | self.log("modified token {!r}".format(name)) 134 | return self.get_tokeninfo_by_name(name) 135 | 136 | def del_token(self, name): 137 | q = "DELETE FROM tokens WHERE name=?" 138 | c = self.cursor() 139 | c.execute(q, (name,)) 140 | if c.rowcount == 0: 141 | raise ValueError("token {!r} does not exist".format(name)) 142 | self.log("deleted token {!r}".format(name)) 143 | 144 | def get_tokeninfo_by_name(self, name): 145 | q = TokenInfo._select_token_columns + "WHERE name = ?" 146 | res = self.execute(q, (name,)).fetchone() 147 | if res is not None: 148 | return TokenInfo(self.config, *res) 149 | 150 | def get_tokeninfo_by_token(self, token): 151 | q = TokenInfo._select_token_columns + "WHERE token=?" 152 | res = self.execute(q, (token,)).fetchone() 153 | if res is not None: 154 | return TokenInfo(self.config, *res) 155 | 156 | def get_tokeninfo_by_addr(self, addr): 157 | if not addr.endswith(self.config.mail_domain): 158 | raise ValueError( 159 | "addr {!r} does not use mail domain {!r}".format(addr, self.config.mail_domain), 160 | ) 161 | q = TokenInfo._select_token_columns 162 | for res in self.execute(q).fetchall(): 163 | token_info = TokenInfo(self.config, *res) 164 | if addr.startswith(token_info.prefix): 165 | return token_info 166 | 167 | # 168 | # user management 169 | # 170 | 171 | def add_email_account_tries(self, token_info, addr=None, password=None, tries=1): 172 | """Try to add an email account.""" 173 | for i in range(1, tries + 1): 174 | logging.info("Try %d to create an account", i) 175 | try: 176 | return self.add_email_account(token_info, addr=addr, password=password) 177 | except (MailcowError, DBError): 178 | if i >= tries: 179 | raise 180 | 181 | def add_email_account(self, token_info, addr=None, password=None): 182 | """Add an email account to the mailcow server & mailadm 183 | 184 | :param token_info: the token which authorizes the new user creation 185 | :param addr: email address for the new account; randomly generated if omitted 186 | :param password: password for the new account; randomly generated if omitted 187 | :return: a UserInfo object with the database information about the new user, plus password 188 | """ 189 | token_info.check_exhausted() 190 | if password is None: 191 | password = mailadm.util.gen_password() 192 | if addr is None: 193 | rand_part = mailadm.util.get_human_readable_id() 194 | username = "{}{}".format(token_info.prefix, rand_part) 195 | addr = "{}@{}".format(username, self.config.mail_domain) 196 | 197 | if not self.is_valid_email(addr): 198 | raise InvalidInputError("not a valid email address") 199 | 200 | # first check that mailcow doesn't have a user with that name already: 201 | if self.get_mailcow_connection().get_user(addr): 202 | raise MailcowError("account does already exist") 203 | 204 | self.add_user_db( 205 | addr=addr, 206 | date=int(time.time()), 207 | ttl=token_info.get_expiry_seconds(), 208 | token_name=token_info.name, 209 | ) 210 | 211 | self.log("added addr {!r} with token {!r}".format(addr, token_info.name)) 212 | 213 | user_info = self.get_user_by_addr(addr) 214 | user_info.password = password 215 | 216 | # seems that everything is fine so far, so let's invoke mailcow: 217 | self.get_mailcow_connection().add_user_mailcow(addr, password, token_info.name) 218 | 219 | return user_info 220 | 221 | def delete_email_account(self, addr): 222 | """Delete an email account from the mailcow server & mailadm. 223 | 224 | :param addr: the email address of the account which is to be deleted. 225 | """ 226 | self.get_mailcow_connection().del_user_mailcow(addr) 227 | self.del_user_db(addr) 228 | 229 | def add_user_db(self, addr, date, ttl, token_name): 230 | self.execute("PRAGMA foreign_keys=on;") 231 | 232 | q = """INSERT INTO users (addr, date, ttl, token_name) 233 | VALUES (?, ?, ?, ?)""" 234 | self.execute(q, (addr, date, ttl, token_name)) 235 | self.execute("UPDATE tokens SET usecount = usecount + 1 WHERE name=?", (token_name,)) 236 | 237 | def del_user_db(self, addr): 238 | q = "DELETE FROM users WHERE addr=?" 239 | c = self.execute(q, (addr,)) 240 | if c.rowcount == 0: 241 | raise UserNotFoundError("addr {!r} does not exist".format(addr)) 242 | self.log("deleted user {!r}".format(addr)) 243 | 244 | def is_valid_email(self, addr): 245 | if not addr.endswith("@" + self.config.mail_domain): 246 | logging.error("address %s doesn't end with @%s", addr, self.config.mail_domain) 247 | return False 248 | if not addr.count("@") == 1: 249 | logging.error("address %s doesn't have exactly one @", addr) 250 | return False 251 | if not addr[0].isalnum(): 252 | logging.error("address %s doesn't start alphanumeric", addr) 253 | return False 254 | chars_allowed = "abcdefghijklmnopqrstuvwxyz-ABCDEFGHIJKLMNOPQRSTUVWXYZ.0123456789_@" 255 | for char in addr: 256 | if char not in chars_allowed: 257 | logging.error("letter %s in address %s is not in %s", char, addr, chars_allowed) 258 | return False 259 | return True 260 | 261 | def get_user_by_addr(self, addr): 262 | q = UserInfo._select_user_columns + "WHERE addr = ?" 263 | args = self._sqlconn.execute(q, (addr,)).fetchone() 264 | return UserInfo(*args) 265 | 266 | def get_expired_users(self, sysdate): 267 | q = UserInfo._select_user_columns + "WHERE (date + ttl) < ?" 268 | overdue_users = [] 269 | expired_users = [] 270 | for args in self._sqlconn.execute(q, (sysdate,)).fetchall(): 271 | overdue_users.append(UserInfo(*args)) 272 | for user in overdue_users: 273 | # expire users who were supposed to live less than 27 days 274 | if user.ttl < mailadm.util.parse_expiry_code("27d"): 275 | expired_users.append(user) 276 | continue 277 | mc_user = self.get_mailcow_connection().get_user(user.addr) 278 | if mc_user is None: 279 | logging.warning("user %s doesn't exist in mailcow", user.addr) 280 | continue 281 | last_login = mc_user.last_login 282 | # expire users who weren't online for longer than 25% of their TTL: 283 | if sysdate - last_login > user.ttl * 0.25: 284 | expired_users.append(user) 285 | return expired_users 286 | 287 | def get_user_list(self, token=None): 288 | q = UserInfo._select_user_columns 289 | args = [] 290 | if token is not None: 291 | q += "WHERE token_name=?" 292 | args.append(token) 293 | dbusers = [UserInfo(*args) for args in self._sqlconn.execute(q, args).fetchall()] 294 | try: 295 | mcusers = self.get_mailcow_connection().get_user_list() 296 | if not token: 297 | for mcuser in mcusers: 298 | if mcuser.addr not in [dbuser.addr for dbuser in dbusers]: 299 | dbusers.append(UserInfo(mcuser.addr, 0, 0, "created in mailcow")) 300 | for dbuser in dbusers: 301 | if dbuser.addr not in [mcuser.addr for mcuser in mcusers]: 302 | dbuser.token_name = "WARNING: does not exist in mailcow" 303 | except MailcowError as e: 304 | self.log("Can't check mailcow users: " + str(e)) 305 | return dbusers 306 | 307 | def get_mailcow_connection(self) -> MailcowConnection: 308 | return MailcowConnection(self.config.mailcow_endpoint, self.config.mailcow_token) 309 | 310 | 311 | class TokenInfo: 312 | _select_token_columns = "SELECT name, token, expiry, prefix, maxuse, usecount from tokens\n" 313 | 314 | def __init__(self, config, name, token, expiry, prefix, maxuse, usecount): 315 | self.config = config 316 | self.name = name 317 | self.token = token 318 | self.expiry = expiry 319 | self.prefix = prefix 320 | self.maxuse = maxuse 321 | self.usecount = usecount 322 | 323 | def get_maxdays(self): 324 | return mailadm.util.parse_expiry_code(self.expiry) / (24 * 60 * 60) 325 | 326 | def get_expiry_seconds(self): 327 | return mailadm.util.parse_expiry_code(self.expiry) 328 | 329 | def get_web_url(self): 330 | return "{web}?t={token}&n={name}".format( 331 | web=self.config.web_endpoint, 332 | token=self.token, 333 | name=self.name, 334 | ) 335 | 336 | def get_qr_uri(self): 337 | return "DCACCOUNT:" + self.get_web_url() 338 | 339 | def check_exhausted(self): 340 | """Check if a token can still create email accounts.""" 341 | if self.usecount >= self.maxuse: 342 | raise TokenExhaustedError 343 | 344 | 345 | class UserInfo: 346 | _select_user_columns = "SELECT addr, date, ttl, token_name from users\n" 347 | 348 | def __init__(self, addr, date, ttl, token_name): 349 | self.addr = addr 350 | self.date = date 351 | self.ttl = ttl 352 | self.token_name = token_name 353 | 354 | 355 | class Config: 356 | """The mailadm config. 357 | 358 | :param mail_domain: the domain of the mailserver 359 | :param web_endpoint: the web endpoint of mailadm's web interface 360 | :param dbversion: the version of the mailadm database schema 361 | :param mailcow_endpoint: the URL to the mailcow API 362 | :param mailcow_token: the token to authenticate with the mailcow API 363 | :param admingrpid: the ID of the admin group 364 | """ 365 | 366 | def __init__( 367 | self, 368 | mail_domain, 369 | web_endpoint, 370 | dbversion, 371 | mailcow_endpoint, 372 | mailcow_token, 373 | admingrpid=None, 374 | ): 375 | self.mail_domain = mail_domain 376 | self.web_endpoint = web_endpoint 377 | self.dbversion = dbversion 378 | self.mailcow_endpoint = mailcow_endpoint 379 | self.mailcow_token = mailcow_token 380 | self.admingrpid = admingrpid 381 | -------------------------------------------------------------------------------- /src/mailadm/cmdline.py: -------------------------------------------------------------------------------- 1 | """ 2 | script implementation of 3 | https://github.com/codespeaknet/sysadmin/blob/master/docs/postfix-virtual-domains.rst#add-a-virtual-mailbox 4 | 5 | """ 6 | 7 | from __future__ import print_function 8 | 9 | import sys 10 | 11 | import click 12 | import qrcode 13 | from click import style 14 | from deltachat import Account, account_hookimpl 15 | from deltachat.events import FFIEventLogger 16 | from deltachat.tracker import ConfigureFailed 17 | 18 | import mailadm 19 | import mailadm.commands 20 | import mailadm.db 21 | import mailadm.util 22 | 23 | from .bot import SetupPlugin, get_admbot_db_path 24 | from .conn import DBError, UserInfo 25 | from .mailcow import MailcowError 26 | 27 | option_dryrun = click.option( 28 | "-n", 29 | "--dryrun", 30 | is_flag=True, 31 | help="don't change any files, only show what would be changed.", 32 | ) 33 | 34 | 35 | @click.command(cls=click.Group, context_settings={"help_option_names": ["-h", "--help"]}) 36 | @click.version_option() 37 | @click.pass_context 38 | def mailadm_main(context): 39 | """e-mail account creation admin tool and web service.""" 40 | 41 | 42 | def get_mailadm_db(ctx, show=False, fail_missing_config=True): 43 | try: 44 | db_path = mailadm.db.get_db_path() 45 | except RuntimeError as e: 46 | ctx.fail(e.args) 47 | 48 | try: 49 | db = mailadm.db.DB(db_path) 50 | except DBError as e: 51 | ctx.fail(str(e)) 52 | 53 | if show: 54 | click.secho("using db: {}".format(db_path), file=sys.stderr) 55 | if fail_missing_config: 56 | with db.read_connection() as conn: 57 | if not conn.is_initialized(): 58 | ctx.fail("database not initialized, use 'init' subcommand to do so") 59 | return db 60 | 61 | 62 | def create_bot_account(ctx, email: str, password=None) -> (str, str): 63 | """Creates a mailcow account to use for the bot. 64 | 65 | :param email: the email address to be used for the bot account 66 | :param password: you can set a custom password via CLI, auto-generated if ommitted 67 | :return email, password: the credentials for the bot account 68 | """ 69 | mailadmdb = get_mailadm_db(ctx) 70 | with mailadmdb.read_connection() as rconn: 71 | mc = rconn.get_mailcow_connection() 72 | if not password: 73 | password = mailadm.util.gen_password() 74 | try: 75 | mc.add_user_mailcow(email, password, "bot") 76 | except MailcowError as e: 77 | if "object_exists" in str(e): 78 | ctx.fail( 79 | "%s already exists; delete the account in mailcow or specify " 80 | "credentials with --email and --password." % (email,), 81 | ) 82 | else: 83 | raise 84 | print("New account %s created as bot account." % (email,)) 85 | return email, password 86 | 87 | 88 | @click.command() 89 | @click.option("--email", type=str, default=None, help="name of email") 90 | @click.option("--password", type=str, default=None, help="name of password") 91 | @click.option("--show-ffi", is_flag=True, help="show low level ffi events") 92 | @click.pass_context 93 | @account_hookimpl 94 | def setup_bot(ctx, email, password, show_ffi): 95 | """initialize the deltachat bot as an alternative command interface. 96 | 97 | :param ctx: the click object passing the CLI environment 98 | :param email: the email account the deltachat bot will use for receiving commands 99 | :param password: the password to the bot's email account 100 | :param db: the path to the deltachat database of the bot - NOT the path to the mailadm database! 101 | :param show_ffi: show low level ffi events 102 | """ 103 | admbot_db = get_admbot_db_path() 104 | ac = Account(admbot_db) 105 | if show_ffi: 106 | ac.add_account_plugin(FFIEventLogger(ac)) 107 | 108 | mailadmdb = get_mailadm_db(ctx) 109 | with mailadmdb.read_connection() as rconn: 110 | mail_domain = rconn.config.mail_domain 111 | admingrpid_old = rconn.config.admingrpid 112 | 113 | if not ac.is_configured(): 114 | if email and not password: 115 | if email.split("@")[1] == mail_domain: 116 | print("--password not specified, creating account automatically... ") 117 | email, password = create_bot_account(ctx, email) 118 | else: 119 | ctx.fail( 120 | "You need to provide --password if you want to use an existing account " 121 | "for the mailadm bot.", 122 | ) 123 | elif not email and not password: 124 | print("--email and --password not specified, creating account automatically... ") 125 | email = "mailadm@" + mail_domain 126 | email, password = create_bot_account(ctx, email, password=password) 127 | elif not email and password: 128 | ctx.fail("Please also provide --email to use an email account for the mailadm bot.") 129 | if email: 130 | ac.set_config("addr", email) 131 | if password: 132 | ac.set_config("mail_pw", password) 133 | ac.set_config("mvbox_move", "1") 134 | ac.set_config("sentbox_watch", "0") 135 | ac.set_config("bot", "1") 136 | configtracker = ac.configure() 137 | try: 138 | configtracker.wait_finish() 139 | except ConfigureFailed as e: 140 | ctx.fail("Authentication Failed: " + str(e)) 141 | 142 | ac.start_io() 143 | 144 | chat = ac.create_group_chat("Admin group on {}".format(mail_domain), contacts=[], verified=True) 145 | 146 | setupplugin = SetupPlugin(chat.id) 147 | ac.add_account_plugin(setupplugin) 148 | 149 | chatinvite = chat.get_join_qr() 150 | qr = qrcode.QRCode() 151 | qr.add_data(chatinvite) 152 | print("\nPlease scan this qr code to join a verified admin group chat:\n\n") 153 | qr.print_ascii(invert=True) 154 | print("\nAlternatively, copy-paste this invite to your Delta Chat desktop client:", chatinvite) 155 | 156 | print("\nWaiting until you join the chat") 157 | sys.stdout.flush() # flush stdout to actually show the messages above 158 | setupplugin.member_added.wait() 159 | setupplugin.message_sent.clear() 160 | 161 | chat.send_text( 162 | "Welcome to the Admin group on %s! Type /help to see the existing commands." 163 | % (mail_domain,), 164 | ) 165 | print("Welcome message sent.") 166 | setupplugin.message_sent.wait() 167 | if admingrpid_old is not None: 168 | setupplugin.message_sent.clear() 169 | try: 170 | oldgroup = ac.get_chat_by_id(int(admingrpid_old)) 171 | oldgroup.send_text( 172 | "Someone created a new admin group on the command line. " 173 | "This one is not valid anymore.", 174 | ) 175 | setupplugin.message_sent.wait() 176 | except ValueError as e: 177 | print("Could not notify the old admin group:", str(e)) 178 | print("The old admin group was deactivated.") 179 | sys.stdout.flush() # flush stdout to actually show the messages above 180 | ac.shutdown() 181 | 182 | with mailadmdb.write_transaction() as wconn: 183 | wconn.set_config("admingrpid", chat.id) 184 | 185 | 186 | @click.command() 187 | @click.pass_context 188 | def config(ctx): 189 | """show and manipulate config settings.""" 190 | db = get_mailadm_db(ctx) 191 | with db.read_connection() as conn: 192 | click.secho("** mailadm version: {}".format(mailadm.__version__)) 193 | click.secho("** mailadm database path: {}".format(db.path)) 194 | for name, val in conn.get_config_items(): 195 | click.secho("{:22s} {}".format(name, val)) 196 | 197 | 198 | @click.command() 199 | @click.pass_context 200 | def list_tokens(ctx): 201 | """list available tokens""" 202 | db = get_mailadm_db(ctx) 203 | click.secho(mailadm.commands.list_tokens(db)) 204 | 205 | 206 | @click.command() 207 | @click.option("--token", type=str, default=None, help="name of token") 208 | @click.pass_context 209 | def list_users(ctx, token): 210 | """list users""" 211 | db = get_mailadm_db(ctx) 212 | with db.read_connection() as conn: 213 | for user_info in conn.get_user_list(token=token): 214 | click.secho("{} [{}]".format(user_info.addr, user_info.token_name)) 215 | 216 | 217 | def dump_token_info(token_info): 218 | click.echo(style("token:{}".format(token_info.name), fg="green")) 219 | click.echo(" prefix = {}".format(token_info.prefix)) 220 | click.echo(" expiry = {}".format(token_info.expiry)) 221 | click.echo(" maxuse = {}".format(token_info.maxuse)) 222 | click.echo(" usecount = {}".format(token_info.usecount)) 223 | click.echo(" token = {}".format(token_info.token)) 224 | click.echo(" " + token_info.get_web_url()) 225 | click.echo(" " + token_info.get_qr_uri()) 226 | 227 | 228 | @click.command() 229 | @click.argument("name", type=str, required=True) 230 | @click.option("--expiry", type=str, default="1d", help="account expiry eg 1w 3d -- default is 1d") 231 | @click.option( 232 | "--maxuse", 233 | type=int, 234 | default=50, 235 | help="maximum number of accounts this token can create", 236 | ) 237 | @click.option( 238 | "--prefix", 239 | type=str, 240 | default="tmp.", 241 | help="prefix for all e-mail addresses for this token", 242 | ) 243 | @click.option("--token", type=str, default=None, help="name of token to be used") 244 | @click.pass_context 245 | def add_token(ctx, name, expiry, maxuse, prefix, token): 246 | """add new token for generating new e-mail addresses""" 247 | db = get_mailadm_db(ctx) 248 | result = mailadm.commands.add_token(db, name, expiry, maxuse, prefix, token) 249 | if result["status"] == "error": 250 | ctx.fail(result["message"]) 251 | click.secho(result["message"]) 252 | 253 | 254 | @click.command() 255 | @click.argument("name", type=str, required=True) 256 | @click.option( 257 | "--expiry", 258 | type=str, 259 | default=None, 260 | help="account expiry eg 1w 3d -- default is to not change", 261 | ) 262 | @click.option( 263 | "--maxuse", 264 | type=int, 265 | default=None, 266 | help="maximum number of accounts this token can create, default is not to change", 267 | ) 268 | @click.option( 269 | "--prefix", 270 | type=str, 271 | default=None, 272 | help="prefix for all e-mail addresses for this token, default is not to change", 273 | ) 274 | @click.pass_context 275 | def mod_token(ctx, name, expiry, prefix, maxuse): 276 | """modify a token selectively""" 277 | db = get_mailadm_db(ctx) 278 | 279 | with db.write_transaction() as conn: 280 | conn.mod_token(name=name, expiry=expiry, maxuse=maxuse, prefix=prefix) 281 | tc = conn.get_tokeninfo_by_name(name) 282 | dump_token_info(tc) 283 | 284 | 285 | @click.command() 286 | @click.argument("name", type=str, required=True) 287 | @click.pass_context 288 | def del_token(ctx, name): 289 | """remove named token""" 290 | db = get_mailadm_db(ctx) 291 | with db.write_transaction() as conn: 292 | conn.del_token(name=name) 293 | click.secho("deleted token " + name) 294 | 295 | 296 | @click.command() 297 | @click.argument("tokenname", type=str, required=True) 298 | @click.pass_context 299 | def gen_qr(ctx, tokenname): 300 | """generate qr code image for a token.""" 301 | db = get_mailadm_db(ctx) 302 | result = mailadm.commands.qr_from_token(db, tokenname) 303 | if result["status"] == "error": 304 | ctx.fail(result["message"]) 305 | fn = result["filename"] 306 | 307 | click.secho("{} written for token '{}'".format(fn, tokenname)) 308 | 309 | 310 | @click.command() 311 | @click.option( 312 | "--web-endpoint", 313 | type=str, 314 | help="external URL for Web API create-account requests", 315 | envvar="WEB_ENDPOINT", 316 | default="https://example.org/new_email", 317 | show_default=True, 318 | ) 319 | @click.option( 320 | "--mail-domain", 321 | type=str, 322 | help="mail domain for which we create new users", 323 | envvar="MAIL_DOMAIN", 324 | default="example.org", 325 | show_default=True, 326 | ) 327 | @click.option( 328 | "--mailcow-endpoint", 329 | type=str, 330 | required=True, 331 | envvar="MAILCOW_ENDPOINT", 332 | help="the API endpoint of the mailcow instance", 333 | ) 334 | @click.option( 335 | "--mailcow-token", 336 | type=str, 337 | required=True, 338 | envvar="MAILCOW_TOKEN", 339 | help="you can get an API token in the mailcow web interface", 340 | ) 341 | @click.pass_context 342 | def init(ctx, web_endpoint, mail_domain, mailcow_endpoint, mailcow_token): 343 | """(re-)initialize configuration in mailadm database. 344 | 345 | Warnings: init can be called multiple times but if you are doing this to a 346 | database that already has users and tokens, you might run into trouble, 347 | depending on what you changed. 348 | """ 349 | db = get_mailadm_db(ctx, fail_missing_config=False) 350 | click.secho("initializing database {}".format(db.path)) 351 | 352 | db.init_config( 353 | mail_domain=mail_domain, 354 | web_endpoint=web_endpoint, 355 | mailcow_endpoint=mailcow_endpoint, 356 | mailcow_token=mailcow_token, 357 | ) 358 | 359 | 360 | @click.command() 361 | @click.argument("addr", type=str, required=True) 362 | @click.option( 363 | "--password", 364 | type=str, 365 | default=None, 366 | help="if not specified, generate a random password", 367 | ) 368 | @click.option( 369 | "--token", 370 | type=str, 371 | default=None, 372 | help="name of token. if not specified, automatically use first token matching addr", 373 | ) 374 | @option_dryrun 375 | @click.pass_context 376 | def add_user(ctx, addr, password, token, dryrun): 377 | """add user as a mailadm managed account.""" 378 | db = get_mailadm_db(ctx) 379 | result = mailadm.commands.add_user(db, token, addr, password, dryrun) 380 | if result["status"] == "error": 381 | ctx.fail(result["message"]) 382 | elif result["status"] == "success": 383 | click.secho( 384 | "Created %s with password: %s" % (result["message"].addr, result["message"].password), 385 | ) 386 | elif result["status"] == "dryrun": 387 | click.secho( 388 | "Would create %s with password %s" 389 | % (result["message"].addr, result["message"].password), 390 | ) 391 | 392 | 393 | @click.command() 394 | @click.argument("addr", type=str, required=True) 395 | @click.pass_context 396 | def del_user(ctx, addr): 397 | """remove e-mail address""" 398 | with get_mailadm_db(ctx).write_transaction() as conn: 399 | try: 400 | conn.delete_email_account(addr) 401 | click.secho("deleted user " + addr) 402 | except (DBError, MailcowError) as e: 403 | ctx.fail("failed to delete e-mail account {}: {}".format(addr, e)) 404 | 405 | 406 | @click.command() 407 | @option_dryrun 408 | @click.pass_context 409 | def prune(ctx, dryrun): 410 | """prune expired users from postfix and dovecot configurations""" 411 | result = mailadm.commands.prune(get_mailadm_db(ctx), dryrun=dryrun) 412 | for msg in result.get("message"): 413 | if result.get("status") == "error": 414 | ctx.fail(msg) 415 | else: 416 | click.secho(msg) 417 | 418 | 419 | @click.command() 420 | @click.pass_context 421 | @click.option( 422 | "--debug", 423 | is_flag=True, 424 | default=False, 425 | help="run server in debug mode and don't change any files", 426 | ) 427 | def web(ctx, debug): 428 | """(debugging-only!) serve http account creation Web API on localhost""" 429 | from .web import create_app_from_db 430 | 431 | db = get_mailadm_db(ctx) 432 | app = create_app_from_db(db) 433 | app.run(debug=debug, host="localhost", port=3691) 434 | 435 | 436 | @click.command() 437 | @click.pass_context 438 | def migrate_db(ctx): 439 | db = get_mailadm_db(ctx) 440 | with db.write_transaction() as conn: 441 | conn.execute("PRAGMA foreign_keys=on;") 442 | 443 | q = "SELECT addr, date, ttl, token_name from users" 444 | users = [UserInfo(*args) for args in conn.execute(q).fetchall()] 445 | q = "DROP TABLE users" 446 | conn.execute(q) 447 | 448 | conn.execute( 449 | """ 450 | CREATE TABLE users ( 451 | addr TEXT PRIMARY KEY, 452 | date INTEGER, 453 | ttl INTEGER, 454 | token_name TEXT NOT NULL, 455 | FOREIGN KEY (token_name) REFERENCES tokens (name) 456 | ) 457 | """, 458 | ) 459 | for u in users: 460 | q = """INSERT INTO users (addr, date, ttl, token_name) 461 | VALUES (?, ?, ?, ?)""" 462 | conn.execute(q, (u.addr, u.date, u.ttl, u.token_name)) 463 | 464 | q = "DELETE FROM config WHERE name=?" 465 | conn.execute(q, ("vmail_user",)) 466 | conn.execute(q, ("path_virtual_mailboxes",)) 467 | 468 | 469 | mailadm_main.add_command(setup_bot) 470 | mailadm_main.add_command(init) 471 | mailadm_main.add_command(config) 472 | mailadm_main.add_command(list_tokens) 473 | mailadm_main.add_command(add_token) 474 | mailadm_main.add_command(mod_token) 475 | mailadm_main.add_command(del_token) 476 | mailadm_main.add_command(gen_qr) 477 | mailadm_main.add_command(add_user) 478 | mailadm_main.add_command(del_user) 479 | mailadm_main.add_command(list_users) 480 | mailadm_main.add_command(prune) 481 | mailadm_main.add_command(web) 482 | mailadm_main.add_command(migrate_db) 483 | 484 | 485 | if __name__ == "__main__": 486 | mailadm_main() 487 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Mozilla Public License Version 2.0 2 | ================================== 3 | 4 | 1. Definitions 5 | -------------- 6 | 7 | 1.1. "Contributor" 8 | means each individual or legal entity that creates, contributes to 9 | the creation of, or owns Covered Software. 10 | 11 | 1.2. "Contributor Version" 12 | means the combination of the Contributions of others (if any) used 13 | by a Contributor and that particular Contributor's Contribution. 14 | 15 | 1.3. "Contribution" 16 | means Covered Software of a particular Contributor. 17 | 18 | 1.4. "Covered Software" 19 | means Source Code Form to which the initial Contributor has attached 20 | the notice in Exhibit A, the Executable Form of such Source Code 21 | Form, and Modifications of such Source Code Form, in each case 22 | including portions thereof. 23 | 24 | 1.5. "Incompatible With Secondary Licenses" 25 | means 26 | 27 | (a) that the initial Contributor has attached the notice described 28 | in Exhibit B to the Covered Software; or 29 | 30 | (b) that the Covered Software was made available under the terms of 31 | version 1.1 or earlier of the License, but not also under the 32 | terms of a Secondary License. 33 | 34 | 1.6. "Executable Form" 35 | means any form of the work other than Source Code Form. 36 | 37 | 1.7. "Larger Work" 38 | means a work that combines Covered Software with other material, in 39 | a separate file or files, that is not Covered Software. 40 | 41 | 1.8. "License" 42 | means this document. 43 | 44 | 1.9. "Licensable" 45 | means having the right to grant, to the maximum extent possible, 46 | whether at the time of the initial grant or subsequently, any and 47 | all of the rights conveyed by this License. 48 | 49 | 1.10. "Modifications" 50 | means any of the following: 51 | 52 | (a) any file in Source Code Form that results from an addition to, 53 | deletion from, or modification of the contents of Covered 54 | Software; or 55 | 56 | (b) any new file in Source Code Form that contains any Covered 57 | Software. 58 | 59 | 1.11. "Patent Claims" of a Contributor 60 | means any patent claim(s), including without limitation, method, 61 | process, and apparatus claims, in any patent Licensable by such 62 | Contributor that would be infringed, but for the grant of the 63 | License, by the making, using, selling, offering for sale, having 64 | made, import, or transfer of either its Contributions or its 65 | Contributor Version. 66 | 67 | 1.12. "Secondary License" 68 | means either the GNU General Public License, Version 2.0, the GNU 69 | Lesser General Public License, Version 2.1, the GNU Affero General 70 | Public License, Version 3.0, or any later versions of those 71 | licenses. 72 | 73 | 1.13. "Source Code Form" 74 | means the form of the work preferred for making modifications. 75 | 76 | 1.14. "You" (or "Your") 77 | means an individual or a legal entity exercising rights under this 78 | License. For legal entities, "You" includes any entity that 79 | controls, is controlled by, or is under common control with You. For 80 | purposes of this definition, "control" means (a) the power, direct 81 | or indirect, to cause the direction or management of such entity, 82 | whether by contract or otherwise, or (b) ownership of more than 83 | fifty percent (50%) of the outstanding shares or beneficial 84 | ownership of such entity. 85 | 86 | 2. License Grants and Conditions 87 | -------------------------------- 88 | 89 | 2.1. Grants 90 | 91 | Each Contributor hereby grants You a world-wide, royalty-free, 92 | non-exclusive license: 93 | 94 | (a) under intellectual property rights (other than patent or trademark) 95 | Licensable by such Contributor to use, reproduce, make available, 96 | modify, display, perform, distribute, and otherwise exploit its 97 | Contributions, either on an unmodified basis, with Modifications, or 98 | as part of a Larger Work; and 99 | 100 | (b) under Patent Claims of such Contributor to make, use, sell, offer 101 | for sale, have made, import, and otherwise transfer either its 102 | Contributions or its Contributor Version. 103 | 104 | 2.2. Effective Date 105 | 106 | The licenses granted in Section 2.1 with respect to any Contribution 107 | become effective for each Contribution on the date the Contributor first 108 | distributes such Contribution. 109 | 110 | 2.3. Limitations on Grant Scope 111 | 112 | The licenses granted in this Section 2 are the only rights granted under 113 | this License. No additional rights or licenses will be implied from the 114 | distribution or licensing of Covered Software under this License. 115 | Notwithstanding Section 2.1(b) above, no patent license is granted by a 116 | Contributor: 117 | 118 | (a) for any code that a Contributor has removed from Covered Software; 119 | or 120 | 121 | (b) for infringements caused by: (i) Your and any other third party's 122 | modifications of Covered Software, or (ii) the combination of its 123 | Contributions with other software (except as part of its Contributor 124 | Version); or 125 | 126 | (c) under Patent Claims infringed by Covered Software in the absence of 127 | its Contributions. 128 | 129 | This License does not grant any rights in the trademarks, service marks, 130 | or logos of any Contributor (except as may be necessary to comply with 131 | the notice requirements in Section 3.4). 132 | 133 | 2.4. Subsequent Licenses 134 | 135 | No Contributor makes additional grants as a result of Your choice to 136 | distribute the Covered Software under a subsequent version of this 137 | License (see Section 10.2) or under the terms of a Secondary License (if 138 | permitted under the terms of Section 3.3). 139 | 140 | 2.5. Representation 141 | 142 | Each Contributor represents that the Contributor believes its 143 | Contributions are its original creation(s) or it has sufficient rights 144 | to grant the rights to its Contributions conveyed by this License. 145 | 146 | 2.6. Fair Use 147 | 148 | This License is not intended to limit any rights You have under 149 | applicable copyright doctrines of fair use, fair dealing, or other 150 | equivalents. 151 | 152 | 2.7. Conditions 153 | 154 | Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted 155 | in Section 2.1. 156 | 157 | 3. Responsibilities 158 | ------------------- 159 | 160 | 3.1. Distribution of Source Form 161 | 162 | All distribution of Covered Software in Source Code Form, including any 163 | Modifications that You create or to which You contribute, must be under 164 | the terms of this License. You must inform recipients that the Source 165 | Code Form of the Covered Software is governed by the terms of this 166 | License, and how they can obtain a copy of this License. You may not 167 | attempt to alter or restrict the recipients' rights in the Source Code 168 | Form. 169 | 170 | 3.2. Distribution of Executable Form 171 | 172 | If You distribute Covered Software in Executable Form then: 173 | 174 | (a) such Covered Software must also be made available in Source Code 175 | Form, as described in Section 3.1, and You must inform recipients of 176 | the Executable Form how they can obtain a copy of such Source Code 177 | Form by reasonable means in a timely manner, at a charge no more 178 | than the cost of distribution to the recipient; and 179 | 180 | (b) You may distribute such Executable Form under the terms of this 181 | License, or sublicense it under different terms, provided that the 182 | license for the Executable Form does not attempt to limit or alter 183 | the recipients' rights in the Source Code Form under this License. 184 | 185 | 3.3. Distribution of a Larger Work 186 | 187 | You may create and distribute a Larger Work under terms of Your choice, 188 | provided that You also comply with the requirements of this License for 189 | the Covered Software. If the Larger Work is a combination of Covered 190 | Software with a work governed by one or more Secondary Licenses, and the 191 | Covered Software is not Incompatible With Secondary Licenses, this 192 | License permits You to additionally distribute such Covered Software 193 | under the terms of such Secondary License(s), so that the recipient of 194 | the Larger Work may, at their option, further distribute the Covered 195 | Software under the terms of either this License or such Secondary 196 | License(s). 197 | 198 | 3.4. Notices 199 | 200 | You may not remove or alter the substance of any license notices 201 | (including copyright notices, patent notices, disclaimers of warranty, 202 | or limitations of liability) contained within the Source Code Form of 203 | the Covered Software, except that You may alter any license notices to 204 | the extent required to remedy known factual inaccuracies. 205 | 206 | 3.5. Application of Additional Terms 207 | 208 | You may choose to offer, and to charge a fee for, warranty, support, 209 | indemnity or liability obligations to one or more recipients of Covered 210 | Software. However, You may do so only on Your own behalf, and not on 211 | behalf of any Contributor. You must make it absolutely clear that any 212 | such warranty, support, indemnity, or liability obligation is offered by 213 | You alone, and You hereby agree to indemnify every Contributor for any 214 | liability incurred by such Contributor as a result of warranty, support, 215 | indemnity or liability terms You offer. You may include additional 216 | disclaimers of warranty and limitations of liability specific to any 217 | jurisdiction. 218 | 219 | 4. Inability to Comply Due to Statute or Regulation 220 | --------------------------------------------------- 221 | 222 | If it is impossible for You to comply with any of the terms of this 223 | License with respect to some or all of the Covered Software due to 224 | statute, judicial order, or regulation then You must: (a) comply with 225 | the terms of this License to the maximum extent possible; and (b) 226 | describe the limitations and the code they affect. Such description must 227 | be placed in a text file included with all distributions of the Covered 228 | Software under this License. Except to the extent prohibited by statute 229 | or regulation, such description must be sufficiently detailed for a 230 | recipient of ordinary skill to be able to understand it. 231 | 232 | 5. Termination 233 | -------------- 234 | 235 | 5.1. The rights granted under this License will terminate automatically 236 | if You fail to comply with any of its terms. However, if You become 237 | compliant, then the rights granted under this License from a particular 238 | Contributor are reinstated (a) provisionally, unless and until such 239 | Contributor explicitly and finally terminates Your grants, and (b) on an 240 | ongoing basis, if such Contributor fails to notify You of the 241 | non-compliance by some reasonable means prior to 60 days after You have 242 | come back into compliance. Moreover, Your grants from a particular 243 | Contributor are reinstated on an ongoing basis if such Contributor 244 | notifies You of the non-compliance by some reasonable means, this is the 245 | first time You have received notice of non-compliance with this License 246 | from such Contributor, and You become compliant prior to 30 days after 247 | Your receipt of the notice. 248 | 249 | 5.2. If You initiate litigation against any entity by asserting a patent 250 | infringement claim (excluding declaratory judgment actions, 251 | counter-claims, and cross-claims) alleging that a Contributor Version 252 | directly or indirectly infringes any patent, then the rights granted to 253 | You by any and all Contributors for the Covered Software under Section 254 | 2.1 of this License shall terminate. 255 | 256 | 5.3. In the event of termination under Sections 5.1 or 5.2 above, all 257 | end user license agreements (excluding distributors and resellers) which 258 | have been validly granted by You or Your distributors under this License 259 | prior to termination shall survive termination. 260 | 261 | ************************************************************************ 262 | * * 263 | * 6. Disclaimer of Warranty * 264 | * ------------------------- * 265 | * * 266 | * Covered Software is provided under this License on an "as is" * 267 | * basis, without warranty of any kind, either expressed, implied, or * 268 | * statutory, including, without limitation, warranties that the * 269 | * Covered Software is free of defects, merchantable, fit for a * 270 | * particular purpose or non-infringing. The entire risk as to the * 271 | * quality and performance of the Covered Software is with You. * 272 | * Should any Covered Software prove defective in any respect, You * 273 | * (not any Contributor) assume the cost of any necessary servicing, * 274 | * repair, or correction. This disclaimer of warranty constitutes an * 275 | * essential part of this License. No use of any Covered Software is * 276 | * authorized under this License except under this disclaimer. * 277 | * * 278 | ************************************************************************ 279 | 280 | ************************************************************************ 281 | * * 282 | * 7. Limitation of Liability * 283 | * -------------------------- * 284 | * * 285 | * Under no circumstances and under no legal theory, whether tort * 286 | * (including negligence), contract, or otherwise, shall any * 287 | * Contributor, or anyone who distributes Covered Software as * 288 | * permitted above, be liable to You for any direct, indirect, * 289 | * special, incidental, or consequential damages of any character * 290 | * including, without limitation, damages for lost profits, loss of * 291 | * goodwill, work stoppage, computer failure or malfunction, or any * 292 | * and all other commercial damages or losses, even if such party * 293 | * shall have been informed of the possibility of such damages. This * 294 | * limitation of liability shall not apply to liability for death or * 295 | * personal injury resulting from such party's negligence to the * 296 | * extent applicable law prohibits such limitation. Some * 297 | * jurisdictions do not allow the exclusion or limitation of * 298 | * incidental or consequential damages, so this exclusion and * 299 | * limitation may not apply to You. * 300 | * * 301 | ************************************************************************ 302 | 303 | 8. Litigation 304 | ------------- 305 | 306 | Any litigation relating to this License may be brought only in the 307 | courts of a jurisdiction where the defendant maintains its principal 308 | place of business and such litigation shall be governed by laws of that 309 | jurisdiction, without reference to its conflict-of-law provisions. 310 | Nothing in this Section shall prevent a party's ability to bring 311 | cross-claims or counter-claims. 312 | 313 | 9. Miscellaneous 314 | ---------------- 315 | 316 | This License represents the complete agreement concerning the subject 317 | matter hereof. If any provision of this License is held to be 318 | unenforceable, such provision shall be reformed only to the extent 319 | necessary to make it enforceable. Any law or regulation which provides 320 | that the language of a contract shall be construed against the drafter 321 | shall not be used to construe this License against a Contributor. 322 | 323 | 10. Versions of the License 324 | --------------------------- 325 | 326 | 10.1. New Versions 327 | 328 | Mozilla Foundation is the license steward. Except as provided in Section 329 | 10.3, no one other than the license steward has the right to modify or 330 | publish new versions of this License. Each version will be given a 331 | distinguishing version number. 332 | 333 | 10.2. Effect of New Versions 334 | 335 | You may distribute the Covered Software under the terms of the version 336 | of the License under which You originally received the Covered Software, 337 | or under the terms of any subsequent version published by the license 338 | steward. 339 | 340 | 10.3. Modified Versions 341 | 342 | If you create software not governed by this License, and you want to 343 | create a new license for such software, you may create and use a 344 | modified version of this License if you rename the license and remove 345 | any references to the name of the license steward (except to note that 346 | such modified license differs from this License). 347 | 348 | 10.4. Distributing Source Code Form that is Incompatible With Secondary 349 | Licenses 350 | 351 | If You choose to distribute Source Code Form that is Incompatible With 352 | Secondary Licenses under the terms of this version of the License, the 353 | notice described in Exhibit B of this License must be attached. 354 | 355 | Exhibit A - Source Code Form License Notice 356 | ------------------------------------------- 357 | 358 | This Source Code Form is subject to the terms of the Mozilla Public 359 | License, v. 2.0. If a copy of the MPL was not distributed with this 360 | file, You can obtain one at http://mozilla.org/MPL/2.0/. 361 | 362 | If it is not possible or desirable to put the notice in a particular 363 | file, then You may include the notice in a location (such as a LICENSE 364 | file in a relevant directory) where a recipient would be likely to look 365 | for such a notice. 366 | 367 | You may add additional accurate notices of copyright ownership. 368 | 369 | Exhibit B - "Incompatible With Secondary Licenses" Notice 370 | --------------------------------------------------------- 371 | 372 | This Source Code Form is "Incompatible With Secondary Licenses", as 373 | defined by the Mozilla Public License, v. 2.0. 374 | --------------------------------------------------------------------------------