mirror of
https://github.com/chatmail/relay.git
synced 2026-05-10 16:04:37 +00:00
Compare commits
1 Commits
hpk/debug3
...
info-varia
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
376ab4c4a7 |
4
.github/CODE_OF_CONDUCT.md
vendored
4
.github/CODE_OF_CONDUCT.md
vendored
@@ -1,4 +0,0 @@
|
||||
|
||||
Please refer to
|
||||
[Delta Chat community standards and practices](https://delta.chat/en/community-standards)
|
||||
which also apply for all chatmail developments.
|
||||
7
.github/workflows/ci.yaml
vendored
7
.github/workflows/ci.yaml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
name: isolated chatmaild tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: run chatmaild tests
|
||||
working-directory: chatmaild
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
name: deploy-chatmail tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: initenv
|
||||
run: scripts/initenv.sh
|
||||
@@ -33,5 +33,8 @@ jobs:
|
||||
- name: run deploy-chatmail offline tests
|
||||
run: pytest --pyargs cmdeploy
|
||||
|
||||
- name: initialize with chatmail domain
|
||||
run: cmdeploy init chat.example.org
|
||||
|
||||
# all other cmdeploy commands require a staging server
|
||||
# see https://github.com/deltachat/chatmail/issues/100
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
;; Zone file for staging2.testrun.org
|
||||
|
||||
$ORIGIN staging2.testrun.org.
|
||||
$TTL 300
|
||||
|
||||
@ IN SOA ns.testrun.org. root.nine.testrun.org (
|
||||
2023010101 ; Serial
|
||||
7200 ; Refresh
|
||||
3600 ; Retry
|
||||
1209600 ; Expire
|
||||
3600 ; Negative response caching TTL
|
||||
)
|
||||
|
||||
;; Nameservers.
|
||||
@ IN NS ns.testrun.org.
|
||||
|
||||
;; DNS records.
|
||||
@ IN A 37.27.24.139
|
||||
mta-sts.staging2.testrun.org. CNAME staging2.testrun.org.
|
||||
www.staging2.testrun.org. CNAME staging2.testrun.org.
|
||||
|
||||
96
.github/workflows/test-and-deploy.yaml
vendored
96
.github/workflows/test-and-deploy.yaml
vendored
@@ -1,96 +0,0 @@
|
||||
name: deploy on staging2.testrun.org, and run tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'scripts/**'
|
||||
- '**/README.md'
|
||||
- 'CHANGELOG.md'
|
||||
- 'LICENSE'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: deploy on staging2.testrun.org, and run tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ !contains(github.ref, '$GITHUB_REF') }}
|
||||
steps:
|
||||
- uses: jsok/serialize-workflow-action@v1
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: prepare SSH
|
||||
run: |
|
||||
mkdir ~/.ssh
|
||||
echo "${{ secrets.STAGING_SSH_KEY }}" >> ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan staging2.testrun.org > ~/.ssh/known_hosts
|
||||
# save previous acme & dkim state
|
||||
rsync -avz root@staging2.testrun.org:/var/lib/acme . || true
|
||||
rsync -avz root@staging2.testrun.org:/etc/dkimkeys . || true
|
||||
# store previous acme & dkim state on ns.testrun.org, if it contains useful certs
|
||||
if [ -f dkimkeys/opendkim.private ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" dkimkeys root@ns.testrun.org:/tmp/ || true; fi
|
||||
if [ "$(ls -A acme/certs)" ]; then rsync -avz -e "ssh -o StrictHostKeyChecking=accept-new" acme root@ns.testrun.org:/tmp/ || true; fi
|
||||
# make sure CAA record isn't set
|
||||
ssh -o StrictHostKeyChecking=accept-new root@ns.testrun.org sed -i '/CAA/d' /etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org systemctl reload nsd
|
||||
|
||||
- name: rebuild staging2.testrun.org to have a clean VPS
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: Bearer ${{ secrets.HETZNER_API_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"image":"debian-12"}' \
|
||||
"https://api.hetzner.cloud/v1/servers/${{ secrets.STAGING_SERVER_ID }}/actions/rebuild"
|
||||
|
||||
- run: scripts/initenv.sh
|
||||
|
||||
- name: append venv/bin to PATH
|
||||
run: echo venv/bin >>$GITHUB_PATH
|
||||
|
||||
- name: upload TLS cert after rebuilding
|
||||
run: |
|
||||
echo " --- wait until staging2.testrun.org VPS is rebuilt --- "
|
||||
rm ~/.ssh/known_hosts
|
||||
while ! ssh -o ConnectTimeout=180 -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u ; do sleep 1 ; done
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org id -u
|
||||
# download acme & dkim state from ns.testrun.org
|
||||
rsync -e "ssh -o StrictHostKeyChecking=accept-new" -avz root@ns.testrun.org:/tmp/acme acme-restore || true
|
||||
rsync -avz root@ns.testrun.org:/tmp/dkimkeys dkimkeys-restore || true
|
||||
# restore acme & dkim state to staging2.testrun.org
|
||||
rsync -avz acme-restore/acme/ root@staging2.testrun.org:/var/lib/acme || true
|
||||
rsync -avz dkimkeys-restore/dkimkeys/ root@staging2.testrun.org:/etc/dkimkeys || true
|
||||
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || true
|
||||
|
||||
- name: run formatting checks
|
||||
run: cmdeploy fmt -v
|
||||
|
||||
- name: run deploy-chatmail offline tests
|
||||
run: pytest --pyargs cmdeploy
|
||||
|
||||
- run: cmdeploy init staging2.testrun.org
|
||||
|
||||
- run: cmdeploy run --verbose
|
||||
|
||||
- name: set DNS entries
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=accept-new root@staging2.testrun.org chown opendkim:opendkim -R /etc/dkimkeys
|
||||
cmdeploy dns --zonefile staging-generated.zone --verbose
|
||||
cat staging-generated.zone >> .github/workflows/staging.testrun.org-default.zone
|
||||
cat .github/workflows/staging.testrun.org-default.zone
|
||||
scp .github/workflows/staging.testrun.org-default.zone root@ns.testrun.org:/etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org nsd-checkzone staging2.testrun.org /etc/nsd/staging2.testrun.org.zone
|
||||
ssh root@ns.testrun.org systemctl reload nsd
|
||||
|
||||
- name: cmdeploy test
|
||||
run: CHATMAIL_DOMAIN2=nine.testrun.org cmdeploy test --slow
|
||||
|
||||
- name: cmdeploy dns (try 3 times)
|
||||
run: cmdeploy dns -v || cmdeploy dns -v || cmdeploy dns -v
|
||||
|
||||
155
CHANGELOG.md
155
CHANGELOG.md
@@ -1,155 +0,0 @@
|
||||
# Changelog for chatmail deployment
|
||||
|
||||
## untagged
|
||||
|
||||
- BREAKING: new required chatmail.ini values:
|
||||
|
||||
mailboxes_dir = /home/vmail/mail/{mail_domain}
|
||||
passdb = /home/vmail/passdb.sqlite
|
||||
|
||||
reducing hardcoding these two paths all over the files, also improving testability.
|
||||
([#351](https://github.com/deltachat/chatmail/pull/351))
|
||||
|
||||
- BREAKING: new required chatmail.ini value 'delete_inactive_users_after = 100'
|
||||
which removes users from database and mails after 100 days without any login.
|
||||
([#350](https://github.com/deltachat/chatmail/pull/350))
|
||||
|
||||
- reload nginx in the acmetool cronjob
|
||||
([#360](https://github.com/deltachat/chatmail/pull/360))
|
||||
|
||||
- remove checking of reverse-DNS PTR records. Chatmail-servers don't
|
||||
depend on it and even in the wider e-mail system it's not common anymore.
|
||||
If it's an issue, a chatmail operator can still care to properly set reverse DNS.
|
||||
([#348](https://github.com/deltachat/chatmail/pull/348))
|
||||
|
||||
- Make DNS-checking faster and more interactive, run it fully during "cmdeploy run",
|
||||
also introducing a generic mechanism for rapid remote ssh-based python function execution.
|
||||
([#346](https://github.com/deltachat/chatmail/pull/346))
|
||||
|
||||
- Don't fix file owner ship of /home/vmail
|
||||
([#345](https://github.com/deltachat/chatmail/pull/345))
|
||||
|
||||
- Support iterating over all users with doveadm commands
|
||||
([#344](https://github.com/deltachat/chatmail/pull/344))
|
||||
|
||||
- Test and fix for attempts to create inadmissible accounts
|
||||
([#333](https://github.com/deltachat/chatmail/pull/321))
|
||||
|
||||
- check that OpenPGP has only PKESK, SKESK and SEIPD packets
|
||||
([#323](https://github.com/deltachat/chatmail/pull/323),
|
||||
[#324](https://github.com/deltachat/chatmail/pull/324))
|
||||
|
||||
- improve filtermail checks for encrypted messages and drop support for unencrypted MDNs
|
||||
([#320](https://github.com/deltachat/chatmail/pull/320))
|
||||
|
||||
- replace `bash` with `/bin/sh`
|
||||
([#334](https://github.com/deltachat/chatmail/pull/334))
|
||||
|
||||
- Increase number of logged in IMAP sessions to 50000
|
||||
([#335](https://github.com/deltachat/chatmail/pull/335))
|
||||
|
||||
- filtermail: do not allow ASCII armor without actual payload
|
||||
([#325](https://github.com/deltachat/chatmail/pull/325))
|
||||
|
||||
- Remove sieve to enable hardlink deduplication in LMTP
|
||||
([#343](https://github.com/deltachat/chatmail/pull/343))
|
||||
|
||||
- dovecot: enable gzip compression on disk
|
||||
([#341](https://github.com/deltachat/chatmail/pull/341))
|
||||
|
||||
- DKIM-sign Content-Type and oversign all signed headers
|
||||
([#296](https://github.com/deltachat/chatmail/pull/296))
|
||||
|
||||
- Add nonci_accounts metric
|
||||
([#347](https://github.com/deltachat/chatmail/pull/347))
|
||||
|
||||
- doveauth: log when a new account is created
|
||||
([#349](https://github.com/deltachat/chatmail/pull/349))
|
||||
|
||||
- Multiplex HTTPS, IMAP and SMTP on port 443
|
||||
([#357](https://github.com/deltachat/chatmail/pull/357))
|
||||
|
||||
## 1.3.0 - 2024-06-06
|
||||
|
||||
- don't check necessary DNS records on cmdeploy init anymore
|
||||
([#316](https://github.com/deltachat/chatmail/pull/316))
|
||||
|
||||
- ensure cron and acl are installed
|
||||
([#293](https://github.com/deltachat/chatmail/pull/293),
|
||||
[#310](https://github.com/deltachat/chatmail/pull/310))
|
||||
|
||||
- change default for delete_mails_after from 40 to 20 days
|
||||
([#300](https://github.com/deltachat/chatmail/pull/300))
|
||||
|
||||
- save journald logs only to memory and save nginx logs to journald instead of file
|
||||
([#299](https://github.com/deltachat/chatmail/pull/299))
|
||||
|
||||
- fix writing of multiple obs repositories in `/etc/apt/sources.list`
|
||||
([#290](https://github.com/deltachat/chatmail/pull/290))
|
||||
|
||||
- metadata: add support for `/shared/vendor/deltachat/irohrelay`
|
||||
([#284](https://github.com/deltachat/chatmail/pull/284))
|
||||
|
||||
- Emit "XCHATMAIL" capability from IMAP server
|
||||
([#278](https://github.com/deltachat/chatmail/pull/278))
|
||||
|
||||
- Move echobot `into /var/lib/echobot`
|
||||
([#281](https://github.com/deltachat/chatmail/pull/281))
|
||||
|
||||
- Accept Let's Encrypt's new Terms of Services
|
||||
([#275](https://github.com/deltachat/chatmail/pull/276))
|
||||
|
||||
- Reload Dovecot and Postfix when TLS certificate updates
|
||||
([#271](https://github.com/deltachat/chatmail/pull/271))
|
||||
|
||||
- Use forked version of dovecot without hardcoded delays
|
||||
([#270](https://github.com/deltachat/chatmail/pull/270))
|
||||
|
||||
## 1.2.0 - 2024-04-04
|
||||
|
||||
- Install dig on the server to resolve DNS records
|
||||
([#267](https://github.com/deltachat/chatmail/pull/267))
|
||||
|
||||
- preserve notification order and exponentially backoff with
|
||||
retries for tokens where we didn't get a successful return
|
||||
([#265](https://github.com/deltachat/chatmail/pull/263))
|
||||
|
||||
- Run chatmail-metadata and doveauth as vmail
|
||||
([#261](https://github.com/deltachat/chatmail/pull/261))
|
||||
|
||||
- Apply systemd restrictions to echobot
|
||||
([#259](https://github.com/deltachat/chatmail/pull/259))
|
||||
|
||||
- re-enable running the CI in pull requests, but not concurrently
|
||||
([#258](https://github.com/deltachat/chatmail/pull/258))
|
||||
|
||||
|
||||
## 1.1.0 - 2024-03-28
|
||||
|
||||
### The changelog starts to record changes from March 15th, 2024
|
||||
|
||||
- Move systemd unit templates to cmdeploy package
|
||||
([#255](https://github.com/deltachat/chatmail/pull/255))
|
||||
|
||||
- Persist push tokens and support multiple device per address
|
||||
([#254](https://github.com/deltachat/chatmail/pull/254))
|
||||
|
||||
- Avoid warning for regular doveauth protocol's hello message.
|
||||
([#250](https://github.com/deltachat/chatmail/pull/250))
|
||||
|
||||
- Fix various tests to pass again with "cmdeploy test".
|
||||
([#245](https://github.com/deltachat/chatmail/pull/245),
|
||||
[#242](https://github.com/deltachat/chatmail/pull/242)
|
||||
|
||||
- Ensure lets-encrypt certificates are reloaded after renewal
|
||||
([#244]) https://github.com/deltachat/chatmail/pull/244
|
||||
|
||||
- Persist tokens to avoid iOS users loosing push-notifications when the
|
||||
chatmail metadata service is restarted (happens regularly during deploys)
|
||||
([#238](https://github.com/deltachat/chatmail/pull/239)
|
||||
|
||||
- Fix failing sieve-script compile errors on incoming messages
|
||||
([#237](https://github.com/deltachat/chatmail/pull/239)
|
||||
|
||||
- Fix quota reporting after expunging of old mails
|
||||
([#233](https://github.com/deltachat/chatmail/pull/239)
|
||||
21
LICENSE
21
LICENSE
@@ -1,21 +0,0 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2023, chatmail and delta chat teams
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
||||
of the Software, and to permit persons to whom the Software is furnished to do
|
||||
so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
234
README.md
234
README.md
@@ -15,174 +15,146 @@ after which the initially specified password is required for using them.
|
||||
|
||||
## Deploying your own chatmail server
|
||||
|
||||
To deploy chatmail on your own server, you must have set-up ssh authentication and need to use an ed25519 key, due to an [upstream bug in paramiko](https://github.com/paramiko/paramiko/issues/2191). You also need to add your private key to the local ssh-agent, because you can't type in your password during deployment.
|
||||
We subsequently use `CHATMAIL_DOMAIN` as a placeholder for your fully qualified
|
||||
DNS domain name (FQDN), for example `chat.example.org`.
|
||||
|
||||
We use `chat.example.org` as the chatmail domain in the following steps.
|
||||
Please substitute it with your own domain.
|
||||
|
||||
1. Install the `cmdeploy` command in a virtualenv
|
||||
1. Setup DNS `A` and `AAAA` records for your `CHATMAIL_DOMAIN`.
|
||||
Verify that DNS is set and SSH root login works:
|
||||
|
||||
```
|
||||
git clone https://github.com/deltachat/chatmail
|
||||
cd chatmail
|
||||
scripts/initenv.sh
|
||||
ssh root@CHATMAIL_DOMAIN
|
||||
```
|
||||
|
||||
2. Create chatmail configuration file `chatmail.ini`:
|
||||
2. Install the `cmdeploy` command in a virtualenv
|
||||
|
||||
```
|
||||
scripts/cmdeploy init chat.example.org # <-- use your domain
|
||||
source scripts/initenv.sh
|
||||
```
|
||||
|
||||
3. Setup first DNS records for your chatmail domain,
|
||||
according to the hints provided by `cmdeploy init`.
|
||||
Verify that SSH root login works:
|
||||
|
||||
3. Create chatmail configuration file `chatmail.ini`:
|
||||
|
||||
```
|
||||
ssh root@chat.example.org # <-- use your domain
|
||||
cmdeploy init CHATMAIL_DOMAIN
|
||||
```
|
||||
|
||||
4. Deploy to the remote chatmail server:
|
||||
|
||||
```
|
||||
scripts/cmdeploy run
|
||||
cmdeploy run
|
||||
```
|
||||
This script will also show you additional DNS records
|
||||
which you should configure at your DNS provider
|
||||
(it can take some time until they are public).
|
||||
|
||||
### Other helpful commands:
|
||||
5. To output a DNS zone file from which you can transfer DNS records
|
||||
to your DNS provider:
|
||||
|
||||
To check the status of your remotely running chatmail service:
|
||||
```
|
||||
cmdeploy dns
|
||||
```
|
||||
|
||||
```
|
||||
scripts/cmdeploy status
|
||||
6. To check status of your remotely running chatmail service:
|
||||
|
||||
```
|
||||
cmdeploy status
|
||||
```
|
||||
|
||||
7. To test your chatmail service:
|
||||
|
||||
```
|
||||
cmdeploy test
|
||||
```
|
||||
|
||||
8. To benchmark your chatmail service:
|
||||
|
||||
```
|
||||
cmdeploy bench
|
||||
```
|
||||
|
||||
### Refining the web pages
|
||||
|
||||
|
||||
```
|
||||
cmdeploy webdev
|
||||
```
|
||||
|
||||
To check whether your DNS records are correct:
|
||||
This starts a local live development cycle for chatmail Web pages:
|
||||
|
||||
```
|
||||
scripts/cmdeploy dns
|
||||
```
|
||||
- uses the `www/src/page-layout.html` file for producing static
|
||||
HTML pages from `www/src/*.md` files
|
||||
|
||||
To test whether your chatmail service is working correctly:
|
||||
- continously builds the web presence reading files from `www/src` directory
|
||||
and generating html files and copying assets to the `www/build` directory.
|
||||
|
||||
```
|
||||
scripts/cmdeploy test
|
||||
```
|
||||
- Starts a browser window automatically where you can "refresh" as needed.
|
||||
|
||||
To measure the performance of your chatmail service:
|
||||
|
||||
```
|
||||
scripts/cmdeploy bench
|
||||
```
|
||||
### Home page and getting started for users
|
||||
|
||||
## Overview of this repository
|
||||
`cmdeploy run` sets up mail services,
|
||||
and also creates default static Web pages and deploys them:
|
||||
|
||||
This repository drives the development of chatmail services,
|
||||
comprised of minimal setups of
|
||||
- a default `index.html` along with a QR code that users can click to
|
||||
create accounts on your chatmail provider,
|
||||
|
||||
- [postfix smtp server](https://www.postfix.org)
|
||||
- [dovecot imap server](https://www.dovecot.org)
|
||||
- a default `info.html` that is linked from the home page,
|
||||
|
||||
as well as custom services that are integrated with these two:
|
||||
- a default `policy.html` that is linked from the home page.
|
||||
|
||||
All `.html` files are generated
|
||||
by the according markdown `.md` file in the `www/src` directory.
|
||||
|
||||
|
||||
### Ports
|
||||
|
||||
Postfix listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
|
||||
Dovecot listens on ports 143(imap) and 993 (imaps).
|
||||
|
||||
Delta Chat will, however, discover all ports and configurations
|
||||
automatically by reading the `autoconfig.xml` file from the chatmail instance.
|
||||
|
||||
|
||||
## Emergency Commands to disable automatic account creation
|
||||
|
||||
If you need to stop account creation,
|
||||
e.g. because some script is wildly creating accounts, run:
|
||||
|
||||
touch /etc/chatmail-nocreate
|
||||
|
||||
While this file is present, account creation will be blocked.
|
||||
|
||||
|
||||
## Running tests and benchmarks (offline and online)
|
||||
|
||||
1. Set `CHATMAIL_SSH` so that `ssh root@$CHATMAIL_SSH` allows
|
||||
to login to the chatmail instance server.
|
||||
|
||||
2. To run local and online tests:
|
||||
|
||||
scripts/test.sh
|
||||
|
||||
3. To run benchmarks against your chatmail instance:
|
||||
|
||||
scripts/bench.sh
|
||||
|
||||
|
||||
## Development Background for chatmail instances
|
||||
|
||||
This repository drives the development of "chatmail instances",
|
||||
comprised of minimal setups of
|
||||
|
||||
- [postfix smtp server](https://www.postfix.org)
|
||||
- [dovecot imap server](https://www.dovecot.org)
|
||||
|
||||
as well as two custom services that are integrated with these two:
|
||||
|
||||
- `chatmaild/src/chatmaild/doveauth.py` implements
|
||||
create-on-login account creation semantics and is used
|
||||
by Dovecot during login authentication and by Postfix
|
||||
which in turn uses [Dovecot SASL](https://doc.dovecot.org/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket)
|
||||
to authenticate users
|
||||
to send mails for them.
|
||||
to send mails for them.
|
||||
|
||||
- `chatmaild/src/chatmaild/filtermail.py` prevents
|
||||
unencrypted e-mail from leaving the chatmail service
|
||||
and is integrated into postfix's outbound mail pipelines.
|
||||
|
||||
There is also the `cmdeploy/src/cmdeploy/cmdeploy.py` command line tool
|
||||
which helps with setting up and managing the chatmail service.
|
||||
`cmdeploy run` uses [pyinfra-based scripting](https://pyinfra.com/)
|
||||
in `cmdeploy/src/cmdeploy/__init__.py`
|
||||
to automatically install all chatmail components on a server.
|
||||
- `chatmaild/src/chatmaild/filtermail.py` prevents
|
||||
unencrypted e-mail from leaving the chatmail instance
|
||||
and is integrated into postfix's outbound mail pipelines.
|
||||
|
||||
|
||||
### Home page and getting started for users
|
||||
|
||||
`cmdeploy run` also creates default static Web pages and deploys them
|
||||
to a nginx web server with:
|
||||
|
||||
- a default `index.html` along with a QR code that users can click to
|
||||
create accounts on your chatmail provider,
|
||||
|
||||
- a default `info.html` that is linked from the home page,
|
||||
|
||||
- a default `policy.html` that is linked from the home page.
|
||||
|
||||
All `.html` files are generated
|
||||
by the according markdown `.md` file in the `www/src` directory.
|
||||
|
||||
|
||||
### Refining the web pages
|
||||
|
||||
|
||||
```
|
||||
scripts/cmdeploy webdev
|
||||
```
|
||||
|
||||
This starts a local live development cycle for chatmail Web pages:
|
||||
|
||||
- uses the `www/src/page-layout.html` file for producing static
|
||||
HTML pages from `www/src/*.md` files
|
||||
|
||||
- continously builds the web presence reading files from `www/src` directory
|
||||
and generating html files and copying assets to the `www/build` directory.
|
||||
|
||||
- Starts a browser window automatically where you can "refresh" as needed.
|
||||
|
||||
|
||||
## Emergency Commands to disable automatic account creation
|
||||
|
||||
If you need to stop account creation,
|
||||
e.g. because some script is wildly creating accounts,
|
||||
login to the server with ssh and run:
|
||||
|
||||
```
|
||||
touch /etc/chatmail-nocreate
|
||||
```
|
||||
|
||||
While this file is present, account creation will be blocked.
|
||||
|
||||
### Ports
|
||||
|
||||
[Postfix](http://www.postfix.org/) listens on ports 25 (smtp) and 587 (submission) and 465 (submissions).
|
||||
[Dovecot](https://www.dovecot.org/) listens on ports 143 (imap) and 993 (imaps).
|
||||
[nginx](https://www.nginx.com/) listens on port 8443 (https-alt) and 443 (https).
|
||||
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
|
||||
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (http).
|
||||
|
||||
Delta Chat apps will, however, discover all ports and configurations
|
||||
automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail service.
|
||||
|
||||
## Email authentication
|
||||
|
||||
chatmail servers rely on [DKIM](https://www.rfc-editor.org/rfc/rfc6376)
|
||||
to authenticate incoming emails.
|
||||
Incoming emails must have a valid DKIM signature with
|
||||
Signing Domain Identifier (SDID, `d=` parameter in the DKIM-Signature header)
|
||||
equal to the `From:` header domain.
|
||||
This property is checked by OpenDKIM screen policy script
|
||||
before validating the signatures.
|
||||
This correpsonds to strict [DMARC](https://www.rfc-editor.org/rfc/rfc7489) alignment (`adkim=s`),
|
||||
but chatmail does not rely on DMARC and does not consult the sender policy published in DMARC records.
|
||||
Other legacy authentication mechanisms such as [iprev](https://www.rfc-editor.org/rfc/rfc8601#section-2.7.3)
|
||||
and [SPF](https://www.rfc-editor.org/rfc/rfc7208) are also not taken into account.
|
||||
If there is no valid DKIM signature on the incoming email,
|
||||
the sender receives a "5.7.1 No valid DKIM signature found" error.
|
||||
|
||||
Outgoing emails must be sent over authenticated connection
|
||||
with envelope MAIL FROM (return path) corresponding to the login.
|
||||
This is ensured by Postfix which maps login username
|
||||
to MAIL FROM with
|
||||
[`smtpd_sender_login_maps`](https://www.postfix.org/postconf.5.html#smtpd_sender_login_maps)
|
||||
and rejects incorrectly authenticated emails with [`reject_sender_login_mismatch`](reject_sender_login_mismatch) policy.
|
||||
`From:` header must correspond to envelope MAIL FROM,
|
||||
this is ensured by `filtermail` proxy.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
include src/chatmaild/*.f
|
||||
include src/chatmaild/ini/*.ini.f
|
||||
include src/chatmaild/ini/*.ini
|
||||
include src/chatmaild/tests/mail-data/*
|
||||
|
||||
@@ -8,10 +8,6 @@ version = "0.2"
|
||||
dependencies = [
|
||||
"aiosmtpd",
|
||||
"iniconfig",
|
||||
"deltachat-rpc-server",
|
||||
"deltachat-rpc-client",
|
||||
"filelock",
|
||||
"requests",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
@@ -22,11 +18,7 @@ where = ['src']
|
||||
|
||||
[project.scripts]
|
||||
doveauth = "chatmaild.doveauth:main"
|
||||
chatmail-metadata = "chatmaild.metadata:main"
|
||||
filtermail = "chatmaild.filtermail:main"
|
||||
echobot = "chatmaild.echo:main"
|
||||
chatmail-metrics = "chatmaild.metrics:main"
|
||||
delete_inactive_users = "chatmaild.delete_inactive_users:main"
|
||||
|
||||
[project.entry-points.pytest11]
|
||||
"chatmaild.testplugin" = "chatmaild.tests.plugin"
|
||||
@@ -37,16 +29,6 @@ log_format = "%(asctime)s %(levelname)s %(message)s"
|
||||
log_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
log_level = "INFO"
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
|
||||
"PLC", # Pylint Convention
|
||||
"PLE", # Pylint Error
|
||||
"PLW", # Pylint Warning
|
||||
]
|
||||
|
||||
[tool.tox]
|
||||
legacy_tox_ini = """
|
||||
[tox]
|
||||
@@ -58,9 +40,10 @@ skipdist = True
|
||||
skip_install = True
|
||||
deps =
|
||||
ruff
|
||||
black
|
||||
commands =
|
||||
ruff format --quiet --diff src/
|
||||
ruff check src/
|
||||
black --quiet --check --diff src/
|
||||
ruff src/
|
||||
|
||||
[testenv]
|
||||
deps = pytest
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
from pathlib import Path
|
||||
|
||||
import iniconfig
|
||||
|
||||
|
||||
def read_config(inipath):
|
||||
assert Path(inipath).exists(), inipath
|
||||
cfg = iniconfig.IniConfig(inipath)
|
||||
params = cfg.sections["params"]
|
||||
return Config(inipath, params=params)
|
||||
return Config(inipath, params=cfg.sections["params"])
|
||||
|
||||
|
||||
class Config:
|
||||
@@ -17,17 +13,13 @@ class Config:
|
||||
self.max_user_send_per_minute = int(params["max_user_send_per_minute"])
|
||||
self.max_mailbox_size = params["max_mailbox_size"]
|
||||
self.delete_mails_after = params["delete_mails_after"]
|
||||
self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
|
||||
self.username_min_length = int(params["username_min_length"])
|
||||
self.username_max_length = int(params["username_max_length"])
|
||||
self.password_min_length = int(params["password_min_length"])
|
||||
self.passthrough_senders = params["passthrough_senders"].split()
|
||||
self.passthrough_recipients = params["passthrough_recipients"].split()
|
||||
self.mailboxes_dir = Path(params["mailboxes_dir"].strip())
|
||||
self.passdb_path = Path(params["passdb_path"].strip())
|
||||
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
|
||||
self.postfix_reinject_port = int(params["postfix_reinject_port"])
|
||||
self.iroh_relay = params.get("iroh_relay")
|
||||
self.privacy_postal = params.get("privacy_postal")
|
||||
self.privacy_mail = params.get("privacy_mail")
|
||||
self.privacy_pdo = params.get("privacy_pdo")
|
||||
@@ -36,36 +28,14 @@ class Config:
|
||||
def _getbytefile(self):
|
||||
return open(self._inipath, "rb")
|
||||
|
||||
def get_user_maildir(self, addr):
|
||||
if addr and addr != "." and "/" not in addr:
|
||||
res = self.mailboxes_dir.joinpath(addr).resolve()
|
||||
if res.is_relative_to(self.mailboxes_dir):
|
||||
return res
|
||||
raise ValueError(f"invalid address {addr!r}")
|
||||
|
||||
|
||||
def write_initial_config(inipath, mail_domain, overrides):
|
||||
"""Write out default config file, using the specified config value overrides."""
|
||||
def write_initial_config(inipath, mail_domain):
|
||||
from importlib.resources import files
|
||||
|
||||
inidir = files(__package__).joinpath("ini")
|
||||
source_inipath = inidir.joinpath("chatmail.ini.f")
|
||||
content = source_inipath.read_text().format(mail_domain=mail_domain)
|
||||
|
||||
# apply config overrides
|
||||
new_lines = []
|
||||
for line in content.split("\n"):
|
||||
new_line = line.strip()
|
||||
if new_line and new_line[0] not in "#[":
|
||||
name, value = map(str.strip, new_line.split("=", maxsplit=1))
|
||||
value = overrides.get(name, value)
|
||||
new_line = f"{name} = {value}"
|
||||
new_lines.append(new_line)
|
||||
|
||||
content = "\n".join(new_lines)
|
||||
|
||||
# apply testrun privacy overrides
|
||||
|
||||
content = (
|
||||
inidir.joinpath("chatmail.ini.f").read_text().format(mail_domain=mail_domain)
|
||||
)
|
||||
if mail_domain.endswith(".testrun.org"):
|
||||
override_inipath = inidir.joinpath("override-testrun.ini")
|
||||
privacy = iniconfig.IniConfig(override_inipath)["privacy"]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import contextlib
|
||||
import sqlite3
|
||||
import contextlib
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
"""
|
||||
Remove inactive users
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
|
||||
from .config import read_config
|
||||
from .database import Database
|
||||
from .doveauth import iter_userdb_lastlogin_before
|
||||
|
||||
|
||||
def delete_inactive_users(db, config, CHUNK=100):
|
||||
cutoff_date = time.time() - config.delete_inactive_users_after * 86400
|
||||
|
||||
old_users = iter_userdb_lastlogin_before(db, cutoff_date)
|
||||
chunks = (old_users[i : i + CHUNK] for i in range(0, len(old_users), CHUNK))
|
||||
for sublist in chunks:
|
||||
for user in sublist:
|
||||
user_mail_dir = config.get_user_maildir(user)
|
||||
shutil.rmtree(user_mail_dir, ignore_errors=True)
|
||||
|
||||
with db.write_transaction() as conn:
|
||||
for user in sublist:
|
||||
conn.execute("DELETE FROM users WHERE addr = ?", (user,))
|
||||
|
||||
|
||||
def main():
|
||||
(cfgpath,) = sys.argv[1:]
|
||||
config = read_config(cfgpath)
|
||||
db = Database(config.passdb_path)
|
||||
delete_inactive_users(db, config)
|
||||
@@ -1,26 +1,22 @@
|
||||
import crypt
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import json
|
||||
import crypt
|
||||
from socketserver import (
|
||||
UnixStreamServer,
|
||||
StreamRequestHandler,
|
||||
ThreadingMixIn,
|
||||
UnixStreamServer,
|
||||
)
|
||||
import pwd
|
||||
|
||||
from .config import Config, read_config
|
||||
from .database import Database
|
||||
from .config import read_config, Config
|
||||
|
||||
NOCREATE_FILE = "/etc/chatmail-nocreate"
|
||||
|
||||
|
||||
class UnknownCommand(ValueError):
|
||||
"""dictproxy handler received an unkown command"""
|
||||
|
||||
|
||||
def encrypt_password(password: str):
|
||||
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
|
||||
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
|
||||
@@ -46,10 +42,6 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
|
||||
return False
|
||||
localpart, domain = parts
|
||||
|
||||
if localpart == "echo":
|
||||
# echobot account should not be created in the database
|
||||
return False
|
||||
|
||||
if (
|
||||
len(localpart) > config.username_max_length
|
||||
or len(localpart) < config.username_min_length
|
||||
@@ -65,56 +57,28 @@ def is_allowed_to_create(config: Config, user, cleartext_password) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def get_user_data(db, config: Config, user):
|
||||
if user == f"echo@{config.mail_domain}":
|
||||
return dict(
|
||||
home=str(config.get_user_maildir(user)),
|
||||
uid="vmail",
|
||||
gid="vmail",
|
||||
)
|
||||
|
||||
def get_user_data(db, user):
|
||||
with db.read_connection() as conn:
|
||||
result = conn.get_user(user)
|
||||
if result:
|
||||
result["home"] = str(config.get_user_maildir(user))
|
||||
result["uid"] = "vmail"
|
||||
result["gid"] = "vmail"
|
||||
return result
|
||||
|
||||
|
||||
def lookup_userdb(db, config: Config, user):
|
||||
return get_user_data(db, config, user)
|
||||
def lookup_userdb(db, user):
|
||||
return get_user_data(db, user)
|
||||
|
||||
|
||||
def lookup_passdb(db, config: Config, user, cleartext_password, last_login=None):
|
||||
if user == f"echo@{config.mail_domain}":
|
||||
# Echobot writes password it wants to log in with into /run/echobot/password
|
||||
try:
|
||||
password = Path("/run/echobot/password").read_text()
|
||||
except Exception:
|
||||
logging.exception("Exception when trying to read /run/echobot/password")
|
||||
return None
|
||||
|
||||
return dict(
|
||||
home=str(config.get_user_maildir(user)),
|
||||
uid="vmail",
|
||||
gid="vmail",
|
||||
password=encrypt_password(password),
|
||||
)
|
||||
|
||||
if last_login is None:
|
||||
last_login = time.time()
|
||||
last_login = int(last_login)
|
||||
|
||||
def lookup_passdb(db, config: Config, user, cleartext_password):
|
||||
with db.write_transaction() as conn:
|
||||
userdata = conn.get_user(user)
|
||||
if userdata:
|
||||
# Update last login time.
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login=? WHERE addr=?", (last_login, user)
|
||||
"UPDATE users SET last_login=? WHERE addr=?", (int(time.time()), user)
|
||||
)
|
||||
|
||||
userdata["home"] = str(config.get_user_maildir(user))
|
||||
userdata["uid"] = "vmail"
|
||||
userdata["gid"] = "vmail"
|
||||
return userdata
|
||||
@@ -124,89 +88,32 @@ def lookup_passdb(db, config: Config, user, cleartext_password, last_login=None)
|
||||
encrypted_password = encrypt_password(cleartext_password)
|
||||
q = """INSERT INTO users (addr, password, last_login)
|
||||
VALUES (?, ?, ?)"""
|
||||
conn.execute(q, (user, encrypted_password, last_login))
|
||||
print(f"Created address: {user}", file=sys.stderr)
|
||||
conn.execute(q, (user, encrypted_password, int(time.time())))
|
||||
return dict(
|
||||
home=str(config.get_user_maildir(user)),
|
||||
home=f"/home/vmail/{user}",
|
||||
uid="vmail",
|
||||
gid="vmail",
|
||||
password=encrypted_password,
|
||||
)
|
||||
|
||||
|
||||
def iter_userdb(db) -> list:
|
||||
"""Get a list of all user addresses."""
|
||||
with db.read_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT addr from users",
|
||||
).fetchall()
|
||||
return [x[0] for x in rows]
|
||||
|
||||
|
||||
def iter_userdb_lastlogin_before(db, cutoff_date):
|
||||
"""Get a list of users where last login was before cutoff_date."""
|
||||
with db.read_connection() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT addr FROM users WHERE last_login < ?", (cutoff_date,)
|
||||
).fetchall()
|
||||
return [x[0] for x in rows]
|
||||
|
||||
|
||||
def split_and_unescape(s):
|
||||
"""Split strings using double quote as a separator and backslash as escape character
|
||||
into parts."""
|
||||
|
||||
out = ""
|
||||
i = 0
|
||||
while i < len(s):
|
||||
c = s[i]
|
||||
if c == "\\":
|
||||
# Skip escape character.
|
||||
i += 1
|
||||
|
||||
# This will raise IndexError if there is no character
|
||||
# after escape character. This is expected
|
||||
# as this is an invalid input.
|
||||
out += s[i]
|
||||
elif c == '"':
|
||||
# Separator
|
||||
yield out
|
||||
out = ""
|
||||
else:
|
||||
out += c
|
||||
i += 1
|
||||
yield out
|
||||
|
||||
|
||||
def handle_dovecot_request(msg, db, config: Config):
|
||||
# see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
|
||||
short_command = msg[0]
|
||||
if short_command == "H": # HELLO
|
||||
# we don't do any checking on versions and just return
|
||||
return
|
||||
elif short_command == "L": # LOOKUP
|
||||
if short_command == "L": # LOOKUP
|
||||
parts = msg[1:].split("\t")
|
||||
|
||||
# Dovecot <2.3.17 has only one part,
|
||||
# do not attempt to read any other parts for compatibility.
|
||||
keyname = parts[0]
|
||||
|
||||
namespace, type, args = keyname.split("/", 2)
|
||||
args = list(split_and_unescape(args))
|
||||
|
||||
keyname, user = parts[:2]
|
||||
namespace, type, *args = keyname.split("/")
|
||||
reply_command = "F"
|
||||
res = ""
|
||||
if namespace == "shared":
|
||||
if type == "userdb":
|
||||
user = args[0]
|
||||
if user.endswith(f"@{config.mail_domain}"):
|
||||
res = lookup_userdb(db, config, user)
|
||||
res = lookup_userdb(db, user)
|
||||
if res:
|
||||
reply_command = "O"
|
||||
else:
|
||||
reply_command = "N"
|
||||
elif type == "passdb":
|
||||
user = args[1]
|
||||
if user.endswith(f"@{config.mail_domain}"):
|
||||
res = lookup_passdb(db, config, user, cleartext_password=args[0])
|
||||
if res:
|
||||
@@ -215,29 +122,7 @@ def handle_dovecot_request(msg, db, config: Config):
|
||||
reply_command = "N"
|
||||
json_res = json.dumps(res) if res else ""
|
||||
return f"{reply_command}{json_res}\n"
|
||||
elif short_command == "I": # ITERATE
|
||||
# example: I0\t0\tshared/userdb/
|
||||
parts = msg[1:].split("\t")
|
||||
if parts[2] == "shared/userdb/":
|
||||
result = "".join(f"Oshared/userdb/{user}\t\n" for user in iter_userdb(db))
|
||||
return f"{result}\n"
|
||||
|
||||
raise UnknownCommand(msg)
|
||||
|
||||
|
||||
def handle_dovecot_protocol(rfile, wfile, db: Database, config: Config):
|
||||
while True:
|
||||
msg = rfile.readline().strip().decode()
|
||||
if not msg:
|
||||
break
|
||||
try:
|
||||
res = handle_dovecot_request(msg, db, config)
|
||||
except UnknownCommand:
|
||||
logging.warning("unknown command: %r", msg)
|
||||
else:
|
||||
if res:
|
||||
wfile.write(res.encode("ascii"))
|
||||
wfile.flush()
|
||||
return None
|
||||
|
||||
|
||||
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
||||
@@ -245,14 +130,24 @@ class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
||||
|
||||
|
||||
def main():
|
||||
socket, cfgpath = sys.argv[1:]
|
||||
config = read_config(cfgpath)
|
||||
db = Database(config.passdb_path)
|
||||
socket = sys.argv[1]
|
||||
passwd_entry = pwd.getpwnam(sys.argv[2])
|
||||
db = Database(sys.argv[3])
|
||||
config = read_config(sys.argv[4])
|
||||
|
||||
class Handler(StreamRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
handle_dovecot_protocol(self.rfile, self.wfile, db, config)
|
||||
while True:
|
||||
msg = self.rfile.readline().strip().decode()
|
||||
if not msg:
|
||||
break
|
||||
res = handle_dovecot_request(msg, db, config)
|
||||
if res:
|
||||
self.wfile.write(res.encode("ascii"))
|
||||
self.wfile.flush()
|
||||
else:
|
||||
logging.warn("request had no answer: %r", msg)
|
||||
except Exception:
|
||||
logging.exception("Exception in the handler")
|
||||
raise
|
||||
@@ -263,6 +158,7 @@ def main():
|
||||
pass
|
||||
|
||||
with ThreadedUnixStreamServer(socket, Handler) as server:
|
||||
os.chown(socket, uid=passwd_entry.pw_uid, gid=passwd_entry.pw_gid)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
|
||||
@@ -2,11 +2,9 @@
|
||||
Description=Chatmail dict authentication proxy for dovecot
|
||||
|
||||
[Service]
|
||||
ExecStart={execpath} /run/doveauth/doveauth.socket {config_path}
|
||||
ExecStart={execpath} /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite {config_path}
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
User=vmail
|
||||
RuntimeDirectory=doveauth
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,104 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Advanced echo bot example.
|
||||
|
||||
it will echo back any message that has non-empty text and also supports the /help command.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events
|
||||
|
||||
from chatmaild.config import read_config
|
||||
from chatmaild.newemail import create_newemail_dict
|
||||
|
||||
hooks = events.HookCollection()
|
||||
|
||||
|
||||
@hooks.on(events.RawEvent)
|
||||
def log_event(event):
|
||||
if event.kind == EventType.INFO:
|
||||
logging.info("%s", event.msg)
|
||||
elif event.kind == EventType.WARNING:
|
||||
logging.warning("%s", event.msg)
|
||||
|
||||
|
||||
@hooks.on(events.RawEvent(EventType.ERROR))
|
||||
def log_error(event):
|
||||
logging.error("%s", event.msg)
|
||||
|
||||
|
||||
@hooks.on(events.MemberListChanged)
|
||||
def on_memberlist_changed(event):
|
||||
logging.info(
|
||||
"member %s was %s", event.member, "added" if event.member_added else "removed"
|
||||
)
|
||||
|
||||
|
||||
@hooks.on(events.GroupImageChanged)
|
||||
def on_group_image_changed(event):
|
||||
logging.info("group image %s", "deleted" if event.image_deleted else "changed")
|
||||
|
||||
|
||||
@hooks.on(events.GroupNameChanged)
|
||||
def on_group_name_changed(event):
|
||||
logging.info("group name changed, old name: %s", event.old_name)
|
||||
|
||||
|
||||
@hooks.on(events.NewMessage(func=lambda e: not e.command))
|
||||
def echo(event):
|
||||
snapshot = event.message_snapshot
|
||||
if snapshot.is_info:
|
||||
# Ignore info messages
|
||||
return
|
||||
if snapshot.text or snapshot.file:
|
||||
snapshot.chat.send_message(text=snapshot.text, file=snapshot.file)
|
||||
|
||||
|
||||
@hooks.on(events.NewMessage(command="/help"))
|
||||
def help_command(event):
|
||||
snapshot = event.message_snapshot
|
||||
snapshot.chat.send_text("Send me any message and I will echo it back")
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
path = os.environ.get("PATH")
|
||||
venv_path = sys.argv[0].strip("echobot")
|
||||
os.environ["PATH"] = path + ":" + venv_path
|
||||
with Rpc() as rpc:
|
||||
deltachat = DeltaChat(rpc)
|
||||
system_info = deltachat.get_system_info()
|
||||
logging.info("Running deltachat core %s", system_info.deltachat_core_version)
|
||||
|
||||
accounts = deltachat.get_all_accounts()
|
||||
account = accounts[0] if accounts else deltachat.add_account()
|
||||
|
||||
bot = Bot(account, hooks)
|
||||
|
||||
config = read_config(sys.argv[1])
|
||||
|
||||
# Create password file
|
||||
if bot.is_configured():
|
||||
password = bot.account.get_config("mail_pw")
|
||||
else:
|
||||
password = create_newemail_dict(config)["password"]
|
||||
Path("/run/echobot/password").write_text(password)
|
||||
|
||||
# Give the user which doveauth runs as access to the password file.
|
||||
subprocess.run(
|
||||
["/usr/bin/setfacl", "-m", "user:vmail:r", "/run/echobot/password"],
|
||||
check=True,
|
||||
)
|
||||
|
||||
if not bot.is_configured():
|
||||
email = "echo@" + config.mail_domain
|
||||
bot.configure(email, password)
|
||||
bot.run_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,36 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from contextlib import contextmanager
|
||||
|
||||
import filelock
|
||||
|
||||
|
||||
class FileDict:
|
||||
"""Concurrency-safe multi-reader/single-writer persistent dict."""
|
||||
|
||||
def __init__(self, path):
|
||||
self.path = path
|
||||
self.lock_path = path.with_name(path.name + ".lock")
|
||||
|
||||
@contextmanager
|
||||
def modify(self):
|
||||
# the OS will release the lock if the process dies,
|
||||
# and the contextmanager will otherwise guarantee release
|
||||
with filelock.FileLock(self.lock_path):
|
||||
data = self.read()
|
||||
yield data
|
||||
write_path = self.path.with_name(self.path.name + ".tmp")
|
||||
with write_path.open("w") as f:
|
||||
json.dump(data, f)
|
||||
os.rename(write_path, self.path)
|
||||
|
||||
def read(self):
|
||||
try:
|
||||
with self.path.open("r") as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except Exception:
|
||||
logging.warning("corrupt serialization state at: %r", self.path)
|
||||
return {}
|
||||
@@ -1,146 +1,68 @@
|
||||
#!/usr/bin/env python3
|
||||
import asyncio
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from email import policy
|
||||
import sys
|
||||
from email.parser import BytesParser
|
||||
from email import policy
|
||||
from email.utils import parseaddr
|
||||
from smtplib import SMTP as SMTPClient
|
||||
|
||||
from aiosmtpd.controller import Controller
|
||||
from smtplib import SMTP as SMTPClient
|
||||
|
||||
from .config import read_config
|
||||
|
||||
|
||||
def check_openpgp_payload(payload: bytes):
|
||||
"""Checks the OpenPGP payload.
|
||||
|
||||
OpenPGP payload must consist only of PKESK and SKESK packets
|
||||
terminated by a single SEIPD packet.
|
||||
|
||||
Returns True if OpenPGP payload is correct,
|
||||
False otherwise.
|
||||
|
||||
May raise IndexError while trying to read OpenPGP packet header
|
||||
if it is truncated.
|
||||
"""
|
||||
i = 0
|
||||
while i < len(payload):
|
||||
# Only OpenPGP format is allowed.
|
||||
if payload[i] & 0xC0 != 0xC0:
|
||||
return False
|
||||
|
||||
packet_type_id = payload[i] & 0x3F
|
||||
i += 1
|
||||
if payload[i] < 192:
|
||||
# One-octet length.
|
||||
body_len = payload[i]
|
||||
i += 1
|
||||
elif payload[i] < 224:
|
||||
# Two-octet length.
|
||||
body_len = ((payload[i] - 192) << 8) + payload[i + 1] + 192
|
||||
i += 2
|
||||
elif payload[i] == 255:
|
||||
# Five-octet length.
|
||||
body_len = (
|
||||
(payload[i + 1] << 24)
|
||||
| (payload[i + 2] << 16)
|
||||
| (payload[i + 3] << 8)
|
||||
| payload[i + 4]
|
||||
)
|
||||
i += 5
|
||||
else:
|
||||
# Partial body length is not allowed.
|
||||
return False
|
||||
|
||||
i += body_len
|
||||
|
||||
if i == len(payload):
|
||||
if packet_type_id == 18:
|
||||
# Last packet should be
|
||||
# Symmetrically Encrypted and Integrity Protected Data Packet (SEIPD)
|
||||
return True
|
||||
elif packet_type_id not in [1, 3]:
|
||||
# All packets except the last one must be either
|
||||
# Public-Key Encrypted Session Key Packet (PKESK)
|
||||
# or
|
||||
# Symmetric-Key Encrypted Session Key Packet (SKESK)
|
||||
return False
|
||||
|
||||
if i == 0:
|
||||
return False
|
||||
|
||||
if i > len(payload):
|
||||
# Payload is truncated.
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def check_armored_payload(payload: str):
|
||||
prefix = "-----BEGIN PGP MESSAGE-----\r\n\r\n"
|
||||
if not payload.startswith(prefix):
|
||||
return False
|
||||
payload = payload.removeprefix(prefix)
|
||||
|
||||
suffix = "-----END PGP MESSAGE-----\r\n\r\n"
|
||||
if not payload.endswith(suffix):
|
||||
return False
|
||||
payload = payload.removesuffix(suffix)
|
||||
|
||||
# Remove CRC24.
|
||||
payload = payload.rpartition("=")[0]
|
||||
|
||||
try:
|
||||
payload = base64.b64decode(payload)
|
||||
except binascii.Error:
|
||||
return False
|
||||
|
||||
try:
|
||||
return check_openpgp_payload(payload)
|
||||
except IndexError:
|
||||
return False
|
||||
|
||||
|
||||
def check_encrypted(message):
|
||||
"""Check that the message is an OpenPGP-encrypted message.
|
||||
|
||||
MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
|
||||
"""
|
||||
"""Check that the message is an OpenPGP-encrypted message."""
|
||||
if not message.is_multipart():
|
||||
return False
|
||||
if message.get("subject") not in {"...", "[...]"}:
|
||||
if message.get("subject") != "...":
|
||||
return False
|
||||
if message.get_content_type() != "multipart/encrypted":
|
||||
return False
|
||||
parts_count = 0
|
||||
for part in message.iter_parts():
|
||||
# We explicitly check Content-Type of each part later,
|
||||
# but this is to be absolutely sure `get_payload()` returns string and not list.
|
||||
if part.is_multipart():
|
||||
return False
|
||||
|
||||
if parts_count == 0:
|
||||
if part.get_content_type() != "application/pgp-encrypted":
|
||||
return False
|
||||
|
||||
payload = part.get_payload()
|
||||
if payload.strip() != "Version: 1":
|
||||
return False
|
||||
elif parts_count == 1:
|
||||
if part.get_content_type() != "application/octet-stream":
|
||||
return False
|
||||
|
||||
if not check_armored_payload(part.get_payload()):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
parts_count += 1
|
||||
return True
|
||||
|
||||
|
||||
def check_mdn(message, envelope):
|
||||
if len(envelope.rcpt_tos) != 1:
|
||||
return False
|
||||
|
||||
for name in ["auto-submitted", "chat-version"]:
|
||||
if not message.get(name):
|
||||
return False
|
||||
|
||||
if message.get_content_type() != "multipart/report":
|
||||
return False
|
||||
|
||||
body = message.get_body()
|
||||
if body.get_content_type() != "text/plain":
|
||||
return False
|
||||
|
||||
if list(body.iter_attachments()) or list(body.iter_parts()):
|
||||
return False
|
||||
|
||||
# even with all mime-structural checks an attacker
|
||||
# could try to abuse the subject or body to contain links or other
|
||||
# annoyance -- we skip on checking subject/body for now as Delta Chat
|
||||
# should evolve to create E2E-encrypted read receipts anyway.
|
||||
# and then MDNs are just encrypted mail and can pass the border
|
||||
# to other instances.
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def asyncmain_beforequeue(config):
|
||||
port = config.filtermail_smtp_port
|
||||
Controller(BeforeQueueHandler(config), hostname="127.0.0.1", port=port).start()
|
||||
@@ -152,7 +74,7 @@ class BeforeQueueHandler:
|
||||
self.send_rate_limiter = SendRateLimiter()
|
||||
|
||||
async def handle_MAIL(self, server, session, envelope, address, mail_options):
|
||||
logging.info("handle_MAIL from %s", address)
|
||||
logging.info(f"handle_MAIL from {address}")
|
||||
envelope.mail_from = address
|
||||
max_sent = self.config.max_user_send_per_minute
|
||||
if not self.send_rate_limiter.is_sending_allowed(address, max_sent):
|
||||
@@ -176,16 +98,19 @@ class BeforeQueueHandler:
|
||||
|
||||
def check_DATA(self, envelope):
|
||||
"""the central filtering function for e-mails."""
|
||||
logging.info("Processing DATA message from %s", envelope.mail_from)
|
||||
logging.info(f"Processing DATA message from {envelope.mail_from}")
|
||||
|
||||
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
|
||||
mail_encrypted = check_encrypted(message)
|
||||
|
||||
_, from_addr = parseaddr(message.get("from").strip())
|
||||
logging.info("mime-from: %s envelope-from: %r", from_addr, envelope.mail_from)
|
||||
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}")
|
||||
if envelope.mail_from.lower() != from_addr.lower():
|
||||
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
|
||||
|
||||
if not mail_encrypted and check_mdn(message, envelope):
|
||||
return
|
||||
|
||||
if envelope.mail_from in self.config.passthrough_senders:
|
||||
return
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[Unit]
|
||||
Description=Chatmail Postfix before queue filter
|
||||
Description=Chatmail Postfix BeforeQeue filter
|
||||
|
||||
[Service]
|
||||
ExecStart={execpath} {config_path}
|
||||
@@ -8,20 +8,17 @@ mail_domain = {mail_domain}
|
||||
#
|
||||
|
||||
#
|
||||
# Restrictions on user addresses
|
||||
# Account Restrictions
|
||||
#
|
||||
|
||||
# how many mails a user can send out per minute
|
||||
max_user_send_per_minute = 60
|
||||
|
||||
# maximum mailbox size of a chatmail address
|
||||
# maximum mailbox size of a chatmail account
|
||||
max_mailbox_size = 100M
|
||||
|
||||
# days after which mails are unconditionally deleted
|
||||
delete_mails_after = 20
|
||||
|
||||
# days after which users without a login are deleted (database and mails)
|
||||
delete_inactive_users_after = 100
|
||||
# time after which seen mails are deleted
|
||||
delete_mails_after = 40d
|
||||
|
||||
# minimum length a username must have
|
||||
username_min_length = 9
|
||||
@@ -32,22 +29,16 @@ username_max_length = 9
|
||||
# minimum length a password must have
|
||||
password_min_length = 9
|
||||
|
||||
# list of chatmail addresses which can send outbound un-encrypted mail
|
||||
# list of chatmail accounts which can send outbound un-encrypted mail
|
||||
passthrough_senders =
|
||||
|
||||
# list of e-mail recipients for which to accept outbound un-encrypted mails
|
||||
passthrough_recipients = xstore@testrun.org groupsbot@hispanilandia.net
|
||||
passthrough_recipients =
|
||||
|
||||
#
|
||||
# Deployment Details
|
||||
#
|
||||
|
||||
# Directory where user mailboxes are stored
|
||||
mailboxes_dir = /home/vmail/mail/{mail_domain}
|
||||
|
||||
# user address sqlite database path
|
||||
passdb_path = /home/vmail/passdb.sqlite
|
||||
|
||||
# where the filtermail SMTP service listens
|
||||
filtermail_smtp_port = 10080
|
||||
|
||||
@@ -69,3 +60,4 @@ privacy_pdo =
|
||||
|
||||
# postal address of the privacy supervisor
|
||||
privacy_supervisor =
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[privacy]
|
||||
|
||||
passthrough_recipients = privacy@testrun.org xstore@testrun.org groupsbot@hispanilandia.net
|
||||
passthrough_recipients = privacy@testrun.org
|
||||
|
||||
privacy_postal =
|
||||
Merlinux GmbH, Represented by the managing director H. Krekel,
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from socketserver import (
|
||||
StreamRequestHandler,
|
||||
ThreadingMixIn,
|
||||
UnixStreamServer,
|
||||
)
|
||||
|
||||
from .config import read_config
|
||||
from .filedict import FileDict
|
||||
from .notifier import Notifier
|
||||
|
||||
DICTPROXY_HELLO_CHAR = "H"
|
||||
DICTPROXY_LOOKUP_CHAR = "L"
|
||||
DICTPROXY_ITERATE_CHAR = "I"
|
||||
DICTPROXY_BEGIN_TRANSACTION_CHAR = "B"
|
||||
DICTPROXY_SET_CHAR = "S"
|
||||
DICTPROXY_COMMIT_TRANSACTION_CHAR = "C"
|
||||
DICTPROXY_TRANSACTION_CHARS = "BSC"
|
||||
|
||||
|
||||
class Metadata:
|
||||
# each SETMETADATA on this key appends to a list of unique device tokens
|
||||
# which only ever get removed if the upstream indicates the token is invalid
|
||||
DEVICETOKEN_KEY = "devicetoken"
|
||||
|
||||
def __init__(self, vmail_dir):
|
||||
self.vmail_dir = vmail_dir
|
||||
|
||||
def get_metadata_dict(self, addr):
|
||||
return FileDict(self.vmail_dir / addr / "metadata.json")
|
||||
|
||||
def add_token_to_addr(self, addr, token):
|
||||
with self.get_metadata_dict(addr).modify() as data:
|
||||
tokens = data.setdefault(self.DEVICETOKEN_KEY, [])
|
||||
if token not in tokens:
|
||||
tokens.append(token)
|
||||
|
||||
def remove_token_from_addr(self, addr, token):
|
||||
with self.get_metadata_dict(addr).modify() as data:
|
||||
tokens = data.get(self.DEVICETOKEN_KEY, [])
|
||||
if token in tokens:
|
||||
tokens.remove(token)
|
||||
|
||||
def get_tokens_for_addr(self, addr):
|
||||
mdict = self.get_metadata_dict(addr).read()
|
||||
return mdict.get(self.DEVICETOKEN_KEY, [])
|
||||
|
||||
|
||||
def handle_dovecot_protocol(rfile, wfile, notifier, metadata, iroh_relay=None):
|
||||
transactions = {}
|
||||
while True:
|
||||
msg = rfile.readline().strip().decode()
|
||||
if not msg:
|
||||
break
|
||||
|
||||
res = handle_dovecot_request(msg, transactions, notifier, metadata, iroh_relay)
|
||||
if res:
|
||||
wfile.write(res.encode("ascii"))
|
||||
wfile.flush()
|
||||
|
||||
|
||||
def handle_dovecot_request(msg, transactions, notifier, metadata, iroh_relay=None):
|
||||
# see https://doc.dovecot.org/3.0/developer_manual/design/dict_protocol/
|
||||
short_command = msg[0]
|
||||
parts = msg[1:].split("\t")
|
||||
if short_command == DICTPROXY_LOOKUP_CHAR:
|
||||
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
|
||||
keyparts = parts[0].split("/", 2)
|
||||
if keyparts[0] == "priv":
|
||||
keyname = keyparts[2]
|
||||
addr = parts[1]
|
||||
if keyname == metadata.DEVICETOKEN_KEY:
|
||||
res = " ".join(metadata.get_tokens_for_addr(addr))
|
||||
return f"O{res}\n"
|
||||
elif keyparts[0] == "shared":
|
||||
keyname = keyparts[2]
|
||||
if (
|
||||
keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay"
|
||||
and iroh_relay
|
||||
):
|
||||
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
|
||||
return f"O{iroh_relay}\n"
|
||||
logging.warning("lookup ignored: %r", msg)
|
||||
return "N\n"
|
||||
elif short_command == DICTPROXY_ITERATE_CHAR:
|
||||
# Empty line means ITER_FINISHED.
|
||||
# If we don't return empty line Dovecot will timeout.
|
||||
return "\n"
|
||||
elif short_command == DICTPROXY_HELLO_CHAR:
|
||||
return # no version checking
|
||||
|
||||
if short_command not in (DICTPROXY_TRANSACTION_CHARS):
|
||||
logging.warning("unknown dictproxy request: %r", msg)
|
||||
return
|
||||
|
||||
transaction_id = parts[0]
|
||||
|
||||
if short_command == DICTPROXY_BEGIN_TRANSACTION_CHAR:
|
||||
addr = parts[1]
|
||||
transactions[transaction_id] = dict(addr=addr, res="O\n")
|
||||
elif short_command == DICTPROXY_COMMIT_TRANSACTION_CHAR:
|
||||
# each set devicetoken operation persists directly
|
||||
# and does not wait until a "commit" comes
|
||||
# because our dovecot config does not involve
|
||||
# multiple set-operations in a single commit
|
||||
return transactions.pop(transaction_id)["res"]
|
||||
elif short_command == DICTPROXY_SET_CHAR:
|
||||
# For documentation on key structure see
|
||||
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
|
||||
|
||||
keyname = parts[1].split("/")
|
||||
value = parts[2] if len(parts) > 2 else ""
|
||||
addr = transactions[transaction_id]["addr"]
|
||||
if keyname[0] == "priv" and keyname[2] == metadata.DEVICETOKEN_KEY:
|
||||
metadata.add_token_to_addr(addr, value)
|
||||
elif keyname[0] == "priv" and keyname[2] == "messagenew":
|
||||
notifier.new_message_for_addr(addr, metadata)
|
||||
else:
|
||||
# Transaction failed.
|
||||
transactions[transaction_id]["res"] = "F\n"
|
||||
|
||||
|
||||
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
|
||||
request_queue_size = 100
|
||||
|
||||
|
||||
def main():
|
||||
socket, config_path = sys.argv[1:]
|
||||
|
||||
config = read_config(config_path)
|
||||
iroh_relay = config.iroh_relay
|
||||
|
||||
vmail_dir = config.mailboxes_dir
|
||||
if not vmail_dir.exists():
|
||||
logging.error("vmail dir does not exist: %r", vmail_dir)
|
||||
return 1
|
||||
|
||||
queue_dir = vmail_dir / "pending_notifications"
|
||||
queue_dir.mkdir(exist_ok=True)
|
||||
metadata = Metadata(vmail_dir)
|
||||
notifier = Notifier(queue_dir)
|
||||
notifier.start_notification_threads(metadata.remove_token_from_addr)
|
||||
|
||||
class Handler(StreamRequestHandler):
|
||||
def handle(self):
|
||||
try:
|
||||
handle_dovecot_protocol(
|
||||
self.rfile, self.wfile, notifier, metadata, iroh_relay
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Exception in the dovecot dictproxy handler")
|
||||
raise
|
||||
|
||||
try:
|
||||
os.unlink(socket)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
with ThreadedUnixStreamServer(socket, Handler) as server:
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -1,30 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main(vmail_dir=None):
|
||||
if vmail_dir is None:
|
||||
vmail_dir = sys.argv[1]
|
||||
|
||||
accounts = 0
|
||||
ci_accounts = 0
|
||||
|
||||
for path in Path(vmail_dir).iterdir():
|
||||
accounts += 1
|
||||
if path.name[:3] in ("ci-", "ac_"):
|
||||
ci_accounts += 1
|
||||
|
||||
print("# HELP total number of accounts")
|
||||
print("# TYPE accounts gauge")
|
||||
print(f"accounts {accounts}")
|
||||
print("# HELP number of CI accounts")
|
||||
print("# TYPE ci_accounts gauge")
|
||||
print(f"ci_accounts {ci_accounts}")
|
||||
print("# HELP number of non-CI accounts")
|
||||
print("# TYPE nonci_accounts gauge")
|
||||
print(f"nonci_accounts {accounts - ci_accounts}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,25 +1,19 @@
|
||||
#!/usr/local/lib/chatmaild/venv/bin/python3
|
||||
|
||||
"""CGI script for creating new accounts."""
|
||||
""" CGI script for creating new accounts. """
|
||||
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from chatmaild.config import Config, read_config
|
||||
from chatmaild.config import read_config, Config
|
||||
|
||||
CONFIG_PATH = "/usr/local/lib/chatmaild/chatmail.ini"
|
||||
ALPHANUMERIC = string.ascii_lowercase + string.digits
|
||||
ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
|
||||
|
||||
|
||||
def create_newemail_dict(config: Config):
|
||||
user = "".join(random.choices(ALPHANUMERIC, k=config.username_min_length))
|
||||
password = "".join(
|
||||
secrets.choice(ALPHANUMERIC_PUNCT)
|
||||
for _ in range(config.password_min_length + 3)
|
||||
)
|
||||
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
|
||||
user = "".join(random.choices(alphanumeric, k=config.username_min_length))
|
||||
password = "".join(random.choices(alphanumeric, k=config.password_min_length + 3))
|
||||
return dict(email=f"{user}@{config.mail_domain}", password=f"{password}")
|
||||
|
||||
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
"""
|
||||
This modules provides notification machinery for transmitting device tokens to
|
||||
a central notification server which in turn contacts a phone provider's notification server
|
||||
to trigger Delta Chat apps to retrieve messages and provide instant notifications to users.
|
||||
|
||||
The Notifier class arranges the queuing of tokens in separate PriorityQueues
|
||||
from which NotifyThreads take and transmit them via HTTPS
|
||||
to the `notifications.delta.chat` service.
|
||||
The current lack of proper HTTP/2-support in Python leads us
|
||||
to use multiple threads and connections to the Rust-implemented `notifications.delta.chat`
|
||||
which itself uses HTTP/2 and thus only a single connection to phone-notification providers.
|
||||
|
||||
If a token fails to cause a successful notification
|
||||
it is moved to a retry-number specific PriorityQueue
|
||||
which handles all tokens that failed a particular number of times
|
||||
and which are scheduled for retry using exponential back-off timing.
|
||||
If a token notification would be scheduled more than DROP_DEADLINE seconds
|
||||
after its first attempt, it is dropped with a log error.
|
||||
|
||||
Note that tokens are completely opaque to the notification machinery here
|
||||
and will in the future be encrypted foreclosing all ability to distinguish
|
||||
which device token ultimately goes to which phone-provider notification service,
|
||||
or to understand the relation of "device tokens" and chatmail addresses.
|
||||
The meaning and format of tokens is basically a matter of Delta-Chat Core and
|
||||
the `notification.delta.chat` service.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from queue import PriorityQueue
|
||||
from threading import Thread
|
||||
from uuid import uuid4
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersistentQueueItem:
|
||||
path: Path
|
||||
addr: str
|
||||
start_ts: int
|
||||
token: str
|
||||
|
||||
def delete(self):
|
||||
self.path.unlink(missing_ok=True)
|
||||
|
||||
@classmethod
|
||||
def create(cls, queue_dir, addr, start_ts, token):
|
||||
queue_id = uuid4().hex
|
||||
start_ts = int(start_ts)
|
||||
path = queue_dir.joinpath(queue_id)
|
||||
tmp_path = path.with_name(path.name + ".tmp")
|
||||
tmp_path.write_text(f"{addr}\n{start_ts}\n{token}")
|
||||
os.rename(tmp_path, path)
|
||||
return cls(path, addr, start_ts, token)
|
||||
|
||||
@classmethod
|
||||
def read_from_path(cls, path):
|
||||
addr, start_ts, token = path.read_text().split("\n", maxsplit=2)
|
||||
return cls(path, addr, int(start_ts), token)
|
||||
|
||||
def __lt__(self, other):
|
||||
return self.start_ts < other.start_ts
|
||||
|
||||
|
||||
class Notifier:
|
||||
URL = "https://notifications.delta.chat/notify"
|
||||
CONNECTION_TIMEOUT = 60.0 # seconds until http-request is given up
|
||||
BASE_DELAY = 8.0 # base seconds for exponential back-off delay
|
||||
DROP_DEADLINE = 5 * 60 * 60 # drop notifications after 5 hours
|
||||
|
||||
def __init__(self, queue_dir):
|
||||
self.queue_dir = queue_dir
|
||||
max_tries = int(math.log(self.DROP_DEADLINE, self.BASE_DELAY)) + 1
|
||||
self.retry_queues = [PriorityQueue() for _ in range(max_tries)]
|
||||
|
||||
def compute_delay(self, retry_num):
|
||||
return 0 if retry_num == 0 else pow(self.BASE_DELAY, retry_num)
|
||||
|
||||
def new_message_for_addr(self, addr, metadata):
|
||||
start_ts = int(time.time())
|
||||
for token in metadata.get_tokens_for_addr(addr):
|
||||
queue_item = PersistentQueueItem.create(
|
||||
self.queue_dir, addr, start_ts, token
|
||||
)
|
||||
self.queue_for_retry(queue_item)
|
||||
|
||||
def requeue_persistent_queue_items(self):
|
||||
for queue_path in self.queue_dir.iterdir():
|
||||
if queue_path.name.endswith(".tmp"):
|
||||
logging.warning("removing spurious queue item: %r", queue_path)
|
||||
queue_path.unlink()
|
||||
continue
|
||||
queue_item = PersistentQueueItem.read_from_path(queue_path)
|
||||
self.queue_for_retry(queue_item)
|
||||
|
||||
def queue_for_retry(self, queue_item, retry_num=0):
|
||||
delay = self.compute_delay(retry_num)
|
||||
when = int(time.time()) + delay
|
||||
deadline = queue_item.start_ts + self.DROP_DEADLINE
|
||||
if retry_num >= len(self.retry_queues) or when > deadline:
|
||||
queue_item.delete()
|
||||
logging.error("notification exceeded deadline: %r", queue_item.token)
|
||||
return
|
||||
|
||||
self.retry_queues[retry_num].put((when, queue_item))
|
||||
|
||||
def start_notification_threads(self, remove_token_from_addr):
|
||||
self.requeue_persistent_queue_items()
|
||||
threads = {}
|
||||
for retry_num in range(len(self.retry_queues)):
|
||||
# use 4 threads for first-try tokens and less for subsequent tries
|
||||
num_threads = 4 if retry_num == 0 else 2
|
||||
threads[retry_num] = []
|
||||
for _ in range(num_threads):
|
||||
thread = NotifyThread(self, retry_num, remove_token_from_addr)
|
||||
threads[retry_num].append(thread)
|
||||
thread.start()
|
||||
return threads
|
||||
|
||||
|
||||
class NotifyThread(Thread):
|
||||
def __init__(self, notifier, retry_num, remove_token_from_addr):
|
||||
super().__init__(daemon=True)
|
||||
self.notifier = notifier
|
||||
self.retry_num = retry_num
|
||||
self.remove_token_from_addr = remove_token_from_addr
|
||||
|
||||
def stop(self):
|
||||
self.notifier.retry_queues[self.retry_num].put((None, None))
|
||||
|
||||
def run(self):
|
||||
requests_session = requests.Session()
|
||||
while self.retry_one(requests_session):
|
||||
pass
|
||||
|
||||
def retry_one(self, requests_session, sleep=time.sleep):
|
||||
when, queue_item = self.notifier.retry_queues[self.retry_num].get()
|
||||
if when is None:
|
||||
return False
|
||||
wait_time = when - int(time.time())
|
||||
if wait_time > 0:
|
||||
sleep(wait_time)
|
||||
self.perform_request_to_notification_server(requests_session, queue_item)
|
||||
return True
|
||||
|
||||
def perform_request_to_notification_server(self, requests_session, queue_item):
|
||||
timeout = self.notifier.CONNECTION_TIMEOUT
|
||||
token = queue_item.token
|
||||
try:
|
||||
res = requests_session.post(self.notifier.URL, data=token, timeout=timeout)
|
||||
except requests.exceptions.RequestException as e:
|
||||
res = e
|
||||
else:
|
||||
if res.status_code in (200, 410):
|
||||
if res.status_code == 410:
|
||||
self.remove_token_from_addr(queue_item.addr, token)
|
||||
queue_item.delete()
|
||||
return
|
||||
|
||||
logging.warning("Notification request failed: %r", res)
|
||||
self.notifier.queue_for_retry(queue_item, retry_num=self.retry_num + 1)
|
||||
@@ -1,6 +1,6 @@
|
||||
From: {from_addr}
|
||||
To: {to_addr}
|
||||
Subject: {subject}
|
||||
Subject: ...
|
||||
Date: Sun, 15 Oct 2023 16:43:21 +0000
|
||||
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
|
||||
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
From: {from_addr}
|
||||
|
||||
To: {to_addr}
|
||||
|
||||
Subject: ...
|
||||
|
||||
Date: Sun, 15 Oct 2023 16:43:21 +0000
|
||||
|
||||
Message-ID: <Mr.UVyJWZmkCKM.hGzNc6glBE_@c2.testrun.org>
|
||||
|
||||
In-Reply-To: <Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||
|
||||
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||
|
||||
<Mr.MvmCz-GQbi_.6FGRkhDf05c@c2.testrun.org>
|
||||
|
||||
Chat-Version: 1.0
|
||||
|
||||
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
|
||||
|
||||
keydata=xjMEZSwWjhYJKwYBBAHaRw8BAQdAQBEhqeJh0GueHB6kF/DUQqYCxARNBVokg/AzT+7LqH
|
||||
|
||||
rNFzxiYXJiYXpAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUsFo4CGwMECwkIBwYVCAkKCwID
|
||||
|
||||
FgIBFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX9A4AEAnHWHp49eBCMHK5t66gYPiW
|
||||
|
||||
XQuB1mwUjzGfYWB+0RXUoA/0xcQ3FbUNlGKW7Blp6eMFfViv6Mv2d3kNSXACB6nmcMzjgEZSwWjhIK
|
||||
|
||||
KwYBBAGXVQEFAQEHQBpY5L2M1XHo0uxf8SX1wNLBp/OVvidoWHQF2Jz+kJsUAwEIB8J4BBgWCAAgBQ
|
||||
|
||||
JlLBaOAhsMFiEEFTfUNvVnY3b9F7yHnmme1PfUhX8ACgkQnmme1PfUhX/INgEA37AJaNvruYsJVanP
|
||||
|
||||
IXnYw4CKd55UAwl8Zcy+M2diAbkA/0fHHcGV4r78hpbbL1Os52DPOdqYQRauIeJUeG+G6bQO
|
||||
|
||||
MIME-Version: 1.0
|
||||
|
||||
Content-Type: multipart/encrypted; protocol="application/pgp-encrypted";
|
||||
|
||||
boundary="YFrteb74qSXmggbOxZL9dRnhymywAi"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ Date: Sun, 15 Oct 2023 16:41:44 +0000
|
||||
Message-ID: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||
References: <Mr.3gckbNy5bch.uK3Hd2Ws6-w@c2.testrun.org>
|
||||
Chat-Version: 1.0
|
||||
Autocrypt: addr={from_addr}; prefer-encrypt=mutual;
|
||||
Autocrypt: addr=foobar@c2.testrun.org; prefer-encrypt=mutual;
|
||||
keydata=xjMEZSrw3hYJKwYBBAHaRw8BAQdAiEKNQFU28c6qsx4vo/JHdt73RXdjMOmByf/XsGiJ7m
|
||||
nNFzxmb29iYXJAYzIudGVzdHJ1bi5vcmc+wosEEBYIADMCGQEFAmUq8N4CGwMECwkIBwYVCAkKCwID
|
||||
FgIBFiEEGil0OvTIa6RngmCLUYNnEa9leJAACgkQUYNnEa9leJCX3gEAhm0MehE5byBBU1avPczr/I
|
||||
@@ -20,4 +20,4 @@ Content-Type: text/plain; charset=utf-8; format=flowed; delsp=no
|
||||
|
||||
Hi!
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import random
|
||||
import importlib.resources
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
from email import policy
|
||||
from email.parser import BytesParser
|
||||
from pathlib import Path
|
||||
|
||||
from email import policy
|
||||
import pytest
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
|
||||
from chatmaild.database import Database
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -16,11 +14,7 @@ def make_config(tmp_path):
|
||||
inipath = tmp_path.joinpath("chatmail.ini")
|
||||
|
||||
def make_conf(mail_domain):
|
||||
basedir = tmp_path.joinpath(f"vmail/{mail_domain}")
|
||||
basedir.mkdir(parents=True, exist_ok=True)
|
||||
passdb = tmp_path.joinpath("vmail/passdb.sqlite")
|
||||
overrides = dict(mailboxes_dir=str(basedir), passdb_path=str(passdb))
|
||||
write_initial_config(inipath, mail_domain, overrides=overrides)
|
||||
write_initial_config(inipath, mail_domain=mail_domain)
|
||||
return read_config(inipath)
|
||||
|
||||
return make_conf
|
||||
@@ -63,19 +57,12 @@ def db(tmpdir):
|
||||
|
||||
@pytest.fixture
|
||||
def maildata(request):
|
||||
try:
|
||||
datadir = importlib.resources.files(__package__).joinpath("mail-data")
|
||||
except TypeError:
|
||||
# in python3.9 or lower, the above doesn't work, so we get datadir this way:
|
||||
datadir = Path(os.getcwd()).joinpath("chatmaild/src/chatmaild/tests/mail-data")
|
||||
|
||||
datadir = importlib.resources.files(__package__).joinpath("mail-data")
|
||||
assert datadir.exists(), datadir
|
||||
|
||||
def maildata(name, from_addr, to_addr, subject="..."):
|
||||
# Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines.
|
||||
data = datadir.joinpath(name).read_bytes().decode()
|
||||
|
||||
text = data.format(from_addr=from_addr, to_addr=to_addr, subject=subject)
|
||||
def maildata(name, from_addr, to_addr):
|
||||
data = datadir.joinpath(name).read_text()
|
||||
text = data.format(from_addr=from_addr, to_addr=to_addr)
|
||||
return BytesParser(policy=policy.default).parsebytes(text.encode())
|
||||
|
||||
return maildata
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import pytest
|
||||
from chatmaild.config import read_config
|
||||
|
||||
|
||||
@@ -25,37 +24,9 @@ def test_read_config_testrun(make_config):
|
||||
assert config.postfix_reinject_port == 10025
|
||||
assert config.max_user_send_per_minute == 60
|
||||
assert config.max_mailbox_size == "100M"
|
||||
assert config.delete_mails_after == "20"
|
||||
assert config.delete_mails_after == "40d"
|
||||
assert config.username_min_length == 9
|
||||
assert config.username_max_length == 9
|
||||
assert config.password_min_length == 9
|
||||
assert "privacy@testrun.org" in config.passthrough_recipients
|
||||
assert config.passthrough_recipients == ["privacy@testrun.org"]
|
||||
assert config.passthrough_senders == []
|
||||
|
||||
|
||||
def test_config_userstate_paths(make_config, tmp_path):
|
||||
config = make_config("something.testrun.org")
|
||||
mailboxes_dir = config.mailboxes_dir
|
||||
passdb_path = config.passdb_path
|
||||
assert mailboxes_dir.name == "something.testrun.org"
|
||||
assert passdb_path.name == "passdb.sqlite"
|
||||
assert passdb_path.is_relative_to(tmp_path)
|
||||
assert config.mail_domain == "something.testrun.org"
|
||||
path = config.get_user_maildir("user1@something.testrun.org")
|
||||
assert not path.exists()
|
||||
assert path == mailboxes_dir.joinpath("user1@something.testrun.org")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
config.get_user_maildir("")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
config.get_user_maildir(None)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
config.get_user_maildir("../some@something.testrun.org")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
config.get_user_maildir("..")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
config.get_user_maildir(".")
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import time
|
||||
|
||||
from chatmaild.delete_inactive_users import delete_inactive_users
|
||||
from chatmaild.doveauth import lookup_passdb
|
||||
|
||||
|
||||
def test_remove_stale_users(db, example_config):
|
||||
new = time.time()
|
||||
old = new - (example_config.delete_inactive_users_after * 86400) - 1
|
||||
|
||||
def create_user(addr, last_login):
|
||||
lookup_passdb(db, example_config, addr, "q9mr3faue", last_login=last_login)
|
||||
md = example_config.get_user_maildir(addr)
|
||||
md.mkdir(parents=True)
|
||||
md.joinpath("cur").mkdir()
|
||||
md.joinpath("cur", "something").mkdir()
|
||||
|
||||
# create some stale and some new accounts
|
||||
to_remove = []
|
||||
for i in range(150):
|
||||
addr = f"oldold{i:03}@chat.example.org"
|
||||
create_user(addr, last_login=old)
|
||||
with db.read_connection() as conn:
|
||||
assert conn.get_user(addr)
|
||||
to_remove.append(addr)
|
||||
|
||||
remain = []
|
||||
for i in range(5):
|
||||
addr = f"newnew{i:03}@chat.example.org"
|
||||
create_user(addr, last_login=new)
|
||||
remain.append(addr)
|
||||
|
||||
# check pre and post-conditions for delete_inactive_users()
|
||||
|
||||
for addr in to_remove:
|
||||
assert example_config.get_user_maildir(addr).exists()
|
||||
|
||||
delete_inactive_users(db, example_config)
|
||||
|
||||
for p in example_config.mailboxes_dir.iterdir():
|
||||
assert not p.name.startswith("old")
|
||||
|
||||
for addr in to_remove:
|
||||
assert not example_config.get_user_maildir(addr).exists()
|
||||
with db.read_connection() as conn:
|
||||
assert not conn.get_user(addr)
|
||||
|
||||
for addr in remain:
|
||||
assert example_config.get_user_maildir(addr).exists()
|
||||
with db.read_connection() as conn:
|
||||
assert conn.get_user(addr)
|
||||
@@ -1,27 +1,17 @@
|
||||
import io
|
||||
import json
|
||||
import queue
|
||||
import pytest
|
||||
import threading
|
||||
import queue
|
||||
import traceback
|
||||
|
||||
import chatmaild.doveauth
|
||||
import pytest
|
||||
from chatmaild.doveauth import get_user_data, lookup_passdb, handle_dovecot_request
|
||||
from chatmaild.database import DBError
|
||||
from chatmaild.doveauth import (
|
||||
get_user_data,
|
||||
handle_dovecot_protocol,
|
||||
handle_dovecot_request,
|
||||
is_allowed_to_create,
|
||||
iter_userdb,
|
||||
iter_userdb_lastlogin_before,
|
||||
lookup_passdb,
|
||||
)
|
||||
from chatmaild.newemail import create_newemail_dict
|
||||
|
||||
|
||||
def test_basic(db, example_config):
|
||||
lookup_passdb(db, example_config, "asdf12345@chat.example.org", "q9mr3faue")
|
||||
data = get_user_data(db, example_config, "asdf12345@chat.example.org")
|
||||
data = get_user_data(db, "asdf12345@chat.example.org")
|
||||
assert data
|
||||
data2 = lookup_passdb(
|
||||
db, example_config, "asdf12345@chat.example.org", "q9mr3jewvadsfaue"
|
||||
@@ -29,49 +19,6 @@ def test_basic(db, example_config):
|
||||
assert data == data2
|
||||
|
||||
|
||||
def test_iterate_addresses(db, example_config):
|
||||
addresses = []
|
||||
|
||||
for i in range(10):
|
||||
addresses.append(f"asdf1234{i}@chat.example.org")
|
||||
lookup_passdb(db, example_config, addresses[-1], "q9mr3faue")
|
||||
res = iter_userdb(db)
|
||||
assert res == addresses
|
||||
|
||||
|
||||
def test_iterate_addresses_lastlogin_before(db, example_config):
|
||||
addresses = []
|
||||
|
||||
cutoff_date = 1000
|
||||
for i in range(10):
|
||||
addr = f"oldold{i:03}@chat.example.org"
|
||||
lookup_passdb(
|
||||
db, example_config, addr, "q9mr3faue", last_login=cutoff_date - 10
|
||||
)
|
||||
addresses.append(addr)
|
||||
|
||||
for i in range(5):
|
||||
addr = f"newnew{i:03}@chat.example.org"
|
||||
lookup_passdb(db, example_config, addr, "q9mr3faue", last_login=cutoff_date + i)
|
||||
|
||||
res = iter_userdb_lastlogin_before(db, cutoff_date)
|
||||
assert sorted(res) == sorted(addresses)
|
||||
|
||||
|
||||
def test_invalid_username_length(example_config):
|
||||
config = example_config
|
||||
config.username_min_length = 6
|
||||
config.username_max_length = 10
|
||||
password = create_newemail_dict(config)["password"]
|
||||
assert not is_allowed_to_create(config, f"a1234@{config.mail_domain}", password)
|
||||
assert is_allowed_to_create(config, f"012345@{config.mail_domain}", password)
|
||||
assert is_allowed_to_create(config, f"0123456@{config.mail_domain}", password)
|
||||
assert is_allowed_to_create(config, f"0123456789@{config.mail_domain}", password)
|
||||
assert not is_allowed_to_create(
|
||||
config, f"0123456789x@{config.mail_domain}", password
|
||||
)
|
||||
|
||||
|
||||
def test_dont_overwrite_password_on_wrong_login(db, example_config):
|
||||
"""Test that logging in with a different password doesn't create a new user"""
|
||||
res = lookup_passdb(
|
||||
@@ -90,7 +37,7 @@ def test_nocreate_file(db, monkeypatch, tmpdir, example_config):
|
||||
lookup_passdb(
|
||||
db, example_config, "newuser12@chat.example.org", "zequ0Aimuchoodaechik"
|
||||
)
|
||||
assert not get_user_data(db, example_config, "newuser12@chat.example.org")
|
||||
assert not get_user_data(db, "newuser12@chat.example.org")
|
||||
|
||||
|
||||
def test_db_version(db):
|
||||
@@ -105,49 +52,19 @@ def test_too_high_db_version(db):
|
||||
|
||||
|
||||
def test_handle_dovecot_request(db, example_config):
|
||||
# Test that password can contain ", ', \ and /
|
||||
msg = (
|
||||
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
|
||||
"Lshared/passdb/laksjdlaksjdlaksjdlk12j3l1k2j3123/"
|
||||
"some42123@chat.example.org\tsome42123@chat.example.org"
|
||||
)
|
||||
res = handle_dovecot_request(msg, db, example_config)
|
||||
assert res
|
||||
assert res[0] == "O" and res.endswith("\n")
|
||||
userdata = json.loads(res[1:].strip())
|
||||
assert userdata["home"].endswith("chat.example.org/some42123@chat.example.org")
|
||||
assert userdata["home"] == "/home/vmail/some42123@chat.example.org"
|
||||
assert userdata["uid"] == userdata["gid"] == "vmail"
|
||||
assert userdata["password"].startswith("{SHA512-CRYPT}")
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol_hello_is_skipped(db, example_config, caplog):
|
||||
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\n")
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, db, example_config)
|
||||
assert wfile.getvalue() == b""
|
||||
assert not caplog.messages
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol(db, example_config):
|
||||
rfile = io.BytesIO(
|
||||
b"H3\t2\t0\t\tauth\nLshared/userdb/foobar@chat.example.org\tfoobar@chat.example.org\n"
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, db, example_config)
|
||||
assert wfile.getvalue() == b"N\n"
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol_iterate(db, gencreds, example_config):
|
||||
lookup_passdb(db, example_config, "asdf00000@chat.example.org", "q9mr3faue")
|
||||
lookup_passdb(db, example_config, "asdf11111@chat.example.org", "q9mr3faue")
|
||||
rfile = io.BytesIO(b"H3\t2\t0\t\tauth\nI0\t0\tshared/userdb/")
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, db, example_config)
|
||||
lines = wfile.getvalue().decode("ascii").split("\n")
|
||||
assert lines[0] == "Oshared/userdb/asdf00000@chat.example.org\t"
|
||||
assert lines[1] == "Oshared/userdb/asdf11111@chat.example.org\t"
|
||||
assert not lines[2]
|
||||
|
||||
|
||||
def test_50_concurrent_lookups_different_accounts(db, gencreds, example_config):
|
||||
num_threads = 50
|
||||
req_per_thread = 5
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
from chatmaild.filedict import FileDict
|
||||
|
||||
|
||||
def test_basic(tmp_path):
|
||||
fdict = FileDict(tmp_path.joinpath("metadata"))
|
||||
assert fdict.read() == {}
|
||||
with fdict.modify() as d:
|
||||
d["devicetoken"] = [1, 2, 3]
|
||||
d["456"] = 4.2
|
||||
new = fdict.read()
|
||||
assert new["devicetoken"] == [1, 2, 3]
|
||||
assert new["456"] == 4.2
|
||||
|
||||
|
||||
def test_bad_marshal_file(tmp_path, caplog):
|
||||
fdict1 = FileDict(tmp_path.joinpath("metadata"))
|
||||
fdict1.path.write_bytes(b"l12k3l12k3l")
|
||||
assert fdict1.read() == {}
|
||||
assert "corrupt" in caplog.records[0].msg
|
||||
@@ -1,11 +1,12 @@
|
||||
import pytest
|
||||
from chatmaild.filtermail import (
|
||||
check_encrypted,
|
||||
BeforeQueueHandler,
|
||||
SendRateLimiter,
|
||||
check_armored_payload,
|
||||
check_encrypted,
|
||||
check_mdn,
|
||||
)
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def maildomain():
|
||||
@@ -54,33 +55,42 @@ def test_filtermail_no_encryption_detection(maildata):
|
||||
|
||||
|
||||
def test_filtermail_encryption_detection(maildata):
|
||||
for subject in ("...", "[...]"):
|
||||
msg = maildata(
|
||||
"encrypted.eml",
|
||||
from_addr="1@example.org",
|
||||
to_addr="2@example.org",
|
||||
subject=subject,
|
||||
)
|
||||
assert check_encrypted(msg)
|
||||
msg = maildata("encrypted.eml", from_addr="1@example.org", to_addr="2@example.org")
|
||||
assert check_encrypted(msg)
|
||||
|
||||
# if the subject is not a known encrypted subject value, it is not considered ac-encrypted
|
||||
# if the subject is not "..." it is not considered ac-encrypted
|
||||
msg.replace_header("Subject", "Click this link")
|
||||
assert not check_encrypted(msg)
|
||||
|
||||
|
||||
def test_filtermail_no_literal_packets(maildata):
|
||||
"""Test that literal OpenPGP packet is not considered an encrypted mail."""
|
||||
msg = maildata("literal.eml", from_addr="1@example.org", to_addr="2@example.org")
|
||||
assert not check_encrypted(msg)
|
||||
|
||||
|
||||
def test_filtermail_unencrypted_mdn(maildata, gencreds):
|
||||
"""Unencrypted MDNs should not pass."""
|
||||
def test_filtermail_is_mdn(maildata, gencreds, handler):
|
||||
from_addr = gencreds()[0]
|
||||
to_addr = gencreds()[0] + ".other"
|
||||
msg = maildata("mdn.eml", from_addr=from_addr, to_addr=to_addr)
|
||||
msg = maildata("mdn.eml", from_addr, to_addr)
|
||||
|
||||
assert not check_encrypted(msg)
|
||||
class env:
|
||||
mail_from = from_addr
|
||||
rcpt_tos = [to_addr]
|
||||
content = msg.as_bytes()
|
||||
|
||||
assert check_mdn(msg, env)
|
||||
print(msg.as_string())
|
||||
|
||||
assert not handler.check_DATA(env)
|
||||
|
||||
|
||||
def test_filtermail_to_multiple_recipients_no_mdn(maildata, gencreds):
|
||||
from_addr = gencreds()[0]
|
||||
to_addr = gencreds()[0] + ".other"
|
||||
thirdaddr = gencreds()[0]
|
||||
msg = maildata("mdn.eml", from_addr, to_addr)
|
||||
|
||||
class env:
|
||||
mail_from = from_addr
|
||||
rcpt_tos = [to_addr, thirdaddr]
|
||||
content = msg.as_bytes()
|
||||
|
||||
assert not check_mdn(msg, env)
|
||||
|
||||
|
||||
def test_send_rate_limiter():
|
||||
@@ -101,7 +111,7 @@ def test_excempt_privacy(maildata, gencreds, handler):
|
||||
handler.config.passthrough_recipients = [to_addr]
|
||||
false_to = "privacy@something.org"
|
||||
|
||||
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
|
||||
msg = maildata("plain.eml", from_addr, to_addr)
|
||||
|
||||
class env:
|
||||
mail_from = from_addr
|
||||
@@ -124,7 +134,7 @@ def test_passthrough_senders(gencreds, handler, maildata):
|
||||
to_addr = "recipient@something.org"
|
||||
handler.config.passthrough_senders = [acc1]
|
||||
|
||||
msg = maildata("plain.eml", from_addr=acc1, to_addr=to_addr)
|
||||
msg = maildata("plain.eml", acc1, to_addr)
|
||||
|
||||
class env:
|
||||
mail_from = acc1
|
||||
@@ -133,59 +143,3 @@ def test_passthrough_senders(gencreds, handler, maildata):
|
||||
|
||||
# assert that None/no error is returned
|
||||
assert not handler.check_DATA(envelope=env)
|
||||
|
||||
|
||||
def test_check_armored_payload():
|
||||
payload = """-----BEGIN PGP MESSAGE-----\r
|
||||
\r
|
||||
wU4DSqFx0d1yqAoSAQdAYkX/ZN/Az4B0k7X47zKyWrXxlDEdS3WOy0Yf2+GJTFgg\r
|
||||
Zk5ql0mLG8Ze+ZifCS0XMO4otlemSyJ0K1ZPdFMGzUDBTgNqzkFabxXoXRIBB0AM\r
|
||||
755wlX41X6Ay3KhnwBq7yEqSykVH6F3x11iHPKraLCAGZoaS8bKKNy/zg5slda1X\r
|
||||
pt14b4aC1VwtSnYhcRRELNLD/wE2TFif+g7poMmFY50VyMPLYjVP96Z5QCT4+z4H\r
|
||||
Ikh/pRRN8S3JNMrRJHc6prooSJmLcx47Y5un7VFy390MsJ+LiUJuQMDdYWRAinfs\r
|
||||
Ebm89Ezjm7F03qbFPXE0X4ZNzVXS/eKO0uhJQdiov/vmbn41rNtHmNpqjaO0vi5+\r
|
||||
sS9tR7yDUrIXiCUCN78eBLVioxtktsPZm5cDORbQWzv+7nmCEz9/JowCUcBVdCGn\r
|
||||
1ofOaH82JCAX/cRx08pLaDNj6iolVBsi56Dd+2bGxJOZOG2AMcEyz0pXY0dOAJCD\r
|
||||
iUThcQeGIdRnU3j8UBcnIEsjLu2+C+rrwMZQESMWKnJ0rnqTk0pK5kXScr6F/L0L\r
|
||||
UE49ccIexNm3xZvYr5drszr6wz3Tv5fdue87P4etBt90gF/Vzknck+g1LLlkzZkp\r
|
||||
d8dI0k2tOSPjUbDPnSy1x+X73WGpPZmj0kWT+RGvq0nH6UkJj3AQTG2qf1T8jK+3\r
|
||||
rTp3LR9vDkMwDjX4R8SA9c0wdnUzzr79OYQC9lTnzcx+fM6BBmgQ2GrS33jaFLp7\r
|
||||
L6/DFpCl5zhnPjM/2dKvMkw/Kd6XS/vjwsO405FQdjSDiQEEAZA+ZvAfcjdccbbU\r
|
||||
yCO+x0QNdeBsufDVnh3xvzuWy4CICdTQT4s1AWRPCzjOj+SGmx5WqCLWfsd8Ma0+\r
|
||||
w/C7SfTYu1FDQILLM+llpq1M/9GPley4QZ8JQjo262AyPXsPF/OW48uuZz0Db1xT\r
|
||||
Yh4iHBztj4VSdy7l2+IyaIf7cnL4EEBFxv/MwmVDXvDlxyvfAfIsd3D9SvJESzKZ\r
|
||||
VWDYwaocgeCN+ojKu1p885lu1EfRbX3fr3YO02K5/c2JYDkc0Py0W3wUP/J1XUax\r
|
||||
pbKpzwlkxEgtmzsGqsOfMJqBV3TNDrOA2uBsa+uBqP5MGYLZ49S/4v/bW9I01Cr1\r
|
||||
D2ZkV510Y1Vgo66WlP8mRqOTyt/5WRhPD+MxXdk67BNN/PmO6tMlVoJDuk+XwWPR\r
|
||||
t2TvNaND/yabT9eYI55Og4fzKD6RIjouUX8DvKLkm+7aXxVs2uuLQ3Jco3O82z55\r
|
||||
dbShU1jYsrw9oouXUz06MHPbkdhNbF/2hfhZ2qA31sNeovJw65iUv7sDKX3LVWgJ\r
|
||||
10jlywcDwqlU8CO7WC9lGixYTbnOkYZpXCGEl8e6Jbs79l42YFo4ogYpFK1NXFhV\r
|
||||
kOXRmDf/wmfj+c/ld3L2PkvwlgofhCudOQknZbo3ub1gjiTn7L+lMGHIj/3suMIl\r
|
||||
ID4EUxAXScIM1ZEz2fjtW5jATlqYcLjLTbf/olw6HFyPNH+9IssqXeZNKnGwPUB9\r
|
||||
3lTXsg0tpzl+x7F/2WjEw1DSNhjC0KnHt1vEYNMkUGDGFdN9y3ERLqX/FIgiASUb\r
|
||||
bTvAVupnAK3raBezGmhrs6LsQtLS9P0VvQiLU3uDhMqw8Z4SISLpcD+NnVBHzQqm\r
|
||||
6W5Qn/8xsCL6av18yUVTi2G3igt3QCNoYx9evt2ZcIkNoyyagUVjfZe5GHXh8Dnz\r
|
||||
GaBXW/hg3HlXLRGaQu4RYCzBMJILcO25OhZOg6jbkCLiEexQlm2e9krB5cXR49Al\r
|
||||
UN4fiB0KR9JyG2ayUdNJVkXZSZLnHyRgiaadlpUo16LVvw==\r
|
||||
=b5Kp\r
|
||||
-----END PGP MESSAGE-----\r
|
||||
\r
|
||||
"""
|
||||
|
||||
assert check_armored_payload(payload) == True
|
||||
|
||||
payload = """-----BEGIN PGP MESSAGE-----\r
|
||||
\r
|
||||
HELLOWORLD
|
||||
-----END PGP MESSAGE-----\r
|
||||
\r
|
||||
"""
|
||||
assert check_armored_payload(payload) == False
|
||||
|
||||
payload = """-----BEGIN PGP MESSAGE-----\r
|
||||
\r
|
||||
=njUN
|
||||
-----END PGP MESSAGE-----\r
|
||||
\r
|
||||
"""
|
||||
assert check_armored_payload(payload) == False
|
||||
|
||||
@@ -1,312 +0,0 @@
|
||||
import io
|
||||
import time
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from chatmaild.metadata import (
|
||||
Metadata,
|
||||
handle_dovecot_protocol,
|
||||
handle_dovecot_request,
|
||||
)
|
||||
from chatmaild.notifier import (
|
||||
Notifier,
|
||||
NotifyThread,
|
||||
PersistentQueueItem,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def notifier(metadata):
|
||||
queue_dir = metadata.vmail_dir.joinpath("pending_notifications")
|
||||
queue_dir.mkdir()
|
||||
return Notifier(queue_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def metadata(tmp_path):
|
||||
vmail_dir = tmp_path.joinpath("vmaildir")
|
||||
vmail_dir.mkdir()
|
||||
return Metadata(vmail_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testaddr():
|
||||
return "user.name@example.org"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def testaddr2():
|
||||
return "user2@example.org"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def token():
|
||||
return "01234"
|
||||
|
||||
|
||||
def get_mocked_requests(statuslist):
|
||||
class ReqMock:
|
||||
requests = []
|
||||
|
||||
def post(self, url, data, timeout):
|
||||
self.requests.append((url, data, timeout))
|
||||
res = statuslist.pop(0)
|
||||
if isinstance(res, Exception):
|
||||
raise res
|
||||
|
||||
class Result:
|
||||
status_code = res
|
||||
|
||||
return Result()
|
||||
|
||||
return ReqMock()
|
||||
|
||||
|
||||
def test_metadata_persistence(tmp_path, testaddr, testaddr2):
|
||||
metadata1 = Metadata(tmp_path)
|
||||
metadata2 = Metadata(tmp_path)
|
||||
assert not metadata1.get_tokens_for_addr(testaddr)
|
||||
assert not metadata2.get_tokens_for_addr(testaddr)
|
||||
|
||||
metadata1.add_token_to_addr(testaddr, "01234")
|
||||
metadata1.add_token_to_addr(testaddr2, "456")
|
||||
assert metadata2.get_tokens_for_addr(testaddr) == ["01234"]
|
||||
assert metadata2.get_tokens_for_addr(testaddr2) == ["456"]
|
||||
metadata2.remove_token_from_addr(testaddr, "01234")
|
||||
assert not metadata1.get_tokens_for_addr(testaddr)
|
||||
assert metadata1.get_tokens_for_addr(testaddr2) == ["456"]
|
||||
|
||||
|
||||
def test_remove_nonexisting(metadata, tmp_path, testaddr):
|
||||
metadata.add_token_to_addr(testaddr, "123")
|
||||
metadata.remove_token_from_addr(testaddr, "1l23k1l2k3")
|
||||
assert metadata.get_tokens_for_addr(testaddr) == ["123"]
|
||||
|
||||
|
||||
def test_notifier_remove_without_set(metadata, testaddr):
|
||||
metadata.remove_token_from_addr(testaddr, "123")
|
||||
assert not metadata.get_tokens_for_addr(testaddr)
|
||||
|
||||
|
||||
def test_handle_dovecot_request_lookup_fails(notifier, metadata, testaddr):
|
||||
res = handle_dovecot_request(
|
||||
f"Lpriv/123/chatmail\t{testaddr}", {}, notifier, metadata
|
||||
)
|
||||
assert res == "N\n"
|
||||
|
||||
|
||||
def test_handle_dovecot_request_happy_path(notifier, metadata, testaddr, token):
|
||||
transactions = {}
|
||||
|
||||
# set device token in a transaction
|
||||
tx = "1111"
|
||||
msg = f"B{tx}\t{testaddr}"
|
||||
res = handle_dovecot_request(msg, transactions, notifier, metadata)
|
||||
assert not res and not metadata.get_tokens_for_addr(testaddr)
|
||||
assert transactions == {tx: dict(addr=testaddr, res="O\n")}
|
||||
|
||||
msg = f"S{tx}\tpriv/guid00/devicetoken\t{token}"
|
||||
res = handle_dovecot_request(msg, transactions, notifier, metadata)
|
||||
assert not res
|
||||
assert len(transactions) == 1
|
||||
assert metadata.get_tokens_for_addr(testaddr) == [token]
|
||||
|
||||
msg = f"C{tx}"
|
||||
res = handle_dovecot_request(msg, transactions, notifier, metadata)
|
||||
assert res == "O\n"
|
||||
assert len(transactions) == 0
|
||||
assert metadata.get_tokens_for_addr(testaddr) == [token]
|
||||
|
||||
# trigger notification for incoming message
|
||||
tx2 = "2222"
|
||||
assert (
|
||||
handle_dovecot_request(f"B{tx2}\t{testaddr}", transactions, notifier, metadata)
|
||||
is None
|
||||
)
|
||||
msg = f"S{tx2}\tpriv/guid00/messagenew"
|
||||
assert handle_dovecot_request(msg, transactions, notifier, metadata) is None
|
||||
queue_item = notifier.retry_queues[0].get()[1]
|
||||
assert queue_item.token == token
|
||||
assert handle_dovecot_request(f"C{tx2}", transactions, notifier, metadata) == "O\n"
|
||||
assert not transactions
|
||||
assert queue_item.path.exists()
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol_set_devicetoken(metadata, notifier):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
[
|
||||
b"HELLO",
|
||||
b"Btx00\tuser@example.org",
|
||||
b"Stx00\tpriv/guid00/devicetoken\t01234",
|
||||
b"Ctx00",
|
||||
]
|
||||
)
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
|
||||
assert wfile.getvalue() == b"O\n"
|
||||
assert metadata.get_tokens_for_addr("user@example.org") == ["01234"]
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol_set_get_devicetoken(metadata, notifier):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
[
|
||||
b"HELLO",
|
||||
b"Btx00\tuser@example.org",
|
||||
b"Stx00\tpriv/guid00/devicetoken\t01234",
|
||||
b"Ctx00",
|
||||
]
|
||||
)
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
|
||||
assert metadata.get_tokens_for_addr("user@example.org") == ["01234"]
|
||||
assert wfile.getvalue() == b"O\n"
|
||||
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join([b"HELLO", b"Lpriv/0123/devicetoken\tuser@example.org"])
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
|
||||
assert wfile.getvalue() == b"O01234\n"
|
||||
|
||||
|
||||
def test_handle_dovecot_protocol_iterate(metadata, notifier):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
[
|
||||
b"H",
|
||||
b"I9\t0\tpriv/5cbe730f146fea6535be0d003dd4fc98/\tci-2dzsrs@nine.testrun.org",
|
||||
]
|
||||
)
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata)
|
||||
assert wfile.getvalue() == b"\n"
|
||||
|
||||
|
||||
def test_notifier_thread_deletes_persistent_file(metadata, notifier, testaddr):
|
||||
reqmock = get_mocked_requests([200])
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
NotifyThread(notifier, 0, None).retry_one(reqmock)
|
||||
url, data, timeout = reqmock.requests[0]
|
||||
assert data == "01234"
|
||||
assert metadata.get_tokens_for_addr(testaddr) == ["01234"]
|
||||
notifier.requeue_persistent_queue_items()
|
||||
assert notifier.retry_queues[0].qsize() == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [requests.exceptions.RequestException(), 404, 500])
|
||||
def test_notifier_thread_connection_failures(
|
||||
metadata, notifier, testaddr, status, caplog
|
||||
):
|
||||
"""test that tokens keep getting retried until they are given up."""
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
notifier.NOTIFICATION_RETRY_DELAY = 5
|
||||
max_tries = len(notifier.retry_queues)
|
||||
for i in range(max_tries):
|
||||
caplog.clear()
|
||||
reqmock = get_mocked_requests([status])
|
||||
sleep_calls = []
|
||||
NotifyThread(notifier, i, None).retry_one(reqmock, sleep=sleep_calls.append)
|
||||
assert notifier.retry_queues[i].qsize() == 0
|
||||
assert "request failed" in caplog.records[0].msg
|
||||
if i > 0:
|
||||
assert len(sleep_calls) == 1
|
||||
if i + 1 < max_tries:
|
||||
assert notifier.retry_queues[i + 1].qsize() == 1
|
||||
assert len(caplog.records) == 1
|
||||
else:
|
||||
assert len(caplog.records) == 2
|
||||
assert "deadline" in caplog.records[1].msg
|
||||
notifier.requeue_persistent_queue_items()
|
||||
assert notifier.retry_queues[0].qsize() == 0
|
||||
|
||||
|
||||
def test_requeue_removes_tmp_files(notifier, metadata, testaddr, caplog):
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
p = notifier.queue_dir.joinpath("1203981203.tmp")
|
||||
p.touch()
|
||||
notifier2 = notifier.__class__(notifier.queue_dir)
|
||||
notifier2.requeue_persistent_queue_items()
|
||||
assert "spurious" in caplog.records[0].msg
|
||||
assert not p.exists()
|
||||
assert notifier2.retry_queues[0].qsize() == 1
|
||||
when, queue_item = notifier2.retry_queues[0].get()
|
||||
assert when <= int(time.time())
|
||||
assert queue_item.addr == testaddr
|
||||
|
||||
|
||||
def test_start_and_stop_notification_threads(notifier, testaddr):
|
||||
threads = notifier.start_notification_threads(None)
|
||||
for retry_num, threadlist in threads.items():
|
||||
for t in threadlist:
|
||||
t.stop()
|
||||
t.join()
|
||||
|
||||
|
||||
def test_multi_device_notifier(metadata, notifier, testaddr):
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
metadata.add_token_to_addr(testaddr, "56789")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
reqmock = get_mocked_requests([200, 200])
|
||||
NotifyThread(notifier, 0, None).retry_one(reqmock)
|
||||
NotifyThread(notifier, 0, None).retry_one(reqmock)
|
||||
assert notifier.retry_queues[0].qsize() == 0
|
||||
assert notifier.retry_queues[1].qsize() == 0
|
||||
url, data, timeout = reqmock.requests[0]
|
||||
assert data == "01234"
|
||||
url, data, timeout = reqmock.requests[1]
|
||||
assert data == "56789"
|
||||
assert metadata.get_tokens_for_addr(testaddr) == ["01234", "56789"]
|
||||
|
||||
|
||||
def test_notifier_thread_run_gone_removes_token(metadata, notifier, testaddr):
|
||||
metadata.add_token_to_addr(testaddr, "01234")
|
||||
metadata.add_token_to_addr(testaddr, "45678")
|
||||
notifier.new_message_for_addr(testaddr, metadata)
|
||||
|
||||
reqmock = get_mocked_requests([410, 200])
|
||||
NotifyThread(notifier, 0, metadata.remove_token_from_addr).retry_one(reqmock)
|
||||
NotifyThread(notifier, 0, None).retry_one(reqmock)
|
||||
url, data, timeout = reqmock.requests[0]
|
||||
assert data == "01234"
|
||||
url, data, timeout = reqmock.requests[1]
|
||||
assert data == "45678"
|
||||
assert metadata.get_tokens_for_addr(testaddr) == ["45678"]
|
||||
assert notifier.retry_queues[0].qsize() == 0
|
||||
assert notifier.retry_queues[1].qsize() == 0
|
||||
|
||||
|
||||
def test_persistent_queue_items(tmp_path, testaddr, token):
|
||||
queue_item = PersistentQueueItem.create(tmp_path, testaddr, 432, token)
|
||||
assert queue_item.addr == testaddr
|
||||
assert queue_item.start_ts == 432
|
||||
assert queue_item.token == token
|
||||
item2 = PersistentQueueItem.read_from_path(queue_item.path)
|
||||
assert item2.addr == testaddr
|
||||
assert item2.start_ts == 432
|
||||
assert item2.token == token
|
||||
assert item2 == queue_item
|
||||
item2.delete()
|
||||
assert not item2.path.exists()
|
||||
assert not queue_item < item2 and not item2 < queue_item
|
||||
|
||||
|
||||
def test_iroh_relay(metadata):
|
||||
rfile = io.BytesIO(
|
||||
b"\n".join(
|
||||
[
|
||||
b"H",
|
||||
b"Lshared/0123/vendor/vendor.dovecot/pvt/server/vendor/deltachat/irohrelay\tuser@example.org",
|
||||
]
|
||||
)
|
||||
)
|
||||
wfile = io.BytesIO()
|
||||
handle_dovecot_protocol(rfile, wfile, notifier, metadata, "https://example.org/")
|
||||
assert wfile.getvalue() == b"Ohttps://example.org/\n"
|
||||
@@ -1,17 +0,0 @@
|
||||
from chatmaild.metrics import main
|
||||
|
||||
|
||||
def test_main(tmp_path, capsys):
|
||||
for x in ("ci-asllkj", "ac_12l3kj", "qweqwe", "ci-l1k2j31l2k3"):
|
||||
tmp_path.joinpath(x).mkdir()
|
||||
main(tmp_path)
|
||||
out, _ = capsys.readouterr()
|
||||
d = {}
|
||||
for line in out.split("\n"):
|
||||
if line.strip() and not line.startswith("#"):
|
||||
name, num = line.split()
|
||||
d[name] = int(num)
|
||||
|
||||
assert d["accounts"] == 4
|
||||
assert d["ci_accounts"] == 3
|
||||
assert d["nonci_accounts"] == 1
|
||||
@@ -16,10 +16,9 @@ dependencies = [
|
||||
"build",
|
||||
"tox",
|
||||
"ruff",
|
||||
"black",
|
||||
"pytest",
|
||||
"pytest-xdist",
|
||||
"execnet",
|
||||
"imap_tools",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -31,13 +30,3 @@ cmdeploy = "cmdeploy.cmdeploy:main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "-v -ra --strict-markers"
|
||||
|
||||
[tool.ruff]
|
||||
lint.select = [
|
||||
"F", # Pyflakes
|
||||
"I", # isort
|
||||
|
||||
"PLC", # Pylint Convention
|
||||
"PLE", # Pylint Error
|
||||
"PLW", # Pylint Warning
|
||||
]
|
||||
|
||||
@@ -1,22 +1,21 @@
|
||||
"""
|
||||
Chat Mail pyinfra deploy.
|
||||
"""
|
||||
|
||||
import importlib.resources
|
||||
import io
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import importlib.resources
|
||||
import subprocess
|
||||
import shutil
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
from chatmaild.config import Config, read_config
|
||||
from pyinfra import host
|
||||
from pyinfra.operations import apt, files, server, systemd, pip
|
||||
from pyinfra.facts.files import File
|
||||
from pyinfra.facts.systemd import SystemdEnabled
|
||||
from pyinfra.operations import apt, files, pip, server, systemd
|
||||
|
||||
from .acmetool import deploy_acmetool
|
||||
|
||||
from chatmaild.config import read_config, Config
|
||||
|
||||
|
||||
def _build_chatmaild(dist_dir) -> None:
|
||||
dist_dir = Path(dist_dir).resolve()
|
||||
@@ -85,34 +84,16 @@ def _install_remote_venv_with_chatmaild(config) -> None:
|
||||
],
|
||||
)
|
||||
|
||||
files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("metrics.cron.j2"),
|
||||
dest="/etc/cron.d/chatmail-metrics",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
config={
|
||||
"mailboxes_dir": config.mailboxes_dir,
|
||||
"execpath": f"{remote_venv_dir}/bin/chatmail-metrics",
|
||||
},
|
||||
)
|
||||
|
||||
# install systemd units
|
||||
for fn in (
|
||||
"doveauth",
|
||||
"filtermail",
|
||||
"echobot",
|
||||
"chatmail-metadata",
|
||||
):
|
||||
params = dict(
|
||||
execpath=f"{remote_venv_dir}/bin/{fn}",
|
||||
config_path=remote_chatmail_inipath,
|
||||
remote_venv_dir=remote_venv_dir,
|
||||
mail_domain=config.mail_domain,
|
||||
)
|
||||
source_path = importlib.resources.files(__package__).joinpath(
|
||||
"service", f"{fn}.service.f"
|
||||
)
|
||||
source_path = importlib.resources.files("chatmaild").joinpath(f"{fn}.service.f")
|
||||
content = source_path.read_text().format(**params).encode()
|
||||
|
||||
files.put(
|
||||
@@ -131,7 +112,7 @@ def _install_remote_venv_with_chatmaild(config) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
|
||||
def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
|
||||
"""Configures OpenDKIM"""
|
||||
need_restart = False
|
||||
|
||||
@@ -145,24 +126,6 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
|
||||
)
|
||||
need_restart |= main_config.changed
|
||||
|
||||
screen_script = files.put(
|
||||
src=importlib.resources.files(__package__).joinpath("opendkim/screen.lua"),
|
||||
dest="/etc/opendkim/screen.lua",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
need_restart |= screen_script.changed
|
||||
|
||||
final_script = files.put(
|
||||
src=importlib.resources.files(__package__).joinpath("opendkim/final.lua"),
|
||||
dest="/etc/opendkim/final.lua",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
need_restart |= final_script.changed
|
||||
|
||||
files.directory(
|
||||
name="Add opendkim directory to /etc",
|
||||
path="/etc/opendkim",
|
||||
@@ -191,6 +154,7 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
|
||||
config={"domain_name": domain, "opendkim_selector": dkim_selector},
|
||||
)
|
||||
need_restart |= signing_table.changed
|
||||
|
||||
files.directory(
|
||||
name="Add opendkim socket directory to /var/spool/postfix",
|
||||
path="/var/spool/postfix/opendkim",
|
||||
@@ -200,11 +164,6 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
|
||||
present=True,
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name="apt install opendkim opendkim-tools",
|
||||
packages=["opendkim", "opendkim-tools"],
|
||||
)
|
||||
|
||||
if not host.get_fact(File, f"/etc/dkimkeys/{dkim_selector}.private"):
|
||||
server.shell(
|
||||
name="Generate OpenDKIM domain keys",
|
||||
@@ -281,27 +240,6 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
|
||||
)
|
||||
need_restart |= master_config.changed
|
||||
|
||||
header_cleanup = files.put(
|
||||
src=importlib.resources.files(__package__).joinpath(
|
||||
"postfix/submission_header_cleanup"
|
||||
),
|
||||
dest="/etc/postfix/submission_header_cleanup",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
need_restart |= header_cleanup.changed
|
||||
|
||||
# Login map that 1:1 maps email address to login.
|
||||
login_map = files.put(
|
||||
src=importlib.resources.files(__package__).joinpath("postfix/login_map"),
|
||||
dest="/etc/postfix/login_map",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
need_restart |= login_map.changed
|
||||
|
||||
return need_restart
|
||||
|
||||
|
||||
@@ -327,16 +265,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
|
||||
mode="644",
|
||||
)
|
||||
need_restart |= auth_config.changed
|
||||
lua_push_notification_script = files.put(
|
||||
src=importlib.resources.files(__package__).joinpath(
|
||||
"dovecot/push_notification.lua"
|
||||
),
|
||||
dest="/etc/dovecot/push_notification.lua",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
need_restart |= lua_push_notification_script.changed
|
||||
|
||||
files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("dovecot/expunge.cron.j2"),
|
||||
@@ -417,110 +345,41 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
|
||||
return need_restart
|
||||
|
||||
|
||||
def _remove_rspamd() -> None:
|
||||
"""Remove rspamd"""
|
||||
apt.packages(name="Remove rspamd", packages="rspamd", present=False)
|
||||
|
||||
|
||||
def check_config(config):
|
||||
mail_domain = config.mail_domain
|
||||
if mail_domain != "testrun.org" and not mail_domain.endswith(".testrun.org"):
|
||||
blocked_words = "merlinux schmieder testrun.org".split()
|
||||
for key in config.__dict__:
|
||||
value = config.__dict__[key]
|
||||
if key.startswith("privacy") and any(
|
||||
x in str(value) for x in blocked_words
|
||||
):
|
||||
for value in config.__dict__.values():
|
||||
if any(x in value for x in blocked_words):
|
||||
raise ValueError(
|
||||
f"please set your own privacy contacts/addresses in {config._inipath}"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
def deploy_chatmail(config_path: Path) -> None:
|
||||
def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> None:
|
||||
"""Deploy a chat-mail instance.
|
||||
|
||||
:param config_path: path to chatmail.ini
|
||||
:param mail_domain: domain part of your future email addresses
|
||||
:param mail_server: the DNS name under which your mail server is reachable
|
||||
:param dkim_selector:
|
||||
"""
|
||||
config = read_config(config_path)
|
||||
check_config(config)
|
||||
mail_domain = config.mail_domain
|
||||
|
||||
from .www import build_webpages
|
||||
|
||||
apt.update(name="apt update", cache_time=24 * 3600)
|
||||
server.group(name="Create vmail group", group="vmail", system=True)
|
||||
server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
|
||||
|
||||
server.group(name="Create opendkim group", group="opendkim", system=True)
|
||||
server.user(
|
||||
name="Create opendkim user",
|
||||
user="opendkim",
|
||||
groups=["opendkim"],
|
||||
system=True,
|
||||
)
|
||||
server.user(
|
||||
name="Add postfix user to opendkim group for socket access",
|
||||
user="postfix",
|
||||
groups=["opendkim"],
|
||||
system=True,
|
||||
)
|
||||
server.user(name="Create echobot user", user="echobot", system=True)
|
||||
|
||||
# Add our OBS repository for dovecot_no_delay
|
||||
files.put(
|
||||
name="Add Deltachat OBS GPG key to apt keyring",
|
||||
src=importlib.resources.files(__package__).joinpath("obs-home-deltachat.gpg"),
|
||||
dest="/etc/apt/keyrings/obs-home-deltachat.gpg",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
|
||||
files.line(
|
||||
name="Add DeltaChat OBS home repository to sources.list",
|
||||
path="/etc/apt/sources.list",
|
||||
line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
|
||||
escape_regex_characters=True,
|
||||
ensure_newline=True,
|
||||
)
|
||||
|
||||
apt.update(name="apt update", cache_time=24 * 3600)
|
||||
|
||||
apt.packages(
|
||||
name="Install rsync",
|
||||
packages=["rsync"],
|
||||
)
|
||||
|
||||
# Run local DNS resolver `unbound`.
|
||||
# `resolvconf` takes care of setting up /etc/resolv.conf
|
||||
# to use 127.0.0.1 as the resolver.
|
||||
apt.packages(
|
||||
name="Install unbound",
|
||||
packages=["unbound", "unbound-anchor", "dnsutils"],
|
||||
)
|
||||
server.shell(
|
||||
name="Generate root keys for validating DNSSEC",
|
||||
commands=[
|
||||
"unbound-anchor -a /var/lib/unbound/root.key || true",
|
||||
"systemctl reset-failed unbound.service",
|
||||
],
|
||||
)
|
||||
systemd.service(
|
||||
name="Start and enable unbound",
|
||||
service="unbound.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
)
|
||||
|
||||
# Deploy acmetool to have TLS certificates.
|
||||
deploy_acmetool(
|
||||
domains=[mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"],
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
# required for setfacl for echobot
|
||||
name="Install acl",
|
||||
packages="acl",
|
||||
)
|
||||
deploy_acmetool(nginx_hook=True, domains=[mail_server, f"mta-sts.{mail_server}"])
|
||||
|
||||
apt.packages(
|
||||
name="Install Postfix",
|
||||
@@ -532,9 +391,17 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
packages=["dovecot-imapd", "dovecot-lmtpd"],
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name="Install OpenDKIM",
|
||||
packages=[
|
||||
"opendkim",
|
||||
"opendkim-tools",
|
||||
],
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name="Install nginx",
|
||||
packages=["nginx", "libnginx-mod-stream"],
|
||||
packages=["nginx"],
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
@@ -542,7 +409,11 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
packages=["fcgiwrap"],
|
||||
)
|
||||
|
||||
www_path = importlib.resources.files(__package__).joinpath("../../../www").resolve()
|
||||
pkg_root = importlib.resources.files(__package__)
|
||||
chatmail_ini = pkg_root.joinpath("../../../chatmail.ini").resolve()
|
||||
config = read_config(chatmail_ini)
|
||||
check_config(config)
|
||||
www_path = pkg_root.joinpath("../../../www").resolve()
|
||||
|
||||
build_dir = www_path.joinpath("build")
|
||||
src_dir = www_path.joinpath("src")
|
||||
@@ -553,12 +424,10 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
debug = False
|
||||
dovecot_need_restart = _configure_dovecot(config, debug=debug)
|
||||
postfix_need_restart = _configure_postfix(config, debug=debug)
|
||||
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)
|
||||
mta_sts_need_restart = _install_mta_sts_daemon()
|
||||
nginx_need_restart = _configure_nginx(mail_domain)
|
||||
|
||||
_remove_rspamd()
|
||||
opendkim_need_restart = _configure_opendkim(mail_domain, "opendkim")
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable OpenDKIM",
|
||||
service="opendkim.service",
|
||||
@@ -576,17 +445,6 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
restarted=mta_sts_need_restart,
|
||||
)
|
||||
|
||||
# Dovecot should be started before Postfix
|
||||
# because it creates authentication socket
|
||||
# required by Postfix.
|
||||
systemd.service(
|
||||
name="Start and enable Dovecot",
|
||||
service="dovecot.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=dovecot_need_restart,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable Postfix",
|
||||
service="postfix.service",
|
||||
@@ -595,6 +453,14 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
restarted=postfix_need_restart,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable Dovecot",
|
||||
service="dovecot.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=dovecot_need_restart,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Start and enable nginx",
|
||||
service="nginx.service",
|
||||
@@ -623,10 +489,5 @@ def deploy_chatmail(config_path: Path) -> None:
|
||||
service="systemd-journald.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=journald_conf.changed,
|
||||
)
|
||||
|
||||
apt.packages(
|
||||
name="Ensure cron is installed",
|
||||
packages=["cron"],
|
||||
restarted=journald_conf,
|
||||
)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import importlib.resources
|
||||
|
||||
from pyinfra import host
|
||||
from pyinfra.facts.systemd import SystemdStatus
|
||||
from pyinfra.operations import apt, files, server, systemd
|
||||
from pyinfra.operations import apt, files, server
|
||||
|
||||
|
||||
def deploy_acmetool(email="", domains=[]):
|
||||
def deploy_acmetool(nginx_hook=False, email="", domains=[]):
|
||||
"""Deploy acmetool."""
|
||||
apt.packages(
|
||||
name="Install acmetool",
|
||||
@@ -20,13 +18,16 @@ def deploy_acmetool(email="", domains=[]):
|
||||
mode="644",
|
||||
)
|
||||
|
||||
files.put(
|
||||
src=importlib.resources.files(__package__).joinpath("acmetool.hook").open("rb"),
|
||||
dest="/usr/lib/acme/hooks/nginx",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="744",
|
||||
)
|
||||
if nginx_hook:
|
||||
files.put(
|
||||
src=importlib.resources.files(__package__)
|
||||
.joinpath("acmetool.hook")
|
||||
.open("rb"),
|
||||
dest="/usr/lib/acme/hooks/nginx",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="744",
|
||||
)
|
||||
|
||||
files.template(
|
||||
src=importlib.resources.files(__package__).joinpath("response-file.yaml.j2"),
|
||||
@@ -45,31 +46,7 @@ def deploy_acmetool(email="", domains=[]):
|
||||
mode="644",
|
||||
)
|
||||
|
||||
service_file = files.put(
|
||||
src=importlib.resources.files(__package__).joinpath(
|
||||
"acmetool-redirector.service"
|
||||
),
|
||||
dest="/etc/systemd/system/acmetool-redirector.service",
|
||||
user="root",
|
||||
group="root",
|
||||
mode="644",
|
||||
)
|
||||
if host.get_fact(SystemdStatus).get("nginx.service"):
|
||||
systemd.service(
|
||||
name="Stop nginx service to free port 80",
|
||||
service="nginx",
|
||||
running=False,
|
||||
)
|
||||
|
||||
systemd.service(
|
||||
name="Setup acmetool-redirector service",
|
||||
service="acmetool-redirector.service",
|
||||
running=True,
|
||||
enabled=True,
|
||||
restarted=service_file.changed,
|
||||
)
|
||||
|
||||
server.shell(
|
||||
name=f"Request certificate for: { ', '.join(domains) }",
|
||||
commands=[f"acmetool want --xlog.severity=debug { ' '.join(domains)}"],
|
||||
commands=[f"acmetool want { ' '.join(domains)}"],
|
||||
)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
[Unit]
|
||||
Description=acmetool HTTP redirector
|
||||
|
||||
[Service]
|
||||
Type=notify
|
||||
ExecStart=/usr/bin/acmetool redirector --service.uid=daemon
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,4 +1,4 @@
|
||||
SHELL=/bin/sh
|
||||
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin
|
||||
MAILTO=root
|
||||
20 16 * * * root /usr/bin/acmetool --batch reconcile && systemctl reload dovecot && systemctl reload postfix && systemctl reload nginx
|
||||
20 16 * * * root /usr/bin/acmetool --batch reconcile
|
||||
|
||||
@@ -3,5 +3,3 @@ set -e
|
||||
EVENT_NAME="$1"
|
||||
[ "$EVENT_NAME" = "live-updated" ] || exit 42
|
||||
systemctl restart nginx.service
|
||||
systemctl reload dovecot.service
|
||||
systemctl reload postfix.service
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"acme-enter-email": "{{ email }}"
|
||||
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.4-April-3-2024.pdf": true
|
||||
"acme-agreement:https://letsencrypt.org/documents/LE-SA-v1.3-September-21-2022.pdf": true
|
||||
|
||||
12
cmdeploy/src/cmdeploy/chatmail.zone.f
Normal file
12
cmdeploy/src/cmdeploy/chatmail.zone.f
Normal file
@@ -0,0 +1,12 @@
|
||||
{chatmail_domain}. MX 10 {chatmail_domain}.
|
||||
_submission._tcp.{chatmail_domain}. SRV 0 1 587 {chatmail_domain}.
|
||||
_submissions._tcp.{chatmail_domain}. SRV 0 1 465 {chatmail_domain}.
|
||||
_imap._tcp.{chatmail_domain}. SRV 0 1 143 {chatmail_domain}.
|
||||
_imaps._tcp.{chatmail_domain}. SRV 0 1 993 {chatmail_domain}.
|
||||
{chatmail_domain}. IN CAA 128 issue "letsencrypt.org;accounturi={acme_account_url}"
|
||||
{chatmail_domain}. TXT "v=spf1 a:{chatmail_domain} -all"
|
||||
_dmarc.{chatmail_domain}. TXT "v=DMARC1;p=reject;rua=mailto:{email};ruf=mailto:{email};fo=1;adkim=r;aspf=r"
|
||||
_mta-sts.{chatmail_domain}. IN TXT "v=STSv1; id={sts_id}"
|
||||
mta-sts.{chatmail_domain}. IN CNAME {chatmail_domain}.
|
||||
_smtp._tls.{chatmail_domain}. IN TXT "v=TLSRPTv1;rua=mailto:{email}"
|
||||
{dkim_entry}
|
||||
@@ -1,21 +0,0 @@
|
||||
{% if ipv4 %}
|
||||
{{ chatmail_domain }}. A {{ ipv4 }}
|
||||
{% endif %}
|
||||
{% if ipv6 %}
|
||||
{{ chatmail_domain }}. AAAA {{ ipv6 }}
|
||||
{% endif %}
|
||||
{{ chatmail_domain }}. MX 10 {{ chatmail_domain }}.
|
||||
_submission._tcp.{{ chatmail_domain }}. SRV 0 1 587 {{ chatmail_domain }}.
|
||||
_submissions._tcp.{{ chatmail_domain }}. SRV 0 1 465 {{ chatmail_domain }}.
|
||||
_imap._tcp.{{ chatmail_domain }}. SRV 0 1 143 {{ chatmail_domain }}.
|
||||
_imaps._tcp.{{ chatmail_domain }}. SRV 0 1 993 {{ chatmail_domain }}.
|
||||
{% if acme_account_url %}
|
||||
{{ chatmail_domain }}. CAA 128 issue "letsencrypt.org;accounturi={{ acme_account_url }}"
|
||||
{% endif %}
|
||||
{{ chatmail_domain }}. TXT "v=spf1 a:{{ chatmail_domain }} ~all"
|
||||
_dmarc.{{ chatmail_domain }}. TXT "v=DMARC1;p=reject;adkim=s;aspf=s"
|
||||
_mta-sts.{{ chatmail_domain }}. TXT "v=STSv1; id={{ sts_id }}"
|
||||
mta-sts.{{ chatmail_domain }}. CNAME {{ chatmail_domain }}.
|
||||
www.{{ chatmail_domain }}. CNAME {{ chatmail_domain }}.
|
||||
{{ dkim_entry }}
|
||||
_adsp._domainkey.{{ chatmail_domain }}. TXT "dkim=discardable"
|
||||
@@ -2,21 +2,20 @@
|
||||
Provides the `cmdeploy` entry point function,
|
||||
along with command line option and subcommand parsing.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import shutil
|
||||
import subprocess
|
||||
import importlib.resources
|
||||
import importlib.util
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
from termcolor import colored
|
||||
|
||||
from . import dns, remote_funcs
|
||||
from .sshexec import SSHExec
|
||||
from termcolor import colored
|
||||
from chatmaild.config import read_config, write_initial_config
|
||||
|
||||
|
||||
#
|
||||
# cmdeploy sub commands and options
|
||||
@@ -33,13 +32,11 @@ def init_cmd_options(parser):
|
||||
|
||||
def init_cmd(args, out):
|
||||
"""Initialize chatmail config file."""
|
||||
mail_domain = args.chatmail_domain
|
||||
if args.inipath.exists():
|
||||
print(f"Path exists, not modifying: {args.inipath}")
|
||||
return 1
|
||||
else:
|
||||
write_initial_config(args.inipath, mail_domain, overrides={})
|
||||
out.green(f"created config file for {mail_domain} in {args.inipath}")
|
||||
out.red(f"Path exists, not modifying: {args.inipath}")
|
||||
raise SystemExit(1)
|
||||
write_initial_config(args.inipath, args.chatmail_domain)
|
||||
out.green(f"created config file for {args.chatmail_domain} in {args.inipath}")
|
||||
|
||||
|
||||
def run_cmd_options(parser):
|
||||
@@ -54,49 +51,52 @@ def run_cmd_options(parser):
|
||||
def run_cmd(args, out):
|
||||
"""Deploy chatmail services on the remote server."""
|
||||
|
||||
remote_data = dns.get_initial_remote_data(args, out)
|
||||
if not dns.check_initial_remote_data(remote_data, print=out.red):
|
||||
return 1
|
||||
|
||||
env = os.environ.copy()
|
||||
env["CHATMAIL_INI"] = args.inipath
|
||||
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
|
||||
env["CHATMAIL_DOMAIN"] = args.config.mail_domain
|
||||
deploy_path = "cmdeploy/src/cmdeploy/deploy.py"
|
||||
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
|
||||
cmd = f"{pyinf} --ssh-user root {args.config.mail_domain} {deploy_path}"
|
||||
|
||||
retcode = out.check_call(cmd, env=env)
|
||||
if retcode == 0:
|
||||
out.green("Deploy completed, call `cmdeploy dns` next.")
|
||||
elif not remote_data["acme_account_url"]:
|
||||
out.red("Deploy completed but letsencrypt not configured")
|
||||
out.red("Run 'cmdeploy run' again")
|
||||
retcode = 0
|
||||
else:
|
||||
out.red("Deploy failed")
|
||||
return retcode
|
||||
|
||||
|
||||
def dns_cmd_options(parser):
|
||||
parser.add_argument(
|
||||
"--zonefile",
|
||||
dest="zonefile",
|
||||
help="print the whole zonefile for deploying directly",
|
||||
)
|
||||
out.check_call(cmd, env=env)
|
||||
|
||||
|
||||
def dns_cmd(args, out):
|
||||
"""Check DNS entries and optionally generate dns zone file."""
|
||||
remote_data = dns.get_initial_remote_data(args, out)
|
||||
if not remote_data:
|
||||
return 1
|
||||
retcode = dns.show_dns(args, out, remote_data)
|
||||
return retcode
|
||||
"""Generate dns zone file."""
|
||||
template = importlib.resources.files(__package__).joinpath("chatmail.zone.f")
|
||||
ssh = f"ssh root@{args.config.mail_domain}"
|
||||
|
||||
def read_dkim_entries(entry):
|
||||
lines = []
|
||||
for line in entry.split("\n"):
|
||||
if line.startswith(";") or not line.strip():
|
||||
continue
|
||||
line = line.replace("\t", " ")
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
acme_account_url = out.shell_output(f"{ssh} -- acmetool account-url")
|
||||
dkim_entry = read_dkim_entries(out.shell_output(f"{ssh} -- opendkim-genzone -F"))
|
||||
|
||||
out(
|
||||
f"[writing {args.config.mail_domain} zone data (using space as separator) to stdout output]",
|
||||
green=True,
|
||||
)
|
||||
print(
|
||||
template.read_text()
|
||||
.format(
|
||||
acme_account_url=acme_account_url,
|
||||
email=f"root@{args.config.mail_domain}",
|
||||
sts_id=datetime.datetime.now().strftime("%Y%m%d%H%M"),
|
||||
chatmail_domain=args.config.mail_domain,
|
||||
dkim_entry=dkim_entry,
|
||||
)
|
||||
.strip()
|
||||
)
|
||||
|
||||
|
||||
def status_cmd(args, out):
|
||||
"""Display status for online chatmail instance."""
|
||||
|
||||
sshexec = args.get_sshexec()
|
||||
ssh = f"ssh root@{args.config.mail_domain}"
|
||||
|
||||
out.green(f"chatmail domain: {args.config.mail_domain}")
|
||||
if args.config.privacy_mail:
|
||||
@@ -104,8 +104,10 @@ def status_cmd(args, out):
|
||||
else:
|
||||
out.red("no privacy settings")
|
||||
|
||||
for line in sshexec(remote_funcs.get_systemd_running):
|
||||
print(line)
|
||||
s1 = "systemctl --type=service --state=running"
|
||||
for line in out.shell_output(f"{ssh} -- {s1}").split("\n"):
|
||||
if line.startswith(" "):
|
||||
print(line)
|
||||
|
||||
|
||||
def test_cmd_options(parser):
|
||||
@@ -134,7 +136,7 @@ def test_cmd(args, out):
|
||||
"-n4",
|
||||
"-rs",
|
||||
"-x",
|
||||
"-v",
|
||||
"-vrx",
|
||||
"--durations=5",
|
||||
]
|
||||
if args.slow:
|
||||
@@ -144,6 +146,14 @@ def test_cmd(args, out):
|
||||
|
||||
|
||||
def fmt_cmd_options(parser):
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
dest="verbose",
|
||||
action="store_true",
|
||||
help="provide information on invocations",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
"-c",
|
||||
@@ -153,26 +163,27 @@ def fmt_cmd_options(parser):
|
||||
|
||||
|
||||
def fmt_cmd(args, out):
|
||||
"""Run formattting fixes on all chatmail source code."""
|
||||
"""Run formattting fixes (fuff and black) on all chatmail source code."""
|
||||
|
||||
sources = [str(importlib.resources.files(x)) for x in ("chatmaild", "cmdeploy")]
|
||||
format_args = [shutil.which("ruff"), "format"]
|
||||
check_args = [shutil.which("ruff"), "check"]
|
||||
black_args = [shutil.which("black")]
|
||||
ruff_args = [shutil.which("ruff")]
|
||||
|
||||
if args.check:
|
||||
format_args.append("--diff")
|
||||
black_args.append("--check")
|
||||
else:
|
||||
check_args.append("--fix")
|
||||
ruff_args.append("--fix")
|
||||
|
||||
if not args.verbose:
|
||||
check_args.append("--quiet")
|
||||
format_args.append("--quiet")
|
||||
black_args.append("-q")
|
||||
ruff_args.append("-q")
|
||||
|
||||
format_args.extend(sources)
|
||||
check_args.extend(sources)
|
||||
black_args.extend(sources)
|
||||
ruff_args.extend(sources)
|
||||
|
||||
out.check_call(" ".join(format_args), quiet=not args.verbose)
|
||||
out.check_call(" ".join(check_args), quiet=not args.verbose)
|
||||
out.check_call(" ".join(black_args), quiet=not args.verbose)
|
||||
out.check_call(" ".join(ruff_args), quiet=not args.verbose)
|
||||
return 0
|
||||
|
||||
|
||||
def bench_cmd(args, out):
|
||||
@@ -208,6 +219,10 @@ class Out:
|
||||
color = "red" if red else ("green" if green else None)
|
||||
print(colored(msg, color), file=file)
|
||||
|
||||
def shell_output(self, arg):
|
||||
self(f"[$ {arg}]", file=sys.stderr)
|
||||
return subprocess.check_output(arg, shell=True).decode()
|
||||
|
||||
def check_call(self, arg, env=None, quiet=False):
|
||||
if not quiet:
|
||||
self(f"[$ {arg}]", file=sys.stderr)
|
||||
@@ -217,7 +232,7 @@ class Out:
|
||||
if not quiet:
|
||||
cmdstring = " ".join(args)
|
||||
self(f"[$ {cmdstring}]", file=sys.stderr)
|
||||
proc = subprocess.run(args, env=env, check=False)
|
||||
proc = subprocess.run(args, env=env)
|
||||
return proc.returncode
|
||||
|
||||
|
||||
@@ -230,14 +245,6 @@ def add_config_option(parser):
|
||||
type=Path,
|
||||
help="path to the chatmail.ini file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
dest="verbose",
|
||||
action="store_true",
|
||||
default=False,
|
||||
help="provide verbose logging",
|
||||
)
|
||||
|
||||
|
||||
def add_subcommand(subparsers, func):
|
||||
@@ -277,23 +284,11 @@ def get_parser():
|
||||
|
||||
|
||||
def main(args=None):
|
||||
"""Provide main entry point for 'cmdeploy' CLI invocation."""
|
||||
"""Provide main entry point for 'xdcget' CLI invocation."""
|
||||
parser = get_parser()
|
||||
args = parser.parse_args(args=args)
|
||||
if not hasattr(args, "func"):
|
||||
return parser.parse_args(["-h"])
|
||||
|
||||
ssh_cache = []
|
||||
|
||||
def get_sshexec():
|
||||
if not ssh_cache:
|
||||
print(f"[ssh] login to {args.config.mail_domain}")
|
||||
ssh = SSHExec(args.config.mail_domain, remote_funcs, verbose=args.verbose)
|
||||
ssh_cache.append(ssh)
|
||||
return ssh_cache[0]
|
||||
|
||||
args.get_sshexec = get_sshexec
|
||||
|
||||
out = Out()
|
||||
kwargs = {}
|
||||
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
|
||||
@@ -311,6 +306,7 @@ def main(args=None):
|
||||
if res is None:
|
||||
res = 0
|
||||
return res
|
||||
|
||||
except KeyboardInterrupt:
|
||||
out.red("KeyboardInterrupt")
|
||||
sys.exit(130)
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import importlib.resources
|
||||
import os
|
||||
|
||||
import pyinfra
|
||||
|
||||
from cmdeploy import deploy_chatmail
|
||||
|
||||
|
||||
def main():
|
||||
config_path = os.getenv(
|
||||
"CHATMAIL_INI",
|
||||
importlib.resources.files("cmdeploy").joinpath("../../../chatmail.ini"),
|
||||
)
|
||||
mail_domain = os.getenv("CHATMAIL_DOMAIN")
|
||||
mail_server = os.getenv("CHATMAIL_SERVER", mail_domain)
|
||||
dkim_selector = os.getenv("CHATMAIL_DKIM_SELECTOR", "dkim")
|
||||
|
||||
deploy_chatmail(config_path)
|
||||
assert mail_domain
|
||||
assert mail_server
|
||||
assert dkim_selector
|
||||
|
||||
deploy_chatmail(mail_domain, mail_server, dkim_selector)
|
||||
|
||||
|
||||
if pyinfra.is_cli:
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import datetime
|
||||
import importlib
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from . import remote_funcs
|
||||
|
||||
|
||||
def get_initial_remote_data(args, out):
|
||||
sshexec = args.get_sshexec()
|
||||
mail_domain = args.config.mail_domain
|
||||
return sshexec.logged(
|
||||
call=remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=mail_domain)
|
||||
)
|
||||
|
||||
|
||||
def check_initial_remote_data(remote_data, print=print):
|
||||
mail_domain = remote_data["mail_domain"]
|
||||
if not remote_data["A"] and not remote_data["AAAA"]:
|
||||
print("Missing A and/or AAAA DNS records for {mail_domain}!")
|
||||
elif not remote_data["MTA_STS"]:
|
||||
print("Missing MTA-STS CNAME record:")
|
||||
print(f"mta-sts.{mail_domain}. CNAME {mail_domain}")
|
||||
else:
|
||||
return remote_data
|
||||
|
||||
|
||||
def show_dns(args, out, remote_data) -> int:
|
||||
"""Check existing DNS records, optionally write them to zone file
|
||||
and return (exitcode, remote_data) tuple."""
|
||||
|
||||
sshexec = args.get_sshexec()
|
||||
|
||||
if not remote_data["acme_account_url"]:
|
||||
out.red("could not get letsencrypt account url, please run 'cmdeploy run'")
|
||||
return 1
|
||||
|
||||
if not remote_data["dkim_entry"]:
|
||||
out.red("could not determine dkim_entry, please run 'cmdeploy run'")
|
||||
return 1
|
||||
|
||||
sts_id = remote_data.get("sts_id")
|
||||
if not sts_id:
|
||||
sts_id = datetime.datetime.now().strftime("%Y%m%d%H%M")
|
||||
|
||||
template = importlib.resources.files(__package__).joinpath("chatmail.zone.j2")
|
||||
content = template.read_text()
|
||||
zonefile = Template(content).render(
|
||||
acme_account_url=remote_data.get("acme_account_url"),
|
||||
dkim_entry=remote_data["dkim_entry"],
|
||||
ipv4=remote_data["A"],
|
||||
ipv6=remote_data["AAAA"],
|
||||
sts_id=sts_id,
|
||||
chatmail_domain=args.config.mail_domain,
|
||||
)
|
||||
lines = [x.strip() for x in zonefile.split("\n") if x.strip()]
|
||||
lines.append("")
|
||||
zonefile = "\n".join(lines)
|
||||
|
||||
diff_records = sshexec.logged(
|
||||
remote_funcs.check_zonefile, kwargs=dict(zonefile=zonefile)
|
||||
)
|
||||
|
||||
if getattr(args, "zonefile", None):
|
||||
with open(args.zonefile, "w+") as zf:
|
||||
zf.write(zonefile)
|
||||
out.green(f"DNS records successfully written to: {args.zonefile}")
|
||||
return 0
|
||||
|
||||
if diff_records:
|
||||
out.red("Please set the following DNS entries at your DNS provider:\n")
|
||||
for line in diff_records:
|
||||
out(line)
|
||||
return 1
|
||||
else:
|
||||
out.green("Great! All your DNS entries are verified and correct.")
|
||||
return 0
|
||||
@@ -1,12 +1,5 @@
|
||||
uri = proxy:/run/doveauth/doveauth.socket:auth
|
||||
iterate_disable = no
|
||||
iterate_prefix = userdb/
|
||||
|
||||
uri = proxy:/run/dovecot/doveauth.socket:auth
|
||||
iterate_disable = yes
|
||||
default_pass_scheme = plain
|
||||
# %E escapes characters " (double quote), ' (single quote) and \ (backslash) with \ (backslash).
|
||||
# See <https://doc.dovecot.org/configuration_manual/config_file/config_variables/#modifiers>
|
||||
# for documentation.
|
||||
#
|
||||
# We escape user-provided input and use double quote as a separator.
|
||||
password_key = passdb/%Ew"%Eu
|
||||
user_key = userdb/%Eu
|
||||
password_key = passdb/%w/%u
|
||||
user_key = userdb/%u
|
||||
|
||||
@@ -13,44 +13,13 @@ auth_cache_size = 100M
|
||||
mail_debug = yes
|
||||
{% endif %}
|
||||
|
||||
# Prevent warnings similar to:
|
||||
# config: Warning: service auth { client_limit=1000 } is lower than required under max. load (10200). Counted for protocol services with service_count != 1: service lmtp { process_limit=100 } + service imap-urlauth-login { process_limit=100 } + service imap-login { process_limit=10000 }
|
||||
# config: Warning: service anvil { client_limit=1000 } is lower than required under max. load (10103). Counted with: service imap-urlauth-login { process_limit=100 } + service imap-login { process_limit=10000 } + service auth { process_limit=1 }
|
||||
# master: Warning: service(stats): client_limit (1000) reached, client connections are being dropped
|
||||
default_client_limit = 20000
|
||||
|
||||
# Increase number of logged in IMAP connections.
|
||||
# Each connection is handled by a separate `imap` process.
|
||||
# `imap` process should have `client_limit=1` as described in
|
||||
# <https://doc.dovecot.org/configuration_manual/service_configuration/#service-limits>
|
||||
# so each logged in IMAP session will need its own `imap` process.
|
||||
#
|
||||
# If this limit is reached,
|
||||
# users will fail to LOGIN as `imap-login` process
|
||||
# will accept them logging in but fail to transfer logged in
|
||||
# connection to `imap` process until someone logs out and
|
||||
# the following warning will be logged:
|
||||
# Warning: service(imap): process_limit (1024) reached, client connections are being dropped
|
||||
service imap {
|
||||
process_limit = 50000
|
||||
}
|
||||
|
||||
mail_server_admin = mailto:root@{{ config.mail_domain }}
|
||||
mail_server_comment = Chatmail server
|
||||
|
||||
# `zlib` enables compressing messages stored in the maildir.
|
||||
# See
|
||||
# <https://doc.dovecot.org/configuration_manual/zlib_plugin/>
|
||||
# for documentation.
|
||||
#
|
||||
# quota plugin documentation:
|
||||
# <https://doc.dovecot.org/configuration_manual/quota_plugin/>
|
||||
mail_plugins = zlib quota
|
||||
mail_plugins = quota
|
||||
|
||||
# these are the capabilities Delta Chat cares about actually
|
||||
# so let's keep the network overhead per login small
|
||||
# https://github.com/deltachat/deltachat-core-rust/blob/master/src/imap/capabilities.rs
|
||||
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY METADATA XDELTAPUSH XCHATMAIL
|
||||
imap_capability = IMAP4rev1 IDLE MOVE QUOTA CONDSTORE NOTIFY
|
||||
|
||||
|
||||
# Authentication for system users.
|
||||
@@ -67,7 +36,7 @@ userdb {
|
||||
##
|
||||
|
||||
# Mailboxes are stored in the "mail" directory of the vmail user home.
|
||||
mail_location = maildir:{{ config.mailboxes_dir }}/%u
|
||||
mail_location = maildir:/home/vmail/mail/%d/%u
|
||||
|
||||
namespace inbox {
|
||||
inbox = yes
|
||||
@@ -100,30 +69,14 @@ mail_privileged_group = vmail
|
||||
## Mail processes
|
||||
##
|
||||
|
||||
# Pass all IMAP METADATA requests to the server implementing Dovecot's dict protocol.
|
||||
mail_attribute_dict = proxy:/run/chatmail-metadata/metadata.socket:metadata
|
||||
|
||||
# `imap_zlib` enables IMAP COMPRESS (RFC 4978).
|
||||
# Enable IMAP COMPRESS (RFC 4978).
|
||||
# <https://datatracker.ietf.org/doc/html/rfc4978.html>
|
||||
protocol imap {
|
||||
mail_plugins = $mail_plugins imap_zlib imap_quota
|
||||
imap_metadata = yes
|
||||
}
|
||||
|
||||
protocol lmtp {
|
||||
# notify plugin is a dependency of push_notification plugin:
|
||||
# <https://doc.dovecot.org/settings/plugin/notify-plugin/>
|
||||
#
|
||||
# push_notification plugin documentation:
|
||||
# <https://doc.dovecot.org/configuration_manual/push_notification/>
|
||||
#
|
||||
# mail_lua and push_notification_lua are needed for Lua push notification handler.
|
||||
# <https://doc.dovecot.org/configuration_manual/push_notification/#configuration>
|
||||
mail_plugins = $mail_plugins mail_lua notify push_notification push_notification_lua
|
||||
}
|
||||
|
||||
plugin {
|
||||
zlib_save = gz
|
||||
mail_plugins = $mail_plugins quota
|
||||
}
|
||||
|
||||
plugin {
|
||||
@@ -139,11 +92,7 @@ plugin {
|
||||
# quota_over_flag_value = TRUE
|
||||
}
|
||||
|
||||
# push_notification configuration
|
||||
plugin {
|
||||
# <https://doc.dovecot.org/configuration_manual/push_notification/#lua-lua>
|
||||
push_notification_driver = lua:file=/etc/dovecot/push_notification.lua
|
||||
}
|
||||
|
||||
|
||||
service lmtp {
|
||||
user=vmail
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox
|
||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||
# or in any IMAP subfolder
|
||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||
# even if they are unseen
|
||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||
# or only temporary (but then they shouldn't be around after {{ config.delete_mails_after }} days anyway).
|
||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
|
||||
3 0 * * * vmail find {{ config.mailboxes_dir }} -name 'maildirsize' -type f -delete
|
||||
4 0 * * * vmail /usr/local/lib/chatmaild/venv/bin/delete_inactive_users /usr/local/lib/chatmaild/chatmail.ini
|
||||
2 0 * * * dovecot doveadm expunge -A SEEN BEFORE {{ config.delete_mails_after }} INBOX
|
||||
2 0 * * * dovecot doveadm expunge -A SEEN BEFORE {{ config.delete_mails_after }} Deltachat
|
||||
2 0 * * * dovecot doveadm expunge -A SEEN BEFORE {{ config.delete_mails_after }} Trash
|
||||
2 30 * * * dovecot doveadm purge -A
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
function dovecot_lua_notify_begin_txn(user)
|
||||
return user
|
||||
end
|
||||
|
||||
function contains(v, needle)
|
||||
for _, keyword in ipairs(v) do
|
||||
if keyword == needle then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function dovecot_lua_notify_event_message_new(user, event)
|
||||
local mbox = user:mailbox(event.mailbox)
|
||||
mbox:sync()
|
||||
|
||||
if user.username ~= event.from_address then
|
||||
-- Incoming message
|
||||
-- Notify METADATA server about new message.
|
||||
mbox:metadata_set("/private/messagenew", "")
|
||||
end
|
||||
|
||||
mbox:free()
|
||||
end
|
||||
|
||||
function dovecot_lua_notify_end_txn(ctx, success)
|
||||
end
|
||||
@@ -1,13 +1,12 @@
|
||||
import importlib
|
||||
import io
|
||||
import os
|
||||
|
||||
import qrcode
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
from PIL import ImageFont, ImageDraw, Image
|
||||
import io
|
||||
|
||||
|
||||
def gen_qr_png_data(maildomain):
|
||||
url = f"DCACCOUNT:https://{maildomain}/new"
|
||||
url = f"DCACCOUNT:https://{maildomain}/cgi-bin/newemail.py"
|
||||
image = gen_qr(maildomain, url)
|
||||
temp = io.BytesIO()
|
||||
image.save(temp, format="png")
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
[Journal]
|
||||
MaxRetentionSec=3d
|
||||
Storage=volatile
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
*/5 * * * * root {{ config.execpath }} {{ config.mailboxes_dir }} >/var/www/html/metrics
|
||||
@@ -19,13 +19,6 @@
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<incomingServer type="imap">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<port>443</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<port>465</port>
|
||||
@@ -40,12 +33,5 @@
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>{{ config.domain_name }}</hostname>
|
||||
<port>443</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%EMAILADDRESS%</username>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>
|
||||
|
||||
@@ -1,30 +1,13 @@
|
||||
load_module modules/ngx_stream_module.so;
|
||||
|
||||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
error_log syslog:server=unix:/dev/log,facility=local3;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
events {
|
||||
worker_connections 768;
|
||||
# multi_accept on;
|
||||
}
|
||||
|
||||
stream {
|
||||
map $ssl_preread_alpn_protocols $proxy {
|
||||
default 127.0.0.1:8443;
|
||||
~\bsmtp\b 127.0.0.1:submissions;
|
||||
~\bimap\b 127.0.0.1:imaps;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443;
|
||||
listen [::]:443;
|
||||
proxy_pass $proxy;
|
||||
ssl_preread on;
|
||||
}
|
||||
}
|
||||
|
||||
http {
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
@@ -43,8 +26,8 @@ http {
|
||||
gzip on;
|
||||
|
||||
server {
|
||||
listen 8443 ssl default_server;
|
||||
listen [::]:8443 ssl default_server;
|
||||
listen 443 ssl default_server;
|
||||
listen [::]:443 ssl default_server;
|
||||
|
||||
root /var/www/html;
|
||||
|
||||
@@ -52,53 +35,21 @@ http {
|
||||
|
||||
server_name _;
|
||||
|
||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||
|
||||
location / {
|
||||
# First attempt to serve request as file, then
|
||||
# as directory, then fall back to displaying a 404.
|
||||
try_files $uri $uri/ =404;
|
||||
}
|
||||
|
||||
location /metrics {
|
||||
default_type text/plain;
|
||||
}
|
||||
|
||||
location /new {
|
||||
if ($request_method = GET) {
|
||||
# Redirect to Delta Chat,
|
||||
# which will in turn do a POST request.
|
||||
return 301 dcaccount:https://{{ config.domain_name }}/new;
|
||||
}
|
||||
|
||||
fastcgi_pass unix:/run/fcgiwrap.socket;
|
||||
include /etc/nginx/fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
|
||||
}
|
||||
|
||||
# Old URL for compatibility with e.g. printed QR codes.
|
||||
#
|
||||
# Copy-paste instead of redirect to /new
|
||||
# because Delta Chat core does not follow redirects.
|
||||
#
|
||||
# Redirects are only for browsers.
|
||||
location /cgi-bin/newemail.py {
|
||||
if ($request_method = GET) {
|
||||
return 301 dcaccount:https://{{ config.domain_name }}/new;
|
||||
}
|
||||
|
||||
fastcgi_pass unix:/run/fcgiwrap.socket;
|
||||
include /etc/nginx/fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME /usr/lib/cgi-bin/newemail.py;
|
||||
}
|
||||
# add cgi-bin support
|
||||
include /usr/share/doc/fcgiwrap/examples/nginx.conf;
|
||||
}
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
server_name _;
|
||||
|
||||
# Redirect www. to non-www
|
||||
server {
|
||||
listen 8443 ssl;
|
||||
listen [::]:8443 ssl;
|
||||
server_name www.{{ config.domain_name }};
|
||||
return 301 $scheme://{{ config.domain_name }}$request_uri;
|
||||
access_log syslog:server=unix:/dev/log,facility=local7;
|
||||
}
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
{{ config.opendkim_selector }}._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/{{ config.opendkim_selector }}.private
|
||||
dkim._domainkey.{{ config.domain_name }} {{ config.domain_name }}:{{ config.opendkim_selector }}:/etc/dkimkeys/dkim.private
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
if odkim.internal_ip(ctx) == 1 then
|
||||
-- Outgoing message will be signed,
|
||||
-- no need to look for signatures.
|
||||
return nil
|
||||
end
|
||||
|
||||
nsigs = odkim.get_sigcount(ctx)
|
||||
if nsigs == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
for i = 1, nsigs do
|
||||
sig = odkim.get_sighandle(ctx, i - 1)
|
||||
sigres = odkim.sig_result(sig)
|
||||
|
||||
-- All signatures that do not correspond to From:
|
||||
-- were ignored in screen.lua and return sigres -1.
|
||||
--
|
||||
-- Any valid signature that was not ignored like this
|
||||
-- means the message is acceptable.
|
||||
if sigres == 0 then
|
||||
return nil
|
||||
end
|
||||
end
|
||||
|
||||
odkim.set_reply(ctx, "554", "5.7.1", "No valid DKIM signature found")
|
||||
odkim.set_result(ctx, SMFIS_REJECT)
|
||||
return nil
|
||||
@@ -8,12 +8,10 @@ SyslogSuccess yes
|
||||
# oversigned, because it is often the identity key used by reputation systems
|
||||
# and thus somewhat security sensitive.
|
||||
Canonicalization relaxed/simple
|
||||
#Mode sv
|
||||
#SubDomains no
|
||||
OversignHeaders From
|
||||
|
||||
On-BadSignature reject
|
||||
On-KeyNotFound reject
|
||||
On-NoSignature reject
|
||||
|
||||
# Signing domain, selector, and key (required). For example, perform signing
|
||||
# for domain "example.com" with selector "2020" (2020._domainkey.example.com),
|
||||
# using the private key stored in /etc/dkimkeys/example.private. More granular
|
||||
@@ -24,32 +22,6 @@ KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
|
||||
KeyTable /etc/dkimkeys/KeyTable
|
||||
SigningTable refile:/etc/dkimkeys/SigningTable
|
||||
|
||||
# Sign Autocrypt header in addition to the default specified in RFC 6376.
|
||||
#
|
||||
# Default list is here:
|
||||
# <https://github.com/trusteddomainproject/OpenDKIM/blob/5c539587561785a66c1f67f720f2fb741f320785/libopendkim/dkim.c#L221-L245>
|
||||
SignHeaders *,+autocrypt,+content-type
|
||||
|
||||
# Prevent addition of second Content-Type header
|
||||
# and other important headers that should not be added
|
||||
# after signing the message.
|
||||
# See
|
||||
# <https://www.zone.eu/blog/2024/05/17/bimi-and-dmarc-cant-save-you/>
|
||||
# and RFC 6376 (page 41) for reference.
|
||||
#
|
||||
# We don't use "l=" body length so the problem described in RFC 6376
|
||||
# is not applicable, but adding e.g. a second "From" header
|
||||
# or second "Autocrypt" header is better prevented in any case.
|
||||
#
|
||||
# Default is empty.
|
||||
OversignHeaders from,reply-to,subject,date,to,cc,resent-date,resent-from,resent-sender,resent-to,resent-cc,in-reply-to,references,list-id,list-help,list-unsubscribe,list-subscribe,list-post,list-owner,list-archive,autocrypt
|
||||
|
||||
# Script to ignore signatures that do not correspond to the From: domain.
|
||||
ScreenPolicyScript /etc/opendkim/screen.lua
|
||||
|
||||
# Script to reject mails without a valid DKIM signature.
|
||||
FinalPolicyScript /etc/opendkim/final.lua
|
||||
|
||||
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
|
||||
# using a local socket with MTAs that access the socket as a non-privileged
|
||||
# user (for example, Postfix). You may need to add user "postfix" to group
|
||||
@@ -57,10 +29,22 @@ FinalPolicyScript /etc/opendkim/final.lua
|
||||
UserID opendkim
|
||||
UMask 007
|
||||
|
||||
# Socket for the MTA connection (required). If the MTA is inside a chroot jail,
|
||||
# it must be ensured that the socket is accessible. In Debian, Postfix runs in
|
||||
# a chroot in /var/spool/postfix, therefore a Unix socket would have to be
|
||||
# configured as shown on the last line below.
|
||||
#Socket local:/run/opendkim/opendkim.sock
|
||||
#Socket inet:8891@localhost
|
||||
#Socket inet:8891
|
||||
Socket local:/var/spool/postfix/opendkim/opendkim.sock
|
||||
|
||||
PidFile /run/opendkim/opendkim.pid
|
||||
|
||||
# Hosts for which to sign rather than verify, default is 127.0.0.1. See the
|
||||
# OPERATION section of opendkim(8) for more information.
|
||||
#InternalHosts 192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12
|
||||
|
||||
# The trust anchor enables DNSSEC. In Debian, the trust anchor file is provided
|
||||
# by the package dns-root-data.
|
||||
TrustAnchorFile /usr/share/dns/root.key
|
||||
#Nameservers 127.0.0.1
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
-- Ignore signatures that do not correspond to the From: domain.
|
||||
|
||||
from_domain = odkim.get_fromdomain(ctx)
|
||||
if from_domain == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
n = odkim.get_sigcount(ctx)
|
||||
if n == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
for i = 1, n do
|
||||
sig = odkim.get_sighandle(ctx, i - 1)
|
||||
sig_domain = odkim.sig_getdomain(sig)
|
||||
if from_domain ~= sig_domain then
|
||||
odkim.sig_ignore(sig)
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
@@ -1 +0,0 @@
|
||||
/^(.*)$/ ${1}
|
||||
@@ -11,8 +11,9 @@ append_dot_mydomain = no
|
||||
|
||||
readme_directory = no
|
||||
|
||||
# See http://www.postfix.org/COMPATIBILITY_README.html
|
||||
compatibility_level = 3.6
|
||||
# See http://www.postfix.org/COMPATIBILITY_README.html -- default to 2 on
|
||||
# fresh installs.
|
||||
compatibility_level = 2
|
||||
|
||||
# TLS parameters
|
||||
smtpd_tls_cert_file=/var/lib/acme/live/{{ config.mail_domain }}/fullchain
|
||||
@@ -23,31 +24,6 @@ smtp_tls_CApath=/etc/ssl/certs
|
||||
smtp_tls_security_level=may
|
||||
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
|
||||
smtp_tls_policy_maps = socketmap:inet:127.0.0.1:8461:postfix
|
||||
smtpd_tls_protocols = >=TLSv1.2
|
||||
|
||||
# Disable anonymous cipher suites
|
||||
# and known insecure algorithms.
|
||||
#
|
||||
# Disabling anonymous ciphers
|
||||
# does not generally improve security
|
||||
# because clients that want to verify certificate
|
||||
# will not select them anyway,
|
||||
# but makes cipher suite list shorter and security scanners happy.
|
||||
# See <https://www.postfix.org/TLS_README.html> for discussion.
|
||||
#
|
||||
# Only ancient insecure ciphers should be disabled here
|
||||
# as MTA clients that do not support more secure cipher
|
||||
# likely do not support MTA-STS either and will
|
||||
# otherwise fall back to using plaintext connection.
|
||||
smtpd_tls_exclude_ciphers = aNULL, RC4, MD5, DES
|
||||
|
||||
# Override client's preference order.
|
||||
# <https://www.postfix.org/postconf.5.html#tls_preempt_cipherlist>
|
||||
#
|
||||
# This is mostly to ensure cipher suites with forward secrecy
|
||||
# are preferred over non cipher suites without forward secrecy.
|
||||
# See <https://www.postfix.org/FORWARD_SECRECY_README.html#server_fs>.
|
||||
tls_preempt_cipherlist = yes
|
||||
|
||||
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
|
||||
myhostname = {{ config.mail_domain }}
|
||||
@@ -71,13 +47,5 @@ inet_protocols = all
|
||||
virtual_transport = lmtp:unix:private/dovecot-lmtp
|
||||
virtual_mailbox_domains = {{ config.mail_domain }}
|
||||
|
||||
mua_client_restrictions = permit_sasl_authenticated, reject
|
||||
mua_sender_restrictions = reject_sender_login_mismatch, permit_sasl_authenticated, reject
|
||||
mua_helo_restrictions = permit_mynetworks, reject_invalid_helo_hostname, reject_non_fqdn_helo_hostname, permit
|
||||
|
||||
# 1:1 map MAIL FROM to SASL login name.
|
||||
smtpd_sender_login_maps = regexp:/etc/postfix/login_map
|
||||
|
||||
# Do not lookup SMTP client hostnames to reduce delays
|
||||
# and avoid unnecessary DNS requests.
|
||||
smtpd_peername_lookup = no
|
||||
smtpd_milters = unix:opendkim/opendkim.sock
|
||||
non_smtpd_milters = $smtpd_milters
|
||||
|
||||
@@ -11,11 +11,14 @@
|
||||
# ==========================================================================
|
||||
{% if debug == true %}
|
||||
smtp inet n - y - - smtpd -v
|
||||
{%- else %}
|
||||
{% else %}
|
||||
smtp inet n - y - - smtpd
|
||||
{%- endif %}
|
||||
-o smtpd_milters=unix:opendkim/opendkim.sock
|
||||
submission inet n - y - 5000 smtpd
|
||||
{% endif %}
|
||||
#smtp inet n - y - 1 postscreen
|
||||
#smtpd pass - - y - - smtpd
|
||||
#dnsblog unix - - y - 0 dnsblog
|
||||
#tlsproxy unix - - y - 0 tlsproxy
|
||||
submission inet n - y - - smtpd
|
||||
-o syslog_name=postfix/submission
|
||||
-o smtpd_tls_security_level=encrypt
|
||||
-o smtpd_sasl_auth_enable=yes
|
||||
@@ -31,8 +34,7 @@ submission inet n - y - 5000 smtpd
|
||||
-o milter_macro_daemon_name=ORIGINATING
|
||||
-o smtpd_client_connection_count_limit=1000
|
||||
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
|
||||
-o cleanup_service_name=authclean
|
||||
smtps inet n - y - 5000 smtpd
|
||||
smtps inet n - y - - smtpd
|
||||
-o syslog_name=postfix/smtps
|
||||
-o smtpd_tls_wrappermode=yes
|
||||
-o smtpd_tls_security_level=encrypt
|
||||
@@ -48,7 +50,6 @@ smtps inet n - y - 5000 smtpd
|
||||
-o smtpd_client_connection_count_limit=1000
|
||||
-o milter_macro_daemon_name=ORIGINATING
|
||||
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
|
||||
-o cleanup_service_name=authclean
|
||||
#628 inet n - y - - qmqpd
|
||||
pickup unix n - y 60 1 pickup
|
||||
cleanup unix n - y - 0 cleanup
|
||||
@@ -79,14 +80,3 @@ filter unix - n n - - lmtp
|
||||
# Local SMTP server for reinjecting filered mail.
|
||||
localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
|
||||
-o syslog_name=postfix/reinject
|
||||
-o smtpd_milters=unix:opendkim/opendkim.sock
|
||||
-o cleanup_service_name=authclean
|
||||
|
||||
# Cleanup `Received` headers for authenticated mail
|
||||
# to avoid leaking client IP.
|
||||
#
|
||||
# We do not do this for received mails
|
||||
# as this will break DKIM signatures
|
||||
# if `Received` header is signed.
|
||||
authclean unix n - - - 0 cleanup
|
||||
-o header_checks=regexp:/etc/postfix/submission_header_cleanup
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
/^Received:/ IGNORE
|
||||
/^X-Originating-IP:/ IGNORE
|
||||
/^X-Mailer:/ IGNORE
|
||||
/^User-Agent:/ IGNORE
|
||||
@@ -1,125 +0,0 @@
|
||||
"""
|
||||
Pure python functions which execute remotely in a system Python interpreter.
|
||||
|
||||
All functions of this module
|
||||
|
||||
- need to get and and return Python builtin data types only,
|
||||
|
||||
- can only use standard library dependencies,
|
||||
|
||||
- can freely call each other.
|
||||
"""
|
||||
|
||||
import re
|
||||
import traceback
|
||||
from subprocess import CalledProcessError, check_output
|
||||
|
||||
|
||||
def shell(command, fail_ok=False):
|
||||
print(f"$ {command}")
|
||||
try:
|
||||
return check_output(command, shell=True).decode().rstrip()
|
||||
except CalledProcessError:
|
||||
if not fail_ok:
|
||||
raise
|
||||
return ""
|
||||
|
||||
|
||||
def get_systemd_running():
|
||||
lines = shell("systemctl --type=service --state=running").split("\n")
|
||||
return [line for line in lines if line.startswith(" ")]
|
||||
|
||||
|
||||
def perform_initial_checks(mail_domain):
|
||||
"""Collecting initial DNS zone content."""
|
||||
assert mail_domain
|
||||
A = query_dns("A", mail_domain)
|
||||
AAAA = query_dns("AAAA", mail_domain)
|
||||
MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}")
|
||||
|
||||
res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS)
|
||||
if not MTA_STS or (not A and not AAAA):
|
||||
return res
|
||||
|
||||
res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
|
||||
if not shell("dig", fail_ok=True):
|
||||
shell("apt-get install -y dnsutils")
|
||||
shell(f"unbound-control flush_zone {mail_domain}", fail_ok=True)
|
||||
res["dkim_entry"] = get_dkim_entry(mail_domain, dkim_selector="opendkim")
|
||||
|
||||
# parse out sts-id if exists, example: "v=STSv1; id=2090123"
|
||||
parts = query_dns("TXT", f"_mta-sts.{mail_domain}").split("id=")
|
||||
res["sts_id"] = parts[1].rstrip('"') if len(parts) == 2 else ""
|
||||
return res
|
||||
|
||||
|
||||
def get_dkim_entry(mail_domain, dkim_selector):
|
||||
try:
|
||||
dkim_pubkey = shell(
|
||||
f"openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
|
||||
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
|
||||
)
|
||||
except CalledProcessError:
|
||||
return
|
||||
dkim_value_raw = f"v=DKIM1;k=rsa;p={dkim_pubkey};s=email;t=s"
|
||||
dkim_value = '" "'.join(re.findall(".{1,255}", dkim_value_raw))
|
||||
return f'{dkim_selector}._domainkey.{mail_domain}. TXT "{dkim_value}"'
|
||||
|
||||
|
||||
def query_dns(typ, domain):
|
||||
res = shell(f"dig -r -q {domain} -t {typ} +short")
|
||||
print(res)
|
||||
if res:
|
||||
return res.split("\n")[0]
|
||||
return ""
|
||||
|
||||
|
||||
def check_zonefile(zonefile):
|
||||
"""Check expected zone file entries."""
|
||||
diff = []
|
||||
|
||||
for zf_line in zonefile.splitlines():
|
||||
print(f"dns-checking {zf_line!r}")
|
||||
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
|
||||
zf_domain = zf_domain.rstrip(".")
|
||||
zf_value = zf_value.strip()
|
||||
query_value = query_dns(zf_typ, zf_domain)
|
||||
if zf_value != query_value:
|
||||
assert zf_typ in ("A", "AAAA", "CNAME", "CAA", "SRV", "MX", "TXT"), zf_line
|
||||
diff.append(zf_line)
|
||||
|
||||
return diff
|
||||
|
||||
|
||||
## Function Execution server
|
||||
|
||||
|
||||
def _run_loop(cmd_channel):
|
||||
while 1:
|
||||
cmd = cmd_channel.receive()
|
||||
if cmd is None:
|
||||
break
|
||||
|
||||
cmd_channel.send(_handle_one_request(cmd))
|
||||
|
||||
|
||||
def _handle_one_request(cmd):
|
||||
func_name, kwargs = cmd
|
||||
try:
|
||||
res = globals()[func_name](**kwargs)
|
||||
return ("finish", res)
|
||||
except:
|
||||
data = traceback.format_exc()
|
||||
return ("error", data)
|
||||
|
||||
|
||||
# check if this module is executed remotely
|
||||
# and setup a simple serialized function-execution loop
|
||||
|
||||
if __name__ == "__channelexec__":
|
||||
channel = channel # noqa (channel object gets injected)
|
||||
|
||||
# enable simple "print" debugging for anyone changing this module
|
||||
globals()["print"] = lambda x="": channel.send(("log", x))
|
||||
|
||||
_run_loop(channel)
|
||||
@@ -1,12 +0,0 @@
|
||||
[Unit]
|
||||
Description=Chatmail dict proxy for IMAP METADATA
|
||||
|
||||
[Service]
|
||||
ExecStart={execpath} /run/chatmail-metadata/metadata.socket {config_path}
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
User=vmail
|
||||
RuntimeDirectory=chatmail-metadata
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,67 +0,0 @@
|
||||
[Unit]
|
||||
Description=Chatmail echo bot for testing it works
|
||||
|
||||
[Service]
|
||||
ExecStart={execpath} {config_path}
|
||||
Environment="PATH={remote_venv_dir}:$PATH"
|
||||
Restart=always
|
||||
RestartSec=30
|
||||
|
||||
User=echobot
|
||||
Group=echobot
|
||||
|
||||
# Create /var/lib/echobot
|
||||
StateDirectory=echobot
|
||||
|
||||
# Create /run/echobot
|
||||
#
|
||||
# echobot stores /run/echobot/password
|
||||
# with a password there, which doveauth then reads.
|
||||
RuntimeDirectory=echobot
|
||||
|
||||
WorkingDirectory=/var/lib/echobot
|
||||
|
||||
# Apply security restrictions suggested by
|
||||
# systemd-analyze security echobot.service
|
||||
CapabilityBoundingSet=
|
||||
LockPersonality=true
|
||||
MemoryDenyWriteExecute=true
|
||||
NoNewPrivileges=true
|
||||
PrivateDevices=true
|
||||
PrivateMounts=true
|
||||
PrivateTmp=true
|
||||
|
||||
# We need to know about doveauth user to give it access to /run/echobot/password
|
||||
PrivateUsers=false
|
||||
|
||||
ProtectClock=true
|
||||
ProtectControlGroups=true
|
||||
ProtectHostname=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectProc=noaccess
|
||||
|
||||
# Should be "strict", but we currently write /accounts folder in a protected path
|
||||
ProtectSystem=full
|
||||
|
||||
RemoveIPC=true
|
||||
RestrictAddressFamilies=AF_INET AF_INET6
|
||||
RestrictNamespaces=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
SystemCallArchitectures=native
|
||||
SystemCallFilter=~@clock
|
||||
SystemCallFilter=~@cpu-emulation
|
||||
SystemCallFilter=~@debug
|
||||
SystemCallFilter=~@module
|
||||
SystemCallFilter=~@mount
|
||||
SystemCallFilter=~@obsolete
|
||||
SystemCallFilter=~@raw-io
|
||||
SystemCallFilter=~@reboot
|
||||
SystemCallFilter=~@resources
|
||||
SystemCallFilter=~@swap
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,48 +0,0 @@
|
||||
import sys
|
||||
|
||||
import execnet
|
||||
|
||||
|
||||
class FuncError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SSHExec:
|
||||
RemoteError = execnet.RemoteError
|
||||
FuncError = FuncError
|
||||
|
||||
def __init__(self, host, remote_funcs, verbose=False, python="python3", timeout=60):
|
||||
self.gateway = execnet.makegateway(f"ssh=root@{host}//python={python}")
|
||||
self._remote_cmdloop_channel = self.gateway.remote_exec(remote_funcs)
|
||||
self.timeout = timeout
|
||||
self.verbose = verbose
|
||||
|
||||
def __call__(self, call, kwargs=None, log_callback=None):
|
||||
if kwargs is None:
|
||||
kwargs = {}
|
||||
self._remote_cmdloop_channel.send((call.__name__, kwargs))
|
||||
while 1:
|
||||
code, data = self._remote_cmdloop_channel.receive(timeout=self.timeout)
|
||||
if log_callback is not None and code == "log":
|
||||
log_callback(data)
|
||||
elif code == "finish":
|
||||
return data
|
||||
elif code == "error":
|
||||
raise self.FuncError(data)
|
||||
|
||||
def logged(self, call, kwargs):
|
||||
def log_progress(data):
|
||||
sys.stdout.write(".")
|
||||
sys.stdout.flush()
|
||||
|
||||
title = call.__doc__
|
||||
if not title:
|
||||
title = call.__name__
|
||||
if self.verbose:
|
||||
print("[ssh] " + title)
|
||||
return self(call, kwargs, log_callback=print)
|
||||
else:
|
||||
print(title, end="")
|
||||
res = self(call, kwargs, log_callback=log_progress)
|
||||
print()
|
||||
return res
|
||||
@@ -30,31 +30,31 @@ def test_login_smtp(benchmark, smtp, gencreds):
|
||||
|
||||
class TestDC:
|
||||
def test_autoconfigure(self, benchmark, cmfactory):
|
||||
def dc_autoconfig_and_idle_ready():
|
||||
def autoconfig_and_idle_ready():
|
||||
cmfactory.get_online_accounts(1)
|
||||
|
||||
benchmark(dc_autoconfig_and_idle_ready, 5)
|
||||
benchmark(autoconfig_and_idle_ready, 5)
|
||||
|
||||
def test_ping_pong(self, benchmark, cmfactory):
|
||||
ac1, ac2 = cmfactory.get_online_accounts(2)
|
||||
chat = cmfactory.get_accepted_chat(ac1, ac2)
|
||||
|
||||
def dc_ping_pong():
|
||||
def ping_pong():
|
||||
chat.send_text("ping")
|
||||
msg = ac2.wait_next_incoming_message()
|
||||
msg.chat.send_text("pong")
|
||||
ac1.wait_next_incoming_message()
|
||||
|
||||
benchmark(dc_ping_pong, 5)
|
||||
benchmark(ping_pong, 5)
|
||||
|
||||
def test_send_10_receive_10(self, benchmark, cmfactory, lp):
|
||||
ac1, ac2 = cmfactory.get_online_accounts(2)
|
||||
chat = cmfactory.get_accepted_chat(ac1, ac2)
|
||||
|
||||
def dc_send_10_receive_10():
|
||||
def send_10_receive_10():
|
||||
for i in range(10):
|
||||
chat.send_text(f"hello {i}")
|
||||
for i in range(10):
|
||||
ac2.wait_next_incoming_message()
|
||||
|
||||
benchmark(dc_send_10_receive_10, 5)
|
||||
benchmark(send_10_receive_10, 5)
|
||||
|
||||
@@ -1,25 +1,6 @@
|
||||
import queue
|
||||
import socket
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
from chatmaild.config import read_config
|
||||
|
||||
from cmdeploy.cmdeploy import main
|
||||
|
||||
|
||||
def test_init(tmp_path, maildomain):
|
||||
inipath = tmp_path.joinpath("chatmail.ini")
|
||||
main(["init", "--config", str(inipath), maildomain])
|
||||
config = read_config(inipath)
|
||||
assert config.mail_domain == maildomain
|
||||
|
||||
|
||||
def test_capabilities(imap):
|
||||
imap.connect()
|
||||
capas = imap.conn.capabilities
|
||||
assert "XCHATMAIL" in capas
|
||||
assert "XDELTAPUSH" in capas
|
||||
import threading
|
||||
import queue
|
||||
|
||||
|
||||
def test_login_basic_functioning(imap_or_smtp, gencreds, lp):
|
||||
@@ -87,24 +68,3 @@ def test_concurrent_logins_same_account(
|
||||
|
||||
for _ in conns:
|
||||
assert login_results.get()
|
||||
|
||||
|
||||
def test_no_vrfy(chatmail_config):
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.connect((chatmail_config.mail_domain, 25))
|
||||
banner = sock.recv(1024)
|
||||
print(banner)
|
||||
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
||||
result = sock.recv(1024)
|
||||
print(result)
|
||||
sock.send(b"VRFY echo@%s\r\n" % (chatmail_config.mail_domain.encode(),))
|
||||
result2 = sock.recv(1024)
|
||||
print(result2)
|
||||
assert result[0:10] == result2[0:10]
|
||||
sock.send(b"VRFY wrongaddress\r\n")
|
||||
result = sock.recv(1024)
|
||||
print(result)
|
||||
sock.send(b"VRFY echo\r\n")
|
||||
result2 = sock.recv(1024)
|
||||
print(result2)
|
||||
assert result[0:10] == result2[0:10] == b"252 2.0.0 "
|
||||
|
||||
@@ -9,17 +9,8 @@ def test_gen_qr_png_data(maildomain):
|
||||
|
||||
|
||||
def test_fastcgi_working(maildomain, chatmail_config):
|
||||
url = f"https://{maildomain}/new"
|
||||
url = f"https://{maildomain}/cgi-bin/newemail.py"
|
||||
print(url)
|
||||
res = requests.post(url)
|
||||
assert maildomain in res.json().get("email")
|
||||
assert len(res.json().get("password")) > chatmail_config.password_min_length
|
||||
|
||||
|
||||
def test_newemail_configure(maildomain, rpc):
|
||||
"""Test configuring accounts by scanning a QR code works."""
|
||||
url = f"DCACCOUNT:https://{maildomain}/new"
|
||||
for i in range(3):
|
||||
account_id = rpc.add_account()
|
||||
rpc.set_config_from_qr(account_id, url)
|
||||
rpc.configure(account_id)
|
||||
|
||||
@@ -1,57 +1,6 @@
|
||||
import smtplib
|
||||
|
||||
import pytest
|
||||
|
||||
from cmdeploy import remote_funcs
|
||||
from cmdeploy.sshexec import SSHExec
|
||||
|
||||
|
||||
class TestSSHExecutor:
|
||||
@pytest.fixture(scope="class")
|
||||
def sshexec(self, sshdomain):
|
||||
return SSHExec(sshdomain, remote_funcs)
|
||||
|
||||
def test_ls(self, sshexec):
|
||||
out = sshexec(call=remote_funcs.shell, kwargs=dict(command="ls"))
|
||||
out2 = sshexec(call=remote_funcs.shell, kwargs=dict(command="ls"))
|
||||
assert out == out2
|
||||
|
||||
def test_perform_initial(self, sshexec, maildomain):
|
||||
res = sshexec(
|
||||
remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
)
|
||||
assert res["A"] or res["AAAA"]
|
||||
|
||||
def test_logged(self, sshexec, maildomain, capsys):
|
||||
sshexec.logged(
|
||||
remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
)
|
||||
out, err = capsys.readouterr()
|
||||
assert out.startswith("Collecting")
|
||||
assert out.endswith("....\n")
|
||||
assert out.count("\n") == 1
|
||||
|
||||
sshexec.verbose = True
|
||||
sshexec.logged(
|
||||
remote_funcs.perform_initial_checks, kwargs=dict(mail_domain=maildomain)
|
||||
)
|
||||
out, err = capsys.readouterr()
|
||||
lines = out.split("\n")
|
||||
assert len(lines) > 4
|
||||
assert remote_funcs.perform_initial_checks.__doc__ in lines[0]
|
||||
|
||||
def test_exception(self, sshexec, capsys):
|
||||
try:
|
||||
sshexec.logged(
|
||||
remote_funcs.perform_initial_checks,
|
||||
kwargs=dict(mail_domain=None),
|
||||
)
|
||||
except sshexec.FuncError as e:
|
||||
assert "remote_funcs.py" in str(e)
|
||||
assert "AssertionError" in str(e)
|
||||
else:
|
||||
pytest.fail("didn't raise exception")
|
||||
|
||||
|
||||
def test_remote(remote, imap_or_smtp):
|
||||
lineproducer = remote.iter_output(imap_or_smtp.logcmd)
|
||||
@@ -93,28 +42,6 @@ def test_reject_forged_from(cmsetup, maildata, gencreds, lp, forgeaddr):
|
||||
assert "500" in str(e.value)
|
||||
|
||||
|
||||
def test_authenticated_from(cmsetup, maildata):
|
||||
"""Test that envelope FROM must be the same as login."""
|
||||
user1, user2, user3 = cmsetup.gen_users(3)
|
||||
|
||||
msg = maildata("encrypted.eml", from_addr=user2.addr, to_addr=user3.addr)
|
||||
with pytest.raises(smtplib.SMTPException) as e:
|
||||
user1.smtp.sendmail(
|
||||
from_addr=user2.addr, to_addrs=[user3.addr], msg=msg.as_string()
|
||||
)
|
||||
assert e.value.recipients[user3.addr][0] == 553
|
||||
|
||||
|
||||
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
|
||||
def test_reject_missing_dkim(cmsetup, maildata, from_addr):
|
||||
"""Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected."""
|
||||
recipient = cmsetup.gen_users(1)[0]
|
||||
msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
|
||||
with smtplib.SMTP(cmsetup.maildomain, 25) as s:
|
||||
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
|
||||
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
|
||||
"""Test that the per-account send-mail limit is exceeded."""
|
||||
@@ -134,19 +61,3 @@ def test_exceed_rate_limit(cmsetup, gencreds, maildata, chatmail_config):
|
||||
assert b"4.7.1: Too much mail from" in outcome[1]
|
||||
return
|
||||
pytest.fail("Rate limit was not exceeded")
|
||||
|
||||
|
||||
@pytest.mark.slow
|
||||
def test_expunged(remote, chatmail_config):
|
||||
outdated_days = int(chatmail_config.delete_mails_after) + 1
|
||||
find_cmds = [
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/.*/cur/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/new/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/.*/new/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/tmp/*' -mtime +{outdated_days} -type f",
|
||||
f"find {chatmail_config.mailboxes_dir} -path '*/.*/tmp/*' -mtime +{outdated_days} -type f",
|
||||
]
|
||||
for cmd in find_cmds:
|
||||
for line in remote.iter_output(cmd):
|
||||
assert not line
|
||||
|
||||
@@ -1,50 +1,7 @@
|
||||
import ipaddress
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
|
||||
import imap_tools
|
||||
import re
|
||||
import random
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def imap_mailbox(cmfactory):
|
||||
(ac1,) = cmfactory.get_online_accounts(1)
|
||||
user = ac1.get_config("addr")
|
||||
password = ac1.get_config("mail_pw")
|
||||
mailbox = imap_tools.MailBox(user.split("@")[1])
|
||||
mailbox.login(user, password)
|
||||
return mailbox
|
||||
|
||||
|
||||
class TestMetadataTokens:
|
||||
"Tests that use Metadata extension for storing tokens"
|
||||
|
||||
def test_set_get_metadata(self, imap_mailbox):
|
||||
"set and get metadata token for an account"
|
||||
client = imap_mailbox.client
|
||||
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "1111" )\n')
|
||||
res = client.readline()
|
||||
assert b"OK Setmetadata completed" in res
|
||||
|
||||
client.send(b"a02 GETMETADATA INBOX /private/devicetoken\n")
|
||||
res = client.readline()
|
||||
assert res[:1] == b"*"
|
||||
res = client.readline().strip().rstrip(b")")
|
||||
assert res == b"1111"
|
||||
assert b"Getmetadata completed" in client.readline()
|
||||
|
||||
client.send(b'a01 SETMETADATA INBOX (/private/devicetoken "2222" )\n')
|
||||
res = client.readline()
|
||||
assert b"OK Setmetadata completed" in res
|
||||
|
||||
client.send(b"a02 GETMETADATA INBOX /private/devicetoken\n")
|
||||
res = client.readline()
|
||||
assert res[:1] == b"*"
|
||||
res = client.readline().strip().rstrip(b")")
|
||||
assert res == b"1111 2222"
|
||||
assert b"Getmetadata completed" in client.readline()
|
||||
|
||||
|
||||
class TestEndToEndDeltaChat:
|
||||
@@ -103,7 +60,7 @@ class TestEndToEndDeltaChat:
|
||||
|
||||
addr = ac2.get_config("addr").lower()
|
||||
saved_ok = 0
|
||||
for line in remote.iter_output("journalctl -n0 -f -u dovecot"):
|
||||
for line in remote.iter_output("journalctl -f -u dovecot"):
|
||||
if addr not in line:
|
||||
# print(line)
|
||||
continue
|
||||
@@ -115,10 +72,7 @@ class TestEndToEndDeltaChat:
|
||||
)
|
||||
lp.indent("good, message sending failed because quota was exceeded")
|
||||
return
|
||||
if (
|
||||
"stored mail into mailbox 'inbox'" in line
|
||||
or "saved mail to inbox" in line
|
||||
):
|
||||
if "saved mail to inbox" in line:
|
||||
saved_ok += 1
|
||||
print(f"{saved_ok}: {line}")
|
||||
if saved_ok >= num_to_send:
|
||||
@@ -155,7 +109,7 @@ class TestEndToEndDeltaChat:
|
||||
lp.sec("ac1 sends a message and ac2 marks it as seen")
|
||||
chat = ac1.create_chat(ac2)
|
||||
msg = chat.send_text("hi")
|
||||
m = ac2._evtracker.wait_next_incoming_message()
|
||||
m = ac2.wait_next_incoming_message()
|
||||
m.mark_seen()
|
||||
# we can only indirectly wait for mark-seen to cause an smtp-error
|
||||
lp.sec("try to wait for markseen to complete and check error states")
|
||||
@@ -165,29 +119,3 @@ class TestEndToEndDeltaChat:
|
||||
for msg in msgs:
|
||||
assert "error" not in m.get_message_info()
|
||||
time.sleep(1)
|
||||
|
||||
|
||||
def test_hide_senders_ip_address(cmfactory):
|
||||
public_ip = requests.get("http://icanhazip.com").content.decode().strip()
|
||||
assert ipaddress.ip_address(public_ip)
|
||||
|
||||
user1, user2 = cmfactory.get_online_accounts(2)
|
||||
chat = cmfactory.get_accepted_chat(user1, user2)
|
||||
|
||||
chat.send_text("testing submission header cleanup")
|
||||
user2._evtracker.wait_next_incoming_message()
|
||||
user2.direct_imap.select_folder("Inbox")
|
||||
msg = user2.direct_imap.get_all_messages()[0]
|
||||
assert public_ip not in msg.obj.as_string()
|
||||
|
||||
|
||||
def test_echobot(cmfactory, chatmail_config, lp):
|
||||
ac = cmfactory.get_online_accounts(1)[0]
|
||||
|
||||
lp.sec(f"Send message to echo@{chatmail_config.mail_domain}")
|
||||
chat = ac.create_chat(f"echo@{chatmail_config.mail_domain}")
|
||||
text = "hi, I hope you text me back"
|
||||
chat.send_text(text)
|
||||
lp.sec("Wait for reply from echobot")
|
||||
reply = ac._evtracker.wait_next_incoming_message()
|
||||
assert reply.text == text
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
import imaplib
|
||||
import io
|
||||
import itertools
|
||||
import os
|
||||
import random
|
||||
import smtplib
|
||||
import subprocess
|
||||
import io
|
||||
import time
|
||||
import random
|
||||
import subprocess
|
||||
import imaplib
|
||||
import smtplib
|
||||
import itertools
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from chatmaild.config import read_config
|
||||
|
||||
from chatmaild.database import Database
|
||||
from chatmaild.config import read_config
|
||||
|
||||
|
||||
conftestdir = Path(__file__).parent
|
||||
|
||||
@@ -35,7 +36,7 @@ def pytest_runtest_setup(item):
|
||||
pytest.skip("skipping slow test, use --slow to run")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.fixture
|
||||
def chatmail_config(pytestconfig):
|
||||
current = basedir = Path().resolve()
|
||||
while 1:
|
||||
@@ -49,12 +50,12 @@ def chatmail_config(pytestconfig):
|
||||
pytest.skip(f"no chatmail.ini file found in {basedir} or parent dirs")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.fixture
|
||||
def maildomain(chatmail_config):
|
||||
return chatmail_config.mail_domain
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
@pytest.fixture
|
||||
def sshdomain(maildomain):
|
||||
return os.environ.get("CHATMAIL_SSH", maildomain)
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from cmdeploy.cmdeploy import get_parser, main
|
||||
from chatmaild.config import read_config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -21,9 +21,13 @@ class TestCmdline:
|
||||
run = parser.parse_args(["run"])
|
||||
assert init and run
|
||||
|
||||
def test_init_not_overwrite(self, capsys):
|
||||
assert main(["init", "chat.example.org"]) == 0
|
||||
capsys.readouterr()
|
||||
assert main(["init", "chat.example.org"]) == 1
|
||||
out, err = capsys.readouterr()
|
||||
assert "path exists" in out.lower()
|
||||
def test_init(self, tmp_path):
|
||||
main(["init", "chat.example.org"])
|
||||
inipath = tmp_path.joinpath("chatmail.ini")
|
||||
config = read_config(inipath)
|
||||
assert config.mail_domain == "chat.example.org"
|
||||
|
||||
def test_init_not_overwrite(self):
|
||||
main(["init", "chat.example.org"])
|
||||
with pytest.raises(SystemExit):
|
||||
main(["init", "chat.example.org"])
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import pytest
|
||||
|
||||
from cmdeploy import remote_funcs
|
||||
from cmdeploy.dns import check_initial_remote_data
|
||||
|
||||
|
||||
class TestPerformInitialChecks:
|
||||
@pytest.fixture
|
||||
def mockdns(self, monkeypatch):
|
||||
qdict = {
|
||||
"A": {"some.domain": "1.1.1.1"},
|
||||
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
|
||||
"CNAME": {"mta-sts.some.domain": "some.domain"},
|
||||
}.copy()
|
||||
|
||||
def query_dns(typ, domain):
|
||||
try:
|
||||
return qdict[typ][domain]
|
||||
except KeyError:
|
||||
return ""
|
||||
|
||||
monkeypatch.setattr(remote_funcs, query_dns.__name__, query_dns)
|
||||
return qdict
|
||||
|
||||
def test_perform_initial_checks_ok1(self, mockdns):
|
||||
remote_data = remote_funcs.perform_initial_checks("some.domain")
|
||||
assert len(remote_data) == 7
|
||||
|
||||
@pytest.mark.parametrize("drop", ["A", "AAAA"])
|
||||
def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop):
|
||||
del mockdns[drop]
|
||||
remote_data = remote_funcs.perform_initial_checks("some.domain")
|
||||
assert len(remote_data) == 7
|
||||
assert not remote_data[drop]
|
||||
|
||||
l = []
|
||||
res = check_initial_remote_data(remote_data, print=l.append)
|
||||
assert res
|
||||
assert not l
|
||||
|
||||
def test_perform_initial_checks_no_mta_sts(self, mockdns):
|
||||
del mockdns["CNAME"]
|
||||
remote_data = remote_funcs.perform_initial_checks("some.domain")
|
||||
assert len(remote_data) == 4
|
||||
assert not remote_data["MTA_STS"]
|
||||
|
||||
l = []
|
||||
res = check_initial_remote_data(remote_data, print=l.append)
|
||||
assert not res
|
||||
assert len(l) == 2
|
||||
@@ -1,14 +1,13 @@
|
||||
import hashlib
|
||||
import importlib.resources
|
||||
import webbrowser
|
||||
import hashlib
|
||||
import time
|
||||
import traceback
|
||||
import webbrowser
|
||||
|
||||
import markdown
|
||||
from chatmaild.config import read_config
|
||||
from jinja2 import Template
|
||||
|
||||
from .genqr import gen_qr_png_data
|
||||
from chatmaild.config import read_config
|
||||
|
||||
|
||||
def snapshot_dir_stats(somedir):
|
||||
@@ -37,6 +36,29 @@ def build_webpages(src_dir, build_dir, config):
|
||||
print(traceback.format_exc())
|
||||
|
||||
|
||||
def timespan_to_english(timespan):
|
||||
val = int(timespan[:-1])
|
||||
c = timespan[-1].lower()
|
||||
match c:
|
||||
case "y":
|
||||
return f"{val} years"
|
||||
case "m":
|
||||
return f"{val} months"
|
||||
case "w":
|
||||
return f"{val} weeks"
|
||||
case "d":
|
||||
return f"{val} days"
|
||||
case "h":
|
||||
return f"{val} hours"
|
||||
case "c":
|
||||
return f"{val} seconds"
|
||||
case _:
|
||||
raise ValueError(
|
||||
c
|
||||
+ " is not a valid time unit. Try [y]ears, [w]eeks, [d]ays, or [h]ours"
|
||||
)
|
||||
|
||||
|
||||
def int_to_english(number):
|
||||
if number >= 0 and number <= 12:
|
||||
a = [
|
||||
@@ -82,6 +104,9 @@ def _build_webpages(src_dir, build_dir, config):
|
||||
render_vars["password_min_length"] = int_to_english(
|
||||
config.password_min_length
|
||||
)
|
||||
render_vars["delete_mails_after"] = timespan_to_english(
|
||||
config.delete_mails_after
|
||||
)
|
||||
target = build_dir.joinpath(path.stem + ".html")
|
||||
|
||||
# recursive jinja2 rendering
|
||||
@@ -121,8 +146,7 @@ def main():
|
||||
print(f"watching {src_path} directory for changes")
|
||||
|
||||
changenum = 0
|
||||
count = 0
|
||||
while True:
|
||||
for count in range(0, 1000000):
|
||||
newstats = snapshot_dir_stats(src_path)
|
||||
if newstats == stats and count % 60 != 0:
|
||||
count += 1
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Wrapper for cmdelpoy to run it in activated virtualenv.
|
||||
set -e
|
||||
. venv/bin/activate
|
||||
cmdeploy "$@"
|
||||
@@ -1,80 +0,0 @@
|
||||
## Introduction to custom Dovecot builds
|
||||
|
||||
Chatmail servers use a custom Debian build of the IMAP 'dovecot' server software because
|
||||
|
||||
a) Dovecot developers did not yet merge a [pull request](https://github.com/dovecot/core/pull/216)
|
||||
which majorly speeds up message delivery by removing a hardcoded 0.5 second delay
|
||||
on relaying incoming messages.
|
||||
|
||||
b) Even if merged, it would take years for it to reach Debian stable.
|
||||
|
||||
c) The modified dovecot has been successfully used since December 2023 without issues
|
||||
and we see no noticeable downside (theoretically higher CPU usage but not measureable)
|
||||
but a considerable upside as the delay-removal facilitates end-to-end message
|
||||
delivery of 200 ms in real networks.
|
||||
|
||||
The modified forked dovecot code lives at
|
||||
[https://github.com/chatmail/dovecot](https://github.com/chatmail/dovecot).
|
||||
The remainder of this document describes the setup of the Debian repository
|
||||
containing the patched dovecot version.
|
||||
|
||||
## Building Debian packages at build.opensuse.org
|
||||
|
||||
Delta Chat developers maintain an [account](https://build.opensuse.org/project/show/home:deltachat)
|
||||
in the [Open Build Service (OBS)](https://openbuildservice.org/),
|
||||
where the [resulting package](https://build.opensuse.org/package/show/home:deltachat/dovecot)
|
||||
is now used in deploying chatmail servers.
|
||||
|
||||
The Open Build Service (OBS) is a platform for building and distributing software packages
|
||||
across various operating systems and architectures.
|
||||
It supports openSUSE, Fedora, Debian, Ubuntu and Arch.
|
||||
It's [primary instance](https://build.opensuse.org/) is ran by the openSUSE project
|
||||
and is part of the pipeline of the creation of SUSE Linux Enterprise.
|
||||
|
||||
The OBS provides a mercurial-like interface to create source repositories
|
||||
that are then automatically built.
|
||||
While in theory a package can be created entirely over the web interface,
|
||||
the use of the cli-tool `osc` is more convenient and is described in the [official documentation](https://openbuildservice.org/help/manuals/obs-user-guide/art.obs.bg#sec.obsbg.obsconfig).
|
||||
|
||||
### How to build the dovecot debian package on the OBS via our script
|
||||
|
||||
In scripts/dovecot/ is a shell script that prepares the required files and pushes them to build.opensuse.org.
|
||||
|
||||
Before using the script, you should have osc set up as described in the [official documentation](https://openbuildservice.org/help/manuals/obs-user-guide/art.obs.bg#sec.obsbg.obsconfig).
|
||||
|
||||
The script assumes you are on Debian. It automatically installs any needed dependencies and creates the source package. To upload the resulting source package to the OBS you need to enter the username and password for deltachat on build.opensuse.org in the last step of the script.
|
||||
|
||||
Use `source build-obs.sh` to run it.
|
||||
|
||||
### Adding the resulting OBS repository to Debian 12
|
||||
|
||||
Our dovecot fork is automatically installed as part of the chatmail deployment. You can see it in cmdeploy/src/cmdeploy/__init__.py. If you want to add our fork manually to a system, you can do the following:
|
||||
|
||||
First add our signing key to your apt keyring:
|
||||
|
||||
```
|
||||
sudo cp cmdeploy/src/cmdeploy/obs-home-deltachat.gpg /etc/apt/keyrings/obs-home-deltachat.gpg`
|
||||
```
|
||||
|
||||
Now add our repository and key to /etc/apt/sources.list with a text editor of your choice:
|
||||
|
||||
```
|
||||
deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./
|
||||
```
|
||||
|
||||
You can now install dovecot like normal.
|
||||
|
||||
```
|
||||
sudo apt update
|
||||
sudo apt install dovecot-core
|
||||
```
|
||||
|
||||
### Security concerns
|
||||
|
||||
The signing of the patched dovecot package is done in the OBS and
|
||||
in theory SUSE could make changes to the package delivered.
|
||||
It is probably reasonable to trust SUSE to not mess with the build
|
||||
process because it would cause serious negative reputation damage for them
|
||||
if they tried and someone finds out.
|
||||
|
||||
Our dovecot fork will receive the same security backports as the dovecot package in Debian Sid.
|
||||
@@ -1,54 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Install dependencies
|
||||
echo "Installing dependencies for this script:"
|
||||
sudo apt install -y devscripts build-essential osc curl git debhelper-compat
|
||||
|
||||
# Define path of your local OBS repository
|
||||
SCRIPT_DIR=$PWD
|
||||
OBS_PATH=$SCRIPT_DIR/obs
|
||||
REPO_PATH=$OBS_PATH/home:deltachat/dovecot/
|
||||
|
||||
# Download Debian Source Files
|
||||
echo "Downloading precise files from Debian unstable repository..."
|
||||
mkdir dovecot-build
|
||||
cd dovecot-build
|
||||
|
||||
# taken May 6th 2024, from https://packages.debian.org/unstable/dovecot-core
|
||||
curl http://deb.debian.org/debian/pool/main/d/dovecot/dovecot_2.3.21+dfsg1-3.debian.tar.xz -O
|
||||
curl http://deb.debian.org/debian/pool/main/d/dovecot/dovecot_2.3.21+dfsg1.orig.tar.gz -O
|
||||
curl http://deb.debian.org/debian/pool/main/d/dovecot/dovecot_2.3.21+dfsg1.orig-pigeonhole.tar.gz -O
|
||||
|
||||
# Clone the Chatmail Dovecot Repo
|
||||
echo "Cloning the Chatmail Dovecot fork..."
|
||||
git clone https://github.com/chatmail/dovecot.git
|
||||
|
||||
# Build the source package
|
||||
echo "Building the source package"
|
||||
cd dovecot
|
||||
dpkg-source -b .
|
||||
|
||||
# Setting up OSC
|
||||
echo "Setting up OBS home repository"
|
||||
mkdir $OBS_PATH
|
||||
cd $OBS_PATH
|
||||
rm -rf home:deltachat/dovecot
|
||||
osc checkout home:deltachat/dovecot
|
||||
|
||||
# Copy Files to Your Local OBS Repository,
|
||||
echo "Copying files to your local OBS repository..."
|
||||
cd $SCRIPT_DIR/dovecot-build
|
||||
cp -rf dovecot_2.3.21+dfsg1-3.debian.tar.xz $REPO_PATH
|
||||
cp -rf dovecot_2.3.21+dfsg1.orig.tar.gz $REPO_PATH
|
||||
cp -rf dovecot_2.3.21+dfsg1.orig-pigeonhole.tar.gz $REPO_PATH
|
||||
cp -rf dovecot_2.3.21+dfsg1-3.dsc $REPO_PATH
|
||||
|
||||
# Push Changes to OBS
|
||||
echo "Pushing changes to OBS..."
|
||||
cd $REPO_PATH
|
||||
osc up
|
||||
osc add dovecot_2.3.21+dfsg1-3.debian.tar.xz
|
||||
osc add dovecot_2.3.21+dfsg1.orig.tar.gz
|
||||
osc add dovecot_2.3.21+dfsg1.orig-pigeonhole.tar.gz
|
||||
osc add dovecot_2.3.21+dfsg1-3.dsc
|
||||
osc commit
|
||||
@@ -1,6 +1,9 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python3 -m venv --upgrade-deps venv
|
||||
python3 -m venv venv
|
||||
|
||||
venv/bin/pip install -e chatmaild
|
||||
venv/bin/pip install -e cmdeploy
|
||||
|
||||
source venv/bin/activate
|
||||
echo activated 'venv' python virtualenv environment containing "cmdeploy" tool
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
|
||||
<img class="banner" src="collage-top.png"/>
|
||||
|
||||
## Dear [Delta Chat](https://get.delta.chat) users and newcomers ...
|
||||
## Dear [Delta Chat](https://get.delta.chat) users and newcomers,
|
||||
|
||||
{% if config.mail_domain != "nine.testrun.org" %}
|
||||
Welcome to instant, interoperable and [privacy-preserving](privacy.html) messaging :)
|
||||
{% else %}
|
||||
Welcome to the default onboarding server ({{ config.mail_domain }})
|
||||
for Delta Chat users. For details how it avoids storing personal information
|
||||
please see our [privacy policy](privacy.html).
|
||||
{% endif %}
|
||||
|
||||
👉 **Tap** or scan this QR code to get a `@{{config.mail_domain}}` chat profile
|
||||
👉 **Tap** or scan this QR code to get a random `@{{config.mail_domain}}` e-mail address
|
||||
|
||||
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
|
||||
<a href="DCACCOUNT:https://{{ config.mail_domain }}/cgi-bin/newemail.py">
|
||||
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>
|
||||
|
||||
🐣 **Choose** your Avatar and Name
|
||||
|
||||
💬 **Start** chatting with any Delta Chat contacts using [QR invite codes](https://delta.chat/en/help#howtoe2ee)
|
||||
|
||||
{% if config.mail_domain != "nine.testrun.org" %}
|
||||
<div class="experimental">Note: this is only a temporary development chatmail service</div>
|
||||
{% endif %}
|
||||
<div class="experimental">Note: this is an experimental service</div>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
|
||||
<img class="banner" src="collage-info.png"/>
|
||||
|
||||
## More information
|
||||
|
||||
{{ config.mail_domain }} provides a low-maintenance, resource efficient and
|
||||
interoperable e-mail service for everyone. What's behind a `chatmail` is
|
||||
effectively a normal e-mail address just like any other but optimized
|
||||
for the usage in chats, especially DeltaChat.
|
||||
|
||||
### Choosing a chatmail address instead of using a random one
|
||||
|
||||
In the Delta Chat account setup
|
||||
you may tap `I already have a profile`
|
||||
you may tap `LOG INTO YOUR E-MAIL ACCOUNT`
|
||||
and fill the two fields like this:
|
||||
|
||||
- `Address`: invent a word with
|
||||
@@ -40,7 +37,7 @@ The first login sets your password.
|
||||
|
||||
- You may send up to {{ config.max_user_send_per_minute }} messages per minute.
|
||||
|
||||
- Messages are unconditionally removed {{ config.delete_mails_after }} days after arriving on the server.
|
||||
- Seen messages are removed {{ delete_mails_after }} after arriving on the server.
|
||||
|
||||
- You can store up to [{{ config.max_mailbox_size }} messages on the server](https://delta.chat/en/help#what-happens-if-i-turn-on-delete-old-messages-from-server).
|
||||
|
||||
|
||||
@@ -1,47 +1,4 @@
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 'Segoe UI Emoji', 'Apple Color Emoji', 'Noto Color Emoji', sans-serif;
|
||||
line-height: 1.4;
|
||||
font-size: 1.2em;
|
||||
max-width: 800px;
|
||||
margin: 20px auto;
|
||||
padding: 0 10px;
|
||||
color: #363636;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.2em;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #000;
|
||||
margin-bottom: 12px;
|
||||
margin-top: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: #0076d1;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
background: #efefef;
|
||||
padding: 2.5px 5px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
#menu {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
{% if config.webdev %}
|
||||
<meta http-equiv="refresh" content="3">
|
||||
{% endif %}
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
|
||||
<title>{{ config.mail_domain }} {{ pagename }}</title>
|
||||
<link rel="stylesheet" href="./water.css">
|
||||
<link rel="stylesheet" href="./main.css">
|
||||
<link rel="icon" href="/logo.svg">
|
||||
<link rel=”mask-icon” href=”/logo.svg” color=”#000000">
|
||||
|
||||
@@ -1,41 +1,21 @@
|
||||
<img class="banner" src="collage-privacy.png"/>
|
||||
|
||||
# Privacy Policy for {{ config.mail_domain }}
|
||||
|
||||
{% if config.mail_domain == "nine.testrun.org" %}
|
||||
Welcome to `{{config.mail_domain}}`, the default chatmail onboarding server for Delta Chat users.
|
||||
It is operated on the side by a small sysops team employed by [merlinux](https://merlinux.eu),
|
||||
an open-source R&D company also acting as the fiscal sponsor of Delta Chat app developments.
|
||||
See [other chatmail servers](https://delta.chat/en/chatmail) for alternative server operators.
|
||||
{% endif %}
|
||||
|
||||
|
||||
## Summary: No personal data asked or collected
|
||||
|
||||
This chatmail server neither asks for nor retains personal information.
|
||||
Chatmail servers exist to reliably transmit (store and deliver) end-to-end encrypted messages
|
||||
between user's devices running the Delta Chat messenger app.
|
||||
Technically, you may think of a Chatmail server as
|
||||
an end-to-end encrypted "messaging router" at Internet-scale.
|
||||
|
||||
A chatmail server is very unlike classic e-mail servers (for example Google Mail servers)
|
||||
that ask for personal data and permanently store messages.
|
||||
A chatmail server behaves more like the Signal messaging server
|
||||
but does not know about phone numbers and securely and automatically interoperates
|
||||
with other chatmail and classic e-mail servers.
|
||||
|
||||
In particular, this chatmail server
|
||||
|
||||
- unconditionally removes messages after {{ config.delete_mails_after }} days,
|
||||
|
||||
- prohibits sending out un-encrypted messages,
|
||||
|
||||
- only has temporary log files used for debugging purposes.
|
||||
|
||||
Legally, authorities might still regard chatmail as a "classic e-mail" server
|
||||
which collects and retains personal data.
|
||||
We do not agree on this interpretation. Nevertheless, we provide more legal details below
|
||||
to make life easier for data protection specialists and lawyers scrutinizing chatmail operations.
|
||||
We want to show you in a fair and transparent way
|
||||
what personal data is processed by us.
|
||||
We follow a strict privacy-by-design approach
|
||||
and try to avoid processing your data in the first place,
|
||||
but as you may know,
|
||||
the internet,
|
||||
and in particular sending e-mail messages,
|
||||
does not work without data.
|
||||
Still,
|
||||
it's only fair that you know at all times
|
||||
what personal data is processed
|
||||
when you use our service.
|
||||
|
||||
If you have any remaining questions about data protection, please contact us.
|
||||
|
||||
## 1. Name and contact information
|
||||
|
||||
@@ -77,12 +57,12 @@ we process the following data and details:
|
||||
- Users can retrieve or delete all stored messages
|
||||
without intervention from the operators using standard IMAP client tools.
|
||||
|
||||
### 2.1 Account setup
|
||||
### 3.1 Account setup
|
||||
|
||||
Creating an account happens in one of two ways on our mail servers:
|
||||
|
||||
- with a QR invitation token
|
||||
which is scanned using the Delta Chat app
|
||||
which is scanned using the DeltaChat app
|
||||
and then the account is created.
|
||||
|
||||
- by letting Delta Chat otherwise create an account
|
||||
@@ -98,7 +78,7 @@ Art. 6 (1) lit. b GDPR,
|
||||
as you have a usage contract with us
|
||||
by using our services.
|
||||
|
||||
### 2.2 Processing of E-Mail-Messages
|
||||
## 3.2 Processing of E-Mail-Messages
|
||||
|
||||
In addition,
|
||||
we will process data
|
||||
@@ -124,7 +104,7 @@ Therefore, limits are enforced:
|
||||
|
||||
- message size limits
|
||||
|
||||
- any other limit necessary for the whole server to function in a healthy way
|
||||
- any other limit neccessary for the whole server to function in a healthy way
|
||||
and to prevent abuse.
|
||||
|
||||
The processing and use of the above permissions
|
||||
@@ -198,9 +178,8 @@ for the purpose of drawing conclusions about your person.
|
||||
|
||||
## 4. Transfer of Data
|
||||
|
||||
We do not retain any personal data but e-mail messages waiting to be delivered
|
||||
may contain personal data.
|
||||
Any such residual personal data will not be transferred to third parties
|
||||
Your personal data
|
||||
will not be transferred to third parties
|
||||
for purposes other than those listed below:
|
||||
|
||||
a) you have given your express consent
|
||||
@@ -239,16 +218,97 @@ on these or possible objections.
|
||||
A deletion can be made
|
||||
directly in the Delta Chat email messenger.
|
||||
|
||||
If you have any questions or complaints,
|
||||
please feel free to contact us by email:
|
||||
{{ config.privacy_mail }}
|
||||
a) request information about your personal data processed by us
|
||||
in accordance with Art. 15 GDPR.
|
||||
In particular,
|
||||
you can request information about the processing purposes,
|
||||
the category of personal data,
|
||||
the categories of recipients to whom your data have been or will be disclosed,
|
||||
the planned storage period,
|
||||
the existence of a right to rectification, erasure, restriction of processing or objection,
|
||||
the existence of a right of complaint,
|
||||
the origin of your data if it has not been collected by us,
|
||||
as well as the existence of automated decision-making including profiling
|
||||
and, if applicable,
|
||||
meaningful information about its details;
|
||||
|
||||
As a rule, you can contact the supervisory authority of your usual place of residence
|
||||
b) in accordance with Art. 16 of the GDPR,
|
||||
immediately request the correction
|
||||
of inaccurate or incomplete personal data stored by us;
|
||||
|
||||
c) pursuant to Article 17 of the GDPR,
|
||||
to request the erasure of your personal data stored by us,
|
||||
unless the processing is necessary
|
||||
for the exercise of the right to freedom of expression and information,
|
||||
for compliance with a legal obligation,
|
||||
for reasons of public interest,
|
||||
or for the establishment, exercise or defence of legal claims;
|
||||
|
||||
d) pursuant to Art. 18 GDPR,
|
||||
to request the restriction of the processing of your personal data,
|
||||
insofar as the accuracy of the data is disputed by you,
|
||||
the processing is unlawful,
|
||||
but you object to its erasure
|
||||
and we no longer require the data,
|
||||
but you need it for the assertion, exercise or defence of legal claims
|
||||
or you have objected to the processing pursuant to Art. 21 GDPR;
|
||||
|
||||
e) pursuant to Art. 20 GDPR,
|
||||
to receive your personal data that you have provided to us
|
||||
in a structured, common and machine-readable format
|
||||
or to request that it be transferred to another controller;
|
||||
|
||||
f) in accordance with Art. 7 (3) of the GDPR,
|
||||
to revoke your consent given to us at any time.
|
||||
This has the consequence that we may no longer continue the data processing
|
||||
based on this consent in the future; and
|
||||
|
||||
g) complain to a supervisory authority
|
||||
in accordance with Article 77 of the GDPR.
|
||||
As a rule,
|
||||
you can contact the supervisory authority of your usual place of residence
|
||||
or workplace
|
||||
or our registered office for this purpose.
|
||||
The supervisory authority responsible for our place of business
|
||||
is the `{{ config.privacy_supervisor }}`.
|
||||
|
||||
If you have any questions or complaints, please feel free to contact us by email:
|
||||
{{ config.privacy_mail }}
|
||||
|
||||
|
||||
### 5.1 Right to object
|
||||
|
||||
If your personal data is processed on the basis of our legitimate interests
|
||||
in accordance with Art. 6 (1) lit. f GDPR,
|
||||
you have the right to object to the processing of your personal data
|
||||
in accordance with Art. 21 GDPR,
|
||||
provided that there are grounds for this based on your particular situation
|
||||
or the objection is directed against direct advertising.
|
||||
In the latter case,
|
||||
you have a general right of objection,
|
||||
which will be implemented by us
|
||||
without specifying a particular situation.
|
||||
|
||||
If you wish to exercise your right of objection,
|
||||
simply send an e-mail to: {{ config.privacy_mail }}
|
||||
|
||||
### 5.2 Right to withdraw
|
||||
|
||||
If your personal data is processed on the basis of your consent
|
||||
in accordance with Art. 6 (1) lit. a GDPR
|
||||
(e.g. via the mailing list),
|
||||
you can withdraw your consent at any time
|
||||
and without any disadvantages.
|
||||
As a result,
|
||||
we may no longer continue the data processing
|
||||
that was based on this consent for the future.
|
||||
However,
|
||||
the withdrawal of your consent
|
||||
does not affect the lawfulness of the processing
|
||||
carried out on the basis of the consent until the withdrawal.
|
||||
|
||||
If you wish to make use of your right of withdrawal,
|
||||
simply send an e-mail to: {{ config.privacy_mail }}
|
||||
|
||||
## 6. Validity of this privacy policy
|
||||
|
||||
|
||||
1690
www/src/water.css
Normal file
1690
www/src/water.css
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user