├── 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 |
--------------------------------------------------------------------------------