Compare commits

..

1 Commits

Author SHA1 Message Date
link2xt
9c4da90377 Remove authclean service from reinjecting port
Messages are already cleaned once on the submission port
to avoid leaking the user IP address.
There is no need to clean them again
and remove the `Received` header
which records reception from filtermail.
2025-03-08 01:57:30 +00:00
58 changed files with 466 additions and 1754 deletions

View File

@@ -1,33 +0,0 @@
---
name: Bug report
about: Report something that isn't working.
title: ''
assignees: ''
---
<!--
Please fill out as much of this form as you can (leaving out stuff that is not applicable is ok).
-->
- Server OS (Operating System) - preferably Debian 12:
- On which OS you run cmdeploy:
- chatmail/relay version: `git rev-parse HEAD`
## Expected behavior
*What did you try to achieve?*
## Actual behavior
*What happened instead?*
### Steps to reproduce the problem:
1.
2.
### Screenshots
### Logs

View File

@@ -1,5 +0,0 @@
blank_issues_enabled: true
contact_links:
- name: Mutual Help Chat Group
url: https://i.delta.chat/#6CBFF8FFD505C0FDEA20A66674F2916EA8FBEE99&a=invitebot%40nine.testrun.org&g=Chatmail%20Mutual%20Help&x=7sFF7Ik50pWv6J1z7RVC5527&i=X69wTFfvCfs3d-JzqP0kVA3i&s=ibp-447dU-wUq-52QanwAtWc
about: If you have troubles setting up the relay server, feel free to ask here.

View File

@@ -10,10 +10,6 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Checkout pull request HEAD commit instead of merge commit
# Otherwise `test_deployed_state` will be unhappy.
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: run chatmaild tests - name: run chatmaild tests
working-directory: chatmaild working-directory: chatmaild

View File

@@ -70,6 +70,9 @@ jobs:
rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true rsync -avz dkimkeys-restore/dkimkeys root@staging-ipv4.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true ssh -o StrictHostKeyChecking=accept-new -v root@staging-ipv4.testrun.org chown root:root -R /var/lib/acme || true
- name: run formatting checks
run: cmdeploy fmt -v
- name: run deploy-chatmail offline tests - name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy run: pytest --pyargs cmdeploy
@@ -77,7 +80,7 @@ jobs:
cmdeploy init staging-ipv4.testrun.org cmdeploy init staging-ipv4.testrun.org
sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini sed -i 's#disable_ipv6 = False#disable_ipv6 = True#' chatmail.ini
- run: cmdeploy run --verbose --skip-dns-check - run: cmdeploy run
- name: set DNS entries - name: set DNS entries
run: | run: |

View File

@@ -70,12 +70,15 @@ jobs:
rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true rsync -avz dkimkeys-restore/dkimkeys root@staging2.testrun.org:/etc/ || true
ssh -o StrictHostKeyChecking=accept-new -v root@staging2.testrun.org chown root:root -R /var/lib/acme || 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 - name: run deploy-chatmail offline tests
run: pytest --pyargs cmdeploy run: pytest --pyargs cmdeploy
- run: cmdeploy init staging2.testrun.org - run: cmdeploy init staging2.testrun.org
- run: cmdeploy run --verbose --skip-dns-check - run: cmdeploy run --verbose
- name: set DNS entries - name: set DNS entries
run: | run: |

View File

@@ -1,50 +0,0 @@
This diagram shows components of the chatmail server; this is a draft
overview as of mid-August 2025:
```mermaid
graph LR;
cmdeploy --- sshd;
letsencrypt --- |80|acmetool-redirector;
acmetool-redirector --- |443|nginx-right(["`nginx
(external)`"]);
nginx-external --- |465|postfix;
nginx-external(["`nginx
(external)`"]) --- |8443|nginx-internal["`nginx
(internal)`"];
nginx-internal --- website["`Website
/var/www/html`"];
nginx-internal --- newemail.py;
nginx-internal --- autoconfig.xml;
certs-nginx[("`TLS certs
/var/lib/acme`")] --> nginx-internal;
cron --- chatmail-metrics;
cron --- acmetool;
cron --- expunge;
chatmail-metrics --- website;
acmetool --> certs[("`TLS certs
/var/lib/acme`")];
nginx-external --- |993|dovecot;
autoconfig.xml --- postfix;
autoconfig.xml --- dovecot;
postfix --- echobot;
postfix --- |10080,10081|filtermail;
postfix --- users["`User data
home/vmail/mail`"];
postfix --- |doveauth.socket|doveauth;
dovecot --- |doveauth.socket|doveauth;
dovecot --- users;
dovecot --- |metadata.socket|chatmail-metadata;
doveauth --- users;
expunge --- users;
chatmail-metadata --- iroh-relay;
certs-nginx --> postfix;
certs-nginx --> dovecot;
style certs fill:#ff6;
style certs-nginx fill:#ff6;
style nginx-external fill:#fc9;
style nginx-right fill:#fc9;
```
The edges in this graph should not be taken too literally; they
reflect some sort of communication path or dependency relationship
between components of the chatmail server.

View File

@@ -2,151 +2,6 @@
## untagged ## untagged
- filtermail: run CPU-intensive handle_DATA in a thread pool executor
([#676](https://github.com/chatmail/relay/pull/676))
- don't use the complicated logging module in filtermail to exclude a potential source of errors.
([#674](https://github.com/chatmail/relay/pull/674))
- Specify nginx.conf to only handle `mail_domain`, www, and mta-sts domains
([#636](https://github.com/chatmail/relay/pull/636))
- Setup TURN server
([#621](https://github.com/chatmail/relay/pull/621))
- cmdeploy: make --ssh-host work with localhost
([#659](https://github.com/chatmail/relay/pull/659))
- Update iroh-relay to 0.35.0
([#650](https://github.com/chatmail/relay/pull/650))
- filtermail: accept mails from Protonmail
([#616](https://github.com/chatmail/relay/pull/655))
- Ignore all RCPT TO: parameters
([#651](https://github.com/chatmail/relay/pull/651))
- Increase opendkim DNS Timeout from 5 to 60 seconds
([#672](https://github.com/chatmail/relay/pull/672))
- Add config parameter for Let's Encrypt ACME email
([#663](https://github.com/chatmail/relay/pull/663))
- Use max username length in newemail.py, not min
([#648](https://github.com/chatmail/relay/pull/648))
- Add startup for `fcgiwrap.service` because sometimes it did not start automatically.
([#657](https://github.com/chatmail/relay/pull/657))
- Add `cmdeploy init --force` command for recreating chatmail.ini
([#656](https://github.com/chatmail/relay/pull/656))
- Increase maxproc for reinjecting ports from 10 to 100
([#646](https://github.com/chatmail/relay/pull/646))
- Allow ports 143 and 993 to be used by `dovecot` process
([#639](https://github.com/chatmail/relay/pull/639))
- Add `--skip-dns-check` argument to `cmdeploy run` command, which disables DNS record checking before installation.
([#661](https://github.com/chatmail/relay/pull/661))
## 1.7.0 2025-09-11
- Make www upload path configurable
([#618](https://github.com/chatmail/relay/pull/618))
- Check whether GCC is installed in initenv.sh
([#608](https://github.com/chatmail/relay/pull/608))
- Expire push notification tokens after 90 days
([#583](https://github.com/chatmail/relay/pull/583))
- Use official `mtail` binary instead of `mtail` package
([#581](https://github.com/chatmail/relay/pull/581))
- dovecot: install from download.delta.chat instead of openSUSE Build Service
([#590](https://github.com/chatmail/relay/pull/590))
- Reconfigure Dovecot imap-login service to high-performance mode
([#578](https://github.com/chatmail/relay/pull/578))
- Set timezone to improve dovecot performance
([#584](https://github.com/chatmail/relay/pull/584))
- Increase nginx connection limits
([#576](https://github.com/chatmail/relay/pull/576))
- If `dns-utils` needs to be installed before cmdeploy run, apt update to make sure it works
([#560](https://github.com/chatmail/relay/pull/560))
- filtermail: respect config message size limit
([#572](https://github.com/chatmail/relay/pull/572))
- Don't deploy if one of the ports used for chatmail relay services is occupied by an unexpected process
([#568](https://github.com/chatmail/relay/pull/568))
- Add config value after how many days large files are deleted
([#555](https://github.com/chatmail/relay/pull/555))
- cmdeploy: push relay version to /etc/chatmail-version
([#573](https://github.com/chatmail/relay/pull/573))
- filtermail: allow partial body length in OpenPGP payloads
([#570](https://github.com/chatmail/relay/pull/570))
- chatmaild: allow echobot to receive unencrypted messages by default
([#556](https://github.com/chatmail/relay/pull/556))
## 1.6.0 2025-04-11
- Handle Port-25 connect errors more gracefully (common with VPNs)
([#552](https://github.com/chatmail/relay/pull/552))
- Avoid "acmetool not found" during initial run
([#550](https://github.com/chatmail/relay/pull/550))
- Fix timezone handling such that client/servers do not need to use
same timezone.
([#553](https://github.com/chatmail/relay/pull/553))
- Enforce end-to-end encryption for incoming messages.
New user address mailboxes now get a `enforceE2EEincoming` file
which prohibits incoming cleartext messages from other domains.
An outside MTA trying to submit a cleartext message will
get a "523 Encryption Needed" response, see RFC5248.
If the file does not exist (as it the case for all existing accounts)
incoming cleartext messages are accepted.
([#538](https://github.com/chatmail/server/pull/538))
- Enforce end-to-end encryption between local addresses
([#535](https://github.com/chatmail/server/pull/535))
- unbound: check that port 53 is not occupied by a different process
([#537](https://github.com/chatmail/server/pull/537))
- unbound: before unbound is there, use 9.9.9.9 for resolving
([#518](https://github.com/chatmail/relay/pull/518))
- Limit the bind for the HTTPS server on 8443 to 127.0.0.1
([#522](https://github.com/chatmail/server/pull/522))
([#532](https://github.com/chatmail/server/pull/532))
- Send SNI when connecting to outside servers
([#524](https://github.com/chatmail/server/pull/524))
- postfix master.cf: use 127.0.0.1 for consistency
([#544](https://github.com/chatmail/relay/pull/544))
- Pass through `original_content` instead of `content` in filtermail
([#509](https://github.com/chatmail/server/pull/509))
- Document TLS requirements in the readme
([#514](https://github.com/chatmail/server/pull/514))
- Remove cleanup service from submission ports
([#512](https://github.com/chatmail/server/pull/512))
- cmdeploy dovecot: delete big messages after 7 days - cmdeploy dovecot: delete big messages after 7 days
([#504](https://github.com/chatmail/server/pull/504)) ([#504](https://github.com/chatmail/server/pull/504))

388
README.md
View File

@@ -1,105 +1,58 @@
<img width="800px" src="www/src/collage-top.png"/> <img width="800px" src="www/src/collage-top.png"/>
# Chatmail relays for end-to-end encrypted e-mail # Chatmail services optimized for Delta Chat apps
Chatmail relay servers are interoperable Mail Transport Agents (MTAs) designed for: This repository helps to setup a ready-to-use chatmail server
- **Convenience:** Low friction instant onboarding
- **Privacy:** No name, phone numbers, email required or collected
- **End-to-End Encryption enforced**: only OpenPGP messages with metadata minimization allowed
- **Instant:** Privacy-preserving Push Notifications for Apple, Google, and Huawei
- **Speed:** Message delivery in half a second, with optional P2P realtime connections
- **Transport Security:** Strict TLS and DKIM enforced
- **Reliability:** No spam or IP reputation checks; rate-limits are suitable for realtime chats
- **Efficiency:** Messages are only stored for transit and removed automatically
This repository contains everything needed to setup a ready-to-use chatmail relay
comprised of a minimal setup of the battle-tested comprised of a minimal setup of the battle-tested
[Postfix SMTP](https://www.postfix.org) and [Dovecot IMAP](https://www.dovecot.org) MTAs/MDAs. [postfix smtp](https://www.postfix.org) and [dovecot imap](https://www.dovecot.org) services.
The automated setup is designed and optimized for providing chatmail addresses The setup is designed and optimized for providing chatmail accounts
for immediate permission-free onboarding through chat apps and bots. for use by [Delta Chat apps](https://delta.chat).
Chatmail addresses are automatically created at first login,
after which the initially specified password is required
for sending and receiving messages through them.
Please see [this list of known apps and client projects](https://chatmail.at/clients.html) Chatmail accounts are automatically created by a first login,
and [this list of known public 3rd party chatmail relay servers](https://chatmail.at/relays). after which the initially specified password is required for using them.
## Deploying your own chatmail server
## Minimal requirements, Prerequisites 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.
You will need the following: We use `chat.example.org` as the chatmail domain in the following steps.
- Control over a domain through a DNS provider of your choice.
- A Debian 12 server with reachable SMTP/SUBMISSIONS/IMAPS/HTTPS ports.
IPv6 is encouraged if available.
Chatmail relay servers only require 1GB RAM, one CPU, and perhaps 10GB storage for a
few thousand active chatmail addresses.
- Key-based SSH authentication to the root user.
You must add a passphrase-protected private key to your local ssh-agent
because you can't type in your passphrase during deployment.
(An ed25519 private key is required due to an [upstream bug in paramiko](https://github.com/paramiko/paramiko/issues/2191))
## Getting started
We use `chat.example.org` as the chatmail domain in the following steps.
Please substitute it with your own domain. Please substitute it with your own domain.
1. Setup the initial DNS records. 1. Install the `cmdeploy` command in a virtualenv
The following is an example in the familiar BIND zone file format with
a TTL of 1 hour (3600 seconds).
Please substitute your domain and IP addresses.
``` ```
chat.example.com. 3600 IN A 198.51.100.5 git clone https://github.com/deltachat/chatmail
chat.example.com. 3600 IN AAAA 2001:db8::5 cd chatmail
www.chat.example.com. 3600 IN CNAME chat.example.com.
mta-sts.chat.example.com. 3600 IN CNAME chat.example.com.
```
2. On your local PC, clone the repository and bootstrap the Python virtualenv.
```
git clone https://github.com/chatmail/relay
cd relay
scripts/initenv.sh scripts/initenv.sh
``` ```
3. On your local PC, create chatmail configuration file `chatmail.ini`: 2. Create chatmail configuration file `chatmail.ini`:
``` ```
scripts/cmdeploy init chat.example.org # <-- use your domain scripts/cmdeploy init chat.example.org # <-- use your domain
``` ```
4. Verify that SSH root login to your remote server works: 3. Point your domain to the server's IP address,
if you haven't done so already.
Verify that SSH root login works:
``` ```
ssh root@chat.example.org # <-- use your domain ssh root@chat.example.org # <-- use your domain
``` ```
5. From your local PC, deploy the remote chatmail relay server: 4. Deploy to the remote chatmail server:
``` ```
scripts/cmdeploy run scripts/cmdeploy run
``` ```
This script will also check that you have all necessary DNS records. This script will check that you have all necessary DNS records.
If DNS records are missing, it will recommend If DNS records are missing, it will recommend
which you should configure at your DNS provider which you should configure at your DNS provider
(it can take some time until they are public). (it can take some time until they are public).
### Other helpful commands ### Other helpful commands:
To check the status of your remotely running chatmail service: To check the status of your remotely running chatmail service:
@@ -129,25 +82,25 @@ scripts/cmdeploy bench
This repository has four directories: This repository has four directories:
- [cmdeploy](https://github.com/chatmail/relay/tree/main/cmdeploy) - [cmdeploy](https://github.com/deltachat/chatmail/tree/main/cmdeploy)
is a collection of configuration files is a collection of configuration files
and a [pyinfra](https://pyinfra.com)-based deployment script. and a [pyinfra](https://pyinfra.com)-based deployment script.
- [chatmaild](https://github.com/chatmail/relay/tree/main/chatmaild) - [chatmaild](https://github.com/deltachat/chatmail/tree/main/chatmaild)
is a Python package containing several small services is a python package containing several small services
which handle authentication, which handle authentication,
trigger push notifications on new messages, trigger push notifications on new messages,
ensure that outbound mails are encrypted, ensure that outbound mails are encrypted,
delete inactive users, delete inactive users,
and some other minor things. and some other minor things.
chatmaild can also be installed as a stand-alone Python package. chatmaild can also be installed as a stand-alone python package.
- [www](https://github.com/chatmail/relay/tree/main/www) - [www](https://github.com/deltachat/chatmail/tree/main/www)
contains the html, css, and markdown files contains the html, css, and markdown files
which make up a chatmail relay's web page. which make up a chatmail server's web page.
Edit them before deploying to make your chatmail relay stand out. Edit them before deploying to make your chatmail server stand out.
- [scripts](https://github.com/chatmail/relay/tree/main/scripts) - [scripts](https://github.com/deltachat/chatmail/tree/main/scripts)
offers two convenience tools for beginners; offers two convenience tools for beginners;
`initenv.sh` installs the necessary dependencies to a local virtual environment, `initenv.sh` installs the necessary dependencies to a local virtual environment,
and the `scripts/cmdeploy` script enables you and the `scripts/cmdeploy` script enables you
@@ -158,82 +111,80 @@ This repository has four directories:
The `cmdeploy/src/cmdeploy/cmdeploy.py` command line tool The `cmdeploy/src/cmdeploy/cmdeploy.py` command line tool
helps with setting up and managing the chatmail service. helps with setting up and managing the chatmail service.
`cmdeploy init` creates the `chatmail.ini` config file. `cmdeploy init` creates the `chatmail.ini` config file.
`cmdeploy run` uses a [pyinfra](https://pyinfra.com/)-based [`script`](cmdeploy/src/cmdeploy/__init__.py) `cmdeploy run` uses a [pyinfra](https://pyinfra.com/)-based [script](`cmdeploy/src/cmdeploy/__init__.py`)
to automatically install or upgrade all chatmail components on a relay, to automatically install or upgrade all chatmail components on a server,
according to the `chatmail.ini` config. according to the `chatmail.ini` config.
The components of chatmail are: The components of chatmail are:
- [Postfix SMTP MTA](https://www.postfix.org) accepts and relays messages - [postfix smtp server](https://www.postfix.org) accepts sent messages (both from your users and from other servers)
(both from your users and from the wider e-mail MTA network)
- [Dovecot IMAP MDA](https://www.dovecot.org) stores messages for your users until they download them - [dovecot imap server](https://www.dovecot.org) stores messages for your users until they download them
- [Nginx](https://nginx.org/) shows the web page with your privacy policy and additional information - [nginx](https://nginx.org/) shows the web page with your privacy policy and additional information
- [acmetool](https://hlandau.github.io/acmetool/) manages TLS certificates for Dovecot, Postfix, and Nginx - [acmetool](https://hlandau.github.io/acmetool/) manages TLS certificates for dovecot, postfix, and nginx
- [OpenDKIM](http://www.opendkim.org/) for signing messages with DKIM and rejecting inbound messages without DKIM - [opendkim](http://www.opendkim.org/) for signing messages with DKIM and rejecting inbound messages without DKIM
- [mtail](https://google.github.io/mtail/) for collecting anonymized metrics in case you have monitoring - [mtail](https://google.github.io/mtail/) for collecting anonymized metrics in case you have monitoring
- [Iroh relay](https://www.iroh.computer/docs/concepts/relay)
which helps client devices to establish Peer-to-Peer connections
- and the chatmaild services, explained in the next section: - and the chatmaild services, explained in the next section:
### chatmaild ### chatmaild
`chatmaild` implements various systemd-controlled services chatmaild offers several commands
that integrate with Dovecot and Postfix to achieve instant-onboarding and which differentiate a *chatmail* server from a classic mail server.
only relaying OpenPGP end-to-end messages encrypted messages. If you deploy them with cmdeploy,
A short overview of `chatmaild` services: they are run by systemd services in the background.
A short overview:
- [`doveauth`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/doveauth.py) - [`doveauth`](https://github.com/deltachat/chatmail/blob/main/chatmaild/src/chatmaild/doveauth.py) implements
implements create-on-login address semantics and is used create-on-login account creation semantics and is used
by Dovecot during IMAP login and by Postfix during SMTP/SUBMISSION login 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) which in turn uses [Dovecot SASL](https://doc.dovecot.org/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket)
to authenticate logins. to authenticate users
to send mails for them.
- [`filtermail`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/filtermail.py) - [`filtermail`](https://github.com/deltachat/chatmail/blob/main/chatmaild/src/chatmaild/filtermail.py) prevents
prevents unencrypted email from leaving or entering the chatmail service unencrypted e-mail from leaving the chatmail service
and is integrated into Postfix's outbound and inbound mail pipelines. and is integrated into postfix's outbound mail pipelines.
- [`chatmail-metadata`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metadata.py) is contacted by a - [`chatmail-metadata`](https://github.com/deltachat/chatmail/blob/main/chatmaild/src/chatmaild/metadata.py) is contacted by a
[Dovecot lua script](https://github.com/chatmail/relay/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua) [dovecot lua script](https://github.com/deltachat/chatmail/blob/main/cmdeploy/src/cmdeploy/dovecot/push_notification.lua)
to store user-specific relay-side config. to store user-specific server-side config.
On new messages, On new messages,
it [passes the user's push notification token](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/notifier.py) it [passes the user's push notification token](https://github.com/deltachat/chatmail/blob/main/chatmaild/src/chatmaild/notifier.py)
to [notifications.delta.chat](https://delta.chat/help#instant-delivery) to [notifications.delta.chat](https://delta.chat/help#instant-delivery)
so the push notifications on the user's phone can be triggered so the push notifications on the user's phone can be triggered
by Apple/Google/Huawei. by Apple/Google.
- [`delete_inactive_users`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/delete_inactive_users.py) - [`delete_inactive_users`](https://github.com/deltachat/chatmail/blob/main/chatmaild/src/chatmaild/delete_inactive_users.py)
deletes users if they have not logged in for a very long time. deletes users if they have not logged in for a very long time.
The timeframe can be configured in `chatmail.ini`. The timeframe can be configured in `chatmail.ini`.
- [`lastlogin`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/lastlogin.py) - [`lastlogin`](https://github.com/deltachat/chatmail/blob/main/chatmaild/src/chatmaild/lastlogin.py)
is contacted by Dovecot when a user logs in is contacted by dovecot when a user logs in
and stores the date of the login. and stores the date of the login.
- [`echobot`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/echo.py) - [`echobot`](https://github.com/deltachat/chatmail/blob/main/chatmaild/src/chatmaild/echo.py)
is a small bot for test purposes. is a small bot for test purposes.
It simply echoes back messages from users. It simply echoes back messages from users.
- [`chatmail-metrics`](https://github.com/chatmail/relay/blob/main/chatmaild/src/chatmaild/metrics.py) - [`chatmail-metrics`](https://github.com/deltachat/chatmail/blob/main/chatmaild/src/chatmaild/metrics.py)
collects some metrics and displays them at `https://example.org/metrics`. collects some metrics and displays them at `https://example.org/metrics`.
### Home page and getting started for users ### Home page and getting started for users
`cmdeploy run` also creates default static web pages and deploys them `cmdeploy run` also creates default static Web pages and deploys them
to a Nginx web server with: to a nginx web server with:
- a default `index.html` along with a QR code that users can click to - a default `index.html` along with a QR code that users can click to
create an address on your chatmail relay create accounts on your chatmail provider,
- a default `info.html` that is linked from the home page - a default `info.html` that is linked from the home page,
- a default `policy.html` that is linked from the home page - a default `policy.html` that is linked from the home page.
All `.html` files are generated All `.html` files are generated
by the according markdown `.md` file in the `www/src` directory. by the according markdown `.md` file in the `www/src` directory.
@@ -241,76 +192,48 @@ by the according markdown `.md` file in the `www/src` directory.
### Refining the web pages ### Refining the web pages
``` ```
scripts/cmdeploy webdev scripts/cmdeploy webdev
``` ```
This starts a local live development cycle for chatmail web pages: This starts a local live development cycle for chatmail Web pages:
- uses the `www/src/page-layout.html` file for producing static - uses the `www/src/page-layout.html` file for producing static
HTML pages from `www/src/*.md` files HTML pages from `www/src/*.md` files
- continously builds the web presence reading files from `www/src` directory - continously builds the web presence reading files from `www/src` directory
and generating HTML files and copying assets to the `www/build` directory. and generating html files and copying assets to the `www/build` directory.
- Starts a browser window automatically where you can "refresh" as needed. - Starts a browser window automatically where you can "refresh" as needed.
#### Custom web pages
You can skip uploading a web page ## Emergency Commands to disable automatic account creation
by setting `www_folder=disabled` in `chatmail.ini`.
If you want to manage your web pages outside this git repository, If you need to stop account creation,
you can set `www_folder` in `chatmail.ini` to a custom directory on your computer. e.g. because some script is wildly creating accounts,
`cmdeploy run` will upload it as the server's home page, login to the server with ssh and run:
and if it contains a `src/index.md` file,
will build it with hugo.
## Mailbox directory layout
Fresh chatmail addresses have a mailbox directory that contains:
- a `password` file with the salted password required for authenticating
whether a login may use the address to send/receive messages.
If you modify the password file manually, you effectively block the user.
- `enforceE2EEincoming` is a default-created file with each address.
If present the file indicates that this chatmail address rejects incoming cleartext messages.
If absent the address accepts incoming cleartext messages.
- `dovecot*`, `cur`, `new` and `tmp` represent IMAP/mailbox state.
If the address is only used by one device, the Maildir directories
will typically be empty unless the user of that address hasn't been online
for a while.
## Emergency Commands to disable automatic address creation
If you need to stop address creation,
e.g. because some script is wildly creating addresses,
login with ssh and run:
``` ```
touch /etc/chatmail-nocreate touch /etc/chatmail-nocreate
``` ```
Chatmail address creation will be denied while this file is present. While this file is present, account creation will be blocked.
### Ports ### Ports
[Postfix](http://www.postfix.org/) listens on ports 25 (SMTP) and 587 (SUBMISSION) and 465 (SUBMISSIONS). [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). [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). [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. 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). [acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (http).
chatmail-core based apps will, however, discover all ports and configurations 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 relay server. automatically by reading the [autoconfig XML file](https://www.ietf.org/archive/id/draft-bucksch-autoconfig-00.html) from the chatmail service.
## Email authentication ## Email authentication
Chatmail relays enforce [DKIM](https://www.rfc-editor.org/rfc/rfc6376) chatmail servers rely on [DKIM](https://www.rfc-editor.org/rfc/rfc6376)
to authenticate incoming emails. to authenticate incoming emails.
Incoming emails must have a valid DKIM signature with Incoming emails must have a valid DKIM signature with
Signing Domain Identifier (SDID, `d=` parameter in the DKIM-Signature header) Signing Domain Identifier (SDID, `d=` parameter in the DKIM-Signature header)
@@ -333,130 +256,101 @@ and rejects incorrectly authenticated emails with [`reject_sender_login_mismatch
`From:` header must correspond to envelope MAIL FROM, `From:` header must correspond to envelope MAIL FROM,
this is ensured by `filtermail` proxy. this is ensured by `filtermail` proxy.
## TLS requirements ## Migrating chatmail server to a new host
Postfix is configured to require valid TLS If you want to migrate chatmail from an old machine
by setting [`smtp_tls_security_level`](https://www.postfix.org/postconf.5.html#smtp_tls_security_level) to `verify`.
If emails don't arrive at your chatmail relay server,
the problem is likely that your relay does not have a valid TLS certificate.
You can test it by resolving `MX` records of your relay domain
and then connecting to MX relays (e.g `mx.example.org`) with
`openssl s_client -connect mx.example.org:25 -verify_hostname mx.example.org -verify_return_error -starttls smtp`
from the host that has open port 25 to verify that certificate is valid.
When providing a TLS certificate to your chatmail relay server,
make sure to provide the full certificate chain
and not just the last certificate.
If you are running an Exim server and don't see incoming connections
from a chatmail relay server in the logs,
make sure `smtp_no_mail` log item is enabled in the config
with `log_selector = +smtp_no_mail`.
By default Exim does not log sessions that are closed
before sending the `MAIL` command.
This happens if certificate is not recognized as valid by Postfix,
so you might think that connection is not established
while actually it is a problem with your TLS certificate.
## Migrating a chatmail relay to a new host
If you want to migrate chatmail relay from an old machine
to a new machine, to a new machine,
you can use these steps. you can use these steps.
They were tested with a Linux laptop; They were tested with a linux laptop;
you might need to adjust some of the steps to your environment. you might need to adjust some of the steps to your environment.
Let's assume that your `mail_domain` is `mail.example.org`, Let's assume that your `mail_domain` is `mail.example.org`,
all involved machines run Debian 12, all involved machines run Debian 12,
your old site's IP address is `13.37.13.37`, your old server's IP address is `13.37.13.37`,
and your new site's IP address is `13.12.23.42`. and your new server's IP address is `13.12.23.42`.
Note, you should lower the TTLs of your DNS records to a value During the guide, you might get a warning about changed SSH Host keys;
such as 300 (5 minutes) so the migration happens as smoothly as possible. in this case, just run `ssh-keygen -R "mail.example.org"` as recommended
to make sure you can connect with SSH.
During the guide you might get a warning about changed SSH Host keys; 1. First, copy `/var/lib/acme` to the new server with
in this case, just run `ssh-keygen -R "mail.example.org"` as recommended. `ssh root@13.37.13.37 tar c /var/lib/acme | ssh root@13.12.23.42 tar x -C /var/lib/`.
This transfers your TLS certificate.
1. First, disable mail services on the old site. 2. You should also copy `/etc/dkimkeys` to the new server with
`ssh root@13.37.13.37 tar c /etc/dkimkeys | ssh root@13.12.23.42 tar x -C /etc/`
so the DKIM DNS record stays correct.
``` 3. On the new server, run `chown root: -R /var/lib/acme` and `chown opendkim: -R /etc/dkimkeys` to make sure the permissions are correct.
cmdeploy run --disable-mail --ssh-host 13.37.13.37
``` 4. Run `cmdeploy run --disable-mail --ssh-host 13.12.23.42` to install chatmail on the new machine.
postfix and dovecot are disabled for now,
we will enable them later.
5. Now, point DNS to the new IP addresses.
You can already remove the old IP addresses from DNS.
Existing Delta Chat users will still be able to connect
to the old server, send and receive messages,
but new users will fail to create new profiles
with your chatmail server.
If other servers try to deliver messages to your new server they will fail,
but normally email servers will retry delivering messages
for at least a week, so messages will not be lost.
6. Now you can run `cmdeploy run --disable-mail --ssh-host 13.37.13.37` to disable your old server.
Now your users will notice the migration Now your users will notice the migration
and will not be able to send or receive messages and will not be able to send or receive messages
until the migration is completed. until the migration is completed.
2. Now we want to copy `/home/vmail`, `/var/lib/acme`, `/etc/dkimkeys`, `/run/echobot`, and `/var/spool/postfix` to the new site. 7. After everything is stopped,
Login to the old site while forwarding your SSH agent you can copy the `/home/vmail/mail` directory to the new server.
so you can copy directly from the old to the new site with your SSH key: It includes all user data, messages, password hashes, etc.
```
ssh -A root@13.37.13.37
tar c - /home/vmail/mail /var/lib/acme /etc/dkimkeys /run/echobot /var/spool/postfix | ssh root@13.12.23.42 "tar x -C /"
```
This transfers all addresses, the TLS certificate, DKIM keys (so DKIM DNS record remains valid), and the echobot's password so it continues to function. Just run: `ssh root@13.37.13.37 tar c /home/vmail/mail | ssh root@13.12.23.42 tar x -C /home/vmail/`
It also preserves the Postfix mail spool so any messages pending delivery will still be delivered.
3. Install chatmail on the new machine: After this, your new server has all the necessary files to start operating :)
``` 8. To be sure the permissions are still fine,
cmdeploy run --disable-mail --ssh-host 13.12.23.42 run `chown vmail: -R /home/vmail` on the new server.
```
Postfix and Dovecot are disabled for now; we will enable them later.
We first need to make the new site fully operational.
3. On the new site, run the following to ensure the ownership is correct in case UIDs/GIDs changed: 9. Finally, you can run `cmdeploy run` to turn on chatmail on the new server.
Your users can continue using the chatmail server,
``` and messages which were sent after step 6. should arrive now.
chown root: -R /var/lib/acme Voilà!
chown opendkim: -R /etc/dkimkeys
chown vmail: -R /home/vmail/mail
chown echobot: -R /run/echobot
```
4. Now, update DNS entries.
If other MTAs try to deliver messages to your chatmail domain they may fail intermittently,
as DNS catches up with the new site settings
but normally will retry delivering messages
for at least a week, so messages will not be lost.
5. Finally, you can execute `cmdeploy run --ssh-host 13.12.23.42` to turn on chatmail on the new relay.
Your users will be able to use the chatmail relay as soon as the DNS changes have propagated.
Voilà!
## Setting up a reverse proxy ## Setting up a reverse proxy
A chatmail relay MTA does not track or depend on the client IP address A chatmail server does not depend on the client IP address
for its operation, so it can be run behind a reverse proxy. for its operation, so it can be run behind a reverse proxy.
This will not even affect incoming mail authentication This will not even affect incoming mail authentication
as DKIM only checks the cryptographic signature as DKIM only checks the cryptographic signature
of the message and does not use the IP address as the input. of the message and does not use the IP address as the input.
For example, you may want to self-host your chatmail relay For example, you may want to self-host your chatmail server
and only use hosted VPS to provide a public IP address and only use hosted VPS to provide a public IP address
for client connections and incoming mail. for client connections and incoming mail.
You can connect chatmail relay to VPS You can connect chatmail server to VPS
using a tunnel protocol using a tunnel protocol
such as [WireGuard](https://www.wireguard.com/) such as [WireGuard](https://www.wireguard.com/)
and setup a reverse proxy on a VPS and setup a reverse proxy on a VPS
to forward connections to the chatmail relay to forward connections to the chatmail server
over the tunnel. over the tunnel.
You can also setup multiple reverse proxies You can also setup multiple reverse proxies
for your chatmail relay in different networks for your chatmail server in different networks
to ensure your relay is reachable even when to ensure your server is reachable even when
one of the IPs becomes inaccessible due to one of the IPs becomes inaccessible due to
hosting or routing problems. hosting or routing problems.
Note that your chatmail relay still needs Note that your server still needs
to be able to make outgoing connections on port 25 to be able to make outgoing connections on port 25
to send messages outside. to send messages outside.
To setup a reverse proxy To setup a reverse proxy
(or rather Destination NAT, DNAT) (or rather Destination NAT, DNAT)
for your chatmail relay, for your chatmail server,
put the following configuration in `/etc/nftables.conf`: put the following configuration in `/etc/nftables.conf`:
``` ```
#!/usr/sbin/nft -f #!/usr/sbin/nft -f
@@ -468,7 +362,7 @@ define wan = eth0
# Which ports to proxy. # Which ports to proxy.
# #
# Note that SSH is not proxied # Note that SSH is not proxied
# so it is possible to log into the proxy server # so it is possible to log into the proxy server
# and not the original one. # and not the original one.
define ports = { smtp, http, https, imap, imaps, submission, submissions } define ports = { smtp, http, https, imap, imaps, submission, submissions }
@@ -531,7 +425,7 @@ table inet filter {
``` ```
Run `systemctl enable nftables.service` Run `systemctl enable nftables.service`
to ensure configuration is reloaded when the proxy relay reboots. to ensure configuration is reloaded when the proxy server reboots.
Uncomment in `/etc/sysctl.conf` the following two lines: Uncomment in `/etc/sysctl.conf` the following two lines:
@@ -540,19 +434,7 @@ net.ipv4.ip_forward=1
net.ipv6.conf.all.forwarding=1 net.ipv6.conf.all.forwarding=1
``` ```
Then reboot the relay or do `sysctl -p` and `nft -f /etc/nftables.conf`. Then reboot the server or do `sysctl -p` and `nft -f /etc/nftables.conf`.
Once proxy relay is set up, Once proxy server is set up,
you can add its IP address to the DNS. you can add its IP address to the DNS.
## Neighbors and Acquaintances
Here are some related projects that you may be interested in:
- [Mox](https://github.com/mjl-/mox): A Golang email server. [Work is in
progress](https://github.com/mjl-/mox/issues/251) to modify it to support all
of the features and configuration settings required to operate as a chatmail
relay.
- [Maddy-Chatmail](https://github.com/sadraiiali/maddy_chatmail): a plugin for the
[Maddy email server](https://maddy.email/) which aims to implement the
chatmail relay features and configuration options.

View File

@@ -29,7 +29,6 @@ echobot = "chatmaild.echo:main"
chatmail-metrics = "chatmaild.metrics:main" chatmail-metrics = "chatmaild.metrics:main"
delete_inactive_users = "chatmaild.delete_inactive_users:main" delete_inactive_users = "chatmaild.delete_inactive_users:main"
lastlogin = "chatmaild.lastlogin:main" lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main"
[project.entry-points.pytest11] [project.entry-points.pytest11]
"chatmaild.testplugin" = "chatmaild.tests.plugin" "chatmaild.testplugin" = "chatmaild.tests.plugin"
@@ -49,9 +48,6 @@ lint.select = [
"PLE", # Pylint Error "PLE", # Pylint Error
"PLW", # Pylint Warning "PLW", # Pylint Warning
] ]
lint.ignore = [
"PLC0415" # import-outside-top-level
]
[tool.tox] [tool.tox]
legacy_tox_ini = """ legacy_tox_ini = """

View File

@@ -11,11 +11,7 @@ def read_config(inipath):
assert Path(inipath).exists(), inipath assert Path(inipath).exists(), inipath
cfg = iniconfig.IniConfig(inipath) cfg = iniconfig.IniConfig(inipath)
params = cfg.sections["params"] params = cfg.sections["params"]
default_config_content = get_default_config_content(params["mail_domain"]) return Config(inipath, params=params)
df_params = iniconfig.IniConfig("ini", data=default_config_content)["params"]
new_params = dict(df_params.items())
new_params.update(params)
return Config(inipath, params=new_params)
class Config: class Config:
@@ -26,25 +22,16 @@ class Config:
self.max_mailbox_size = params["max_mailbox_size"] self.max_mailbox_size = params["max_mailbox_size"]
self.max_message_size = int(params.get("max_message_size", "31457280")) self.max_message_size = int(params.get("max_message_size", "31457280"))
self.delete_mails_after = params["delete_mails_after"] self.delete_mails_after = params["delete_mails_after"]
self.delete_large_after = params["delete_large_after"]
self.delete_inactive_users_after = int(params["delete_inactive_users_after"]) self.delete_inactive_users_after = int(params["delete_inactive_users_after"])
self.username_min_length = int(params["username_min_length"]) self.username_min_length = int(params["username_min_length"])
self.username_max_length = int(params["username_max_length"]) self.username_max_length = int(params["username_max_length"])
self.password_min_length = int(params["password_min_length"]) self.password_min_length = int(params["password_min_length"])
self.passthrough_senders = params["passthrough_senders"].split() self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split() self.passthrough_recipients = params["passthrough_recipients"].split()
self.www_folder = params.get("www_folder", "")
self.filtermail_smtp_port = int(params["filtermail_smtp_port"]) self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.filtermail_smtp_port_incoming = int(
params["filtermail_smtp_port_incoming"]
)
self.postfix_reinject_port = int(params["postfix_reinject_port"]) self.postfix_reinject_port = int(params["postfix_reinject_port"])
self.postfix_reinject_port_incoming = int(
params["postfix_reinject_port_incoming"]
)
self.mtail_address = params.get("mtail_address") self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true" self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.acme_email = params.get("acme_email", "")
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true" self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
if "iroh_relay" not in params: if "iroh_relay" not in params:
self.iroh_relay = "https://" + params["mail_domain"] self.iroh_relay = "https://" + params["mail_domain"]
@@ -67,7 +54,7 @@ class Config:
def _getbytefile(self): def _getbytefile(self):
return open(self._inipath, "rb") return open(self._inipath, "rb")
def get_user(self, addr) -> User: def get_user(self, addr):
if not addr or "@" not in addr or "/" in addr: if not addr or "@" not in addr or "/" in addr:
raise ValueError(f"invalid address {addr!r}") raise ValueError(f"invalid address {addr!r}")
@@ -82,11 +69,6 @@ class Config:
def write_initial_config(inipath, mail_domain, overrides): def write_initial_config(inipath, mail_domain, overrides):
"""Write out default config file, using the specified config value overrides.""" """Write out default config file, using the specified config value overrides."""
content = get_default_config_content(mail_domain, **overrides)
inipath.write_text(content)
def get_default_config_content(mail_domain, **overrides):
from importlib.resources import files from importlib.resources import files
inidir = files(__package__).joinpath("ini") inidir = files(__package__).joinpath("ini")
@@ -118,7 +100,7 @@ def get_default_config_content(mail_domain, **overrides):
lines = [] lines = []
for line in content.split("\n"): for line in content.split("\n"):
for key, value in privacy.items(): for key, value in privacy.items():
value_lines = value.format(mail_domain=mail_domain).strip().split("\n") value_lines = value.strip().split("\n")
if not line.startswith(f"{key} =") or not value_lines: if not line.startswith(f"{key} =") or not value_lines:
continue continue
if len(value_lines) == 1: if len(value_lines) == 1:
@@ -131,4 +113,5 @@ def get_default_config_content(mail_domain, **overrides):
else: else:
lines.append(line) lines.append(line)
content = "\n".join(lines) content = "\n".join(lines)
return content
inipath.write_text(content)

View File

@@ -8,7 +8,6 @@ import logging
import os import os
import subprocess import subprocess
import sys import sys
from pathlib import Path
from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events from deltachat_rpc_client import Bot, DeltaChat, EventType, Rpc, events
@@ -98,10 +97,6 @@ def main():
if not bot.is_configured(): if not bot.is_configured():
bot.configure(addr, password) bot.configure(addr, password)
# write invite link to working directory
invitelink = bot.account.get_qr_code()
Path("invite-link.txt").write_text(invitelink)
bot.run_forever() bot.run_forever()

View File

@@ -2,6 +2,7 @@
import asyncio import asyncio
import base64 import base64
import binascii import binascii
import logging
import sys import sys
import time import time
from email import policy from email import policy
@@ -10,12 +11,9 @@ from email.utils import parseaddr
from smtplib import SMTP as SMTPClient from smtplib import SMTP as SMTPClient
from aiosmtpd.controller import Controller from aiosmtpd.controller import Controller
from aiosmtpd.smtp import SMTP
from .config import read_config from .config import read_config
ENCRYPTION_NEEDED_523 = "523 Encryption Needed: Invalid Unencrypted Mail"
def check_openpgp_payload(payload: bytes): def check_openpgp_payload(payload: bytes):
"""Checks the OpenPGP payload. """Checks the OpenPGP payload.
@@ -37,12 +35,6 @@ def check_openpgp_payload(payload: bytes):
packet_type_id = payload[i] & 0x3F packet_type_id = payload[i] & 0x3F
i += 1 i += 1
while payload[i] >= 224 and payload[i] < 255:
# Partial body length.
partial_length = 1 << (payload[i] & 0x1F)
i += 1 + partial_length
if payload[i] < 192: if payload[i] < 192:
# One-octet length. # One-octet length.
body_len = payload[i] body_len = payload[i]
@@ -61,7 +53,7 @@ def check_openpgp_payload(payload: bytes):
) )
i += 5 i += 5
else: else:
# Impossible, partial body length was processed above. # Partial body length is not allowed.
return False return False
i += body_len i += body_len
@@ -82,14 +74,8 @@ def check_openpgp_payload(payload: bytes):
return False return False
def check_armored_payload(payload: str, outgoing: bool): def check_armored_payload(payload: str):
"""Check the armored PGP message for invalid content. prefix = "-----BEGIN PGP MESSAGE-----\r\n\r\n"
:param payload: the armored PGP message
:param outgoing: whether the message is outgoing or incoming
:return: whether the message is a valid PGP message
"""
prefix = "-----BEGIN PGP MESSAGE-----\r\n"
if not payload.startswith(prefix): if not payload.startswith(prefix):
return False return False
payload = payload.removeprefix(prefix) payload = payload.removeprefix(prefix)
@@ -101,17 +87,6 @@ def check_armored_payload(payload: str, outgoing: bool):
return False return False
payload = payload.removesuffix(suffix) payload = payload.removesuffix(suffix)
# Disallow comments in outgoing messages
version_comment = "Version: "
if payload.startswith(version_comment):
splitindex = payload.find("\r\n") + 2
payload = payload[splitindex:]
if outgoing:
return False
while payload.startswith("\r\n"):
payload = payload.removeprefix("\r\n")
# Remove CRC24. # Remove CRC24.
payload = payload.rpartition("=")[0] payload = payload.rpartition("=")[0]
@@ -147,7 +122,7 @@ def is_securejoin(message):
return True return True
def check_encrypted(message, outgoing=True): def check_encrypted(message):
"""Check that the message is an OpenPGP-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>. MIME structure of the message must correspond to <https://www.rfc-editor.org/rfc/rfc3156>.
@@ -174,7 +149,7 @@ def check_encrypted(message, outgoing=True):
if part.get_content_type() != "application/octet-stream": if part.get_content_type() != "application/octet-stream":
return False return False
if not check_armored_payload(part.get_payload(), outgoing=outgoing): if not check_armored_payload(part.get_payload()):
return False return False
else: else:
return False return False
@@ -182,19 +157,9 @@ def check_encrypted(message, outgoing=True):
return True return True
async def asyncmain_beforequeue(config, mode): async def asyncmain_beforequeue(config):
if mode == "outgoing": port = config.filtermail_smtp_port
port = config.filtermail_smtp_port Controller(BeforeQueueHandler(config), hostname="127.0.0.1", port=port).start()
handler = OutgoingBeforeQueueHandler(config)
else:
port = config.filtermail_smtp_port_incoming
handler = IncomingBeforeQueueHandler(config)
HackedController(
handler,
hostname="127.0.0.1",
port=port,
data_size_limit=config.max_message_size,
).start()
def recipient_matches_passthrough(recipient, passthrough_recipients): def recipient_matches_passthrough(recipient, passthrough_recipients):
@@ -206,29 +171,13 @@ def recipient_matches_passthrough(recipient, passthrough_recipients):
return False return False
class HackedController(Controller): class BeforeQueueHandler:
def factory(self):
return SMTPDiscardRCPTO_options(self.handler, **self.SMTP_kwargs)
class SMTPDiscardRCPTO_options(SMTP):
def _getparams(self, params):
# Ignore RCPT TO parameters.
#
# Otherwise parameters such as `ORCPT=...`
# or `NOTIFY=DELAY,FAILURE` (generated by Stalwart)
# make aiosmtpd reject the message here:
# <https://github.com/aio-libs/aiosmtpd/blob/98f578389ae86e5345cc343fa4e5a17b21d9c96d/aiosmtpd/smtp.py#L1379-L1384>
return {}
class OutgoingBeforeQueueHandler:
def __init__(self, config): def __init__(self, config):
self.config = config self.config = config
self.send_rate_limiter = SendRateLimiter() self.send_rate_limiter = SendRateLimiter()
async def handle_MAIL(self, server, session, envelope, address, mail_options): async def handle_MAIL(self, server, session, envelope, address, mail_options):
log_info(f"handle_MAIL from {address}") logging.info(f"handle_MAIL from {address}")
envelope.mail_from = address envelope.mail_from = address
max_sent = self.config.max_user_send_per_minute max_sent = self.config.max_user_send_per_minute
if not self.send_rate_limiter.is_sending_allowed(address, max_sent): if not self.send_rate_limiter.is_sending_allowed(address, max_sent):
@@ -241,113 +190,57 @@ class OutgoingBeforeQueueHandler:
return "250 OK" return "250 OK"
async def handle_DATA(self, server, session, envelope): async def handle_DATA(self, server, session, envelope):
loop = asyncio.get_running_loop() logging.info("handle_DATA before-queue")
return await loop.run_in_executor(None, self.sync_handle_DATA, envelope)
def sync_handle_DATA(self, envelope):
log_info("handle_DATA before-queue")
error = self.check_DATA(envelope) error = self.check_DATA(envelope)
if error: if error:
return error return error
log_info("re-injecting the mail that passed checks") logging.info("re-injecting the mail that passed checks")
client = SMTPClient("localhost", self.config.postfix_reinject_port) client = SMTPClient("localhost", self.config.postfix_reinject_port)
client.sendmail( client.sendmail(envelope.mail_from, envelope.rcpt_tos, envelope.content)
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
)
return "250 OK" return "250 OK"
def check_DATA(self, envelope): def check_DATA(self, envelope):
"""the central filtering function for e-mails.""" """the central filtering function for e-mails."""
log_info(f"Processing DATA message from {envelope.mail_from}") logging.info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content) message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message, outgoing=True) mail_encrypted = check_encrypted(message)
_, from_addr = parseaddr(message.get("from").strip()) _, from_addr = parseaddr(message.get("from").strip())
envelope_from_domain = from_addr.split("@").pop()
logging.info(f"mime-from: {from_addr} envelope-from: {envelope.mail_from!r}")
if envelope.mail_from.lower() != from_addr.lower(): if envelope.mail_from.lower() != from_addr.lower():
return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>" return f"500 Invalid FROM <{from_addr!r}> for <{envelope.mail_from!r}>"
if mail_encrypted or is_securejoin(message): if mail_encrypted:
print("Outgoing: Filtering encrypted mail.", file=sys.stderr) print("Filtering encrypted mail.", file=sys.stderr)
return else:
print("Filtering unencrypted mail.", file=sys.stderr)
print("Outgoing: Filtering unencrypted mail.", file=sys.stderr)
if envelope.mail_from in self.config.passthrough_senders: if envelope.mail_from in self.config.passthrough_senders:
return return
# allow self-sent Autocrypt Setup Message
if envelope.rcpt_tos == [from_addr]:
if message.get("subject") == "Autocrypt Setup Message":
if message.get_content_type() == "multipart/mixed":
return
passthrough_recipients = self.config.passthrough_recipients passthrough_recipients = self.config.passthrough_recipients
for recipient in envelope.rcpt_tos:
if recipient_matches_passthrough(recipient, passthrough_recipients):
continue
print("Rejected unencrypted mail.", file=sys.stderr)
return ENCRYPTION_NEEDED_523
class IncomingBeforeQueueHandler:
def __init__(self, config):
self.config = config
async def handle_DATA(self, server, session, envelope):
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, self.sync_handle_DATA, envelope)
def sync_handle_DATA(self, envelope):
log_info("handle_DATA before-queue")
error = self.check_DATA(envelope)
if error:
return error
log_info("re-injecting the mail that passed checks")
# the smtp daemon on reinject_port_incoming gives it to dkim milter
# which looks at source address to determine whether to verify or sign
client = SMTPClient(
"localhost",
self.config.postfix_reinject_port_incoming,
source_address=("127.0.0.2", 0),
)
client.sendmail(
envelope.mail_from, envelope.rcpt_tos, envelope.original_content
)
return "250 OK"
def check_DATA(self, envelope):
"""the central filtering function for e-mails."""
log_info(f"Processing DATA message from {envelope.mail_from}")
message = BytesParser(policy=policy.default).parsebytes(envelope.content)
mail_encrypted = check_encrypted(message, outgoing=False)
if mail_encrypted or is_securejoin(message): if mail_encrypted or is_securejoin(message):
print("Incoming: Filtering encrypted mail.", file=sys.stderr)
return return
print("Incoming: Filtering unencrypted mail.", file=sys.stderr)
# we want cleartext mailer-daemon messages to pass through
# chatmail core will typically not display them as normal messages
if message.get("auto-submitted"):
_, from_addr = parseaddr(message.get("from").strip())
if from_addr.lower().startswith("mailer-daemon@"):
if message.get_content_type() == "multipart/report":
return
for recipient in envelope.rcpt_tos: for recipient in envelope.rcpt_tos:
user = self.config.get_user(recipient) if envelope.mail_from == recipient:
if user is None or user.is_incoming_cleartext_ok(): # Always allow sending emails to self.
continue continue
if recipient_matches_passthrough(recipient, passthrough_recipients):
continue
res = recipient.split("@")
if len(res) != 2:
return f"500 Invalid address <{recipient}>"
_recipient_addr, recipient_domain = res
print("Rejected unencrypted mail.", file=sys.stderr) is_outgoing = recipient_domain != envelope_from_domain
return ENCRYPTION_NEEDED_523 if is_outgoing:
print("Rejected unencrypted mail.", file=sys.stderr)
return f"500 Invalid unencrypted mail to <{recipient}>"
class SendRateLimiter: class SendRateLimiter:
@@ -364,19 +257,13 @@ class SendRateLimiter:
return False return False
def log_info(msg):
print(msg, file=sys.stderr)
def main(): def main():
args = sys.argv[1:] args = sys.argv[1:]
assert len(args) == 2 assert len(args) == 1
config = read_config(args[0]) config = read_config(args[0])
mode = args[1] logging.basicConfig(level=logging.WARN)
loop = asyncio.new_event_loop() loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop) asyncio.set_event_loop(loop)
assert mode in ["incoming", "outgoing"] task = asyncmain_beforequeue(config)
task = asyncmain_beforequeue(config, mode)
loop.create_task(task) loop.create_task(task)
log_info("entering serving loop")
loop.run_forever() loop.run_forever()

View File

@@ -23,9 +23,6 @@ max_message_size = 31457280
# days after which mails are unconditionally deleted # days after which mails are unconditionally deleted
delete_mails_after = 20 delete_mails_after = 20
# days after which large messages (>200k) are unconditionally deleted
delete_large_after = 7
# days after which users without a successful login are deleted (database and mails) # days after which users without a successful login are deleted (database and mails)
delete_inactive_users_after = 90 delete_inactive_users_after = 90
@@ -43,29 +40,21 @@ passthrough_senders =
# list of e-mail recipients for which to accept outbound un-encrypted mails # list of e-mail recipients for which to accept outbound un-encrypted mails
# (space-separated, item may start with "@" to whitelist whole recipient domains) # (space-separated, item may start with "@" to whitelist whole recipient domains)
passthrough_recipients = xstore@testrun.org echo@{mail_domain} passthrough_recipients = xstore@testrun.org
# path to www directory - documented here: https://github.com/chatmail/relay/#custom-web-pages
#www_folder = www
# #
# Deployment Details # Deployment Details
# #
# SMTP outgoing filtermail and reinjection # where the filtermail SMTP service listens
filtermail_smtp_port = 10080 filtermail_smtp_port = 10080
postfix_reinject_port = 10025
# SMTP incoming filtermail and reinjection # postfix accepts on the localhost reinject SMTP port
filtermail_smtp_port_incoming = 10081 postfix_reinject_port = 10025
postfix_reinject_port_incoming = 10026
# if set to "True" IPv6 is disabled # if set to "True" IPv6 is disabled
disable_ipv6 = False disable_ipv6 = False
# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates
acme_email =
# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail # Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
# service. # service.
# If you set it to anything else, the service will be disabled # If you set it to anything else, the service will be disabled

View File

@@ -1,7 +1,7 @@
[privacy] [privacy]
passthrough_recipients = privacy@testrun.org xstore@testrun.org echo@{mail_domain} passthrough_recipients = privacy@testrun.org xstore@testrun.org
privacy_postal = privacy_postal =
Merlinux GmbH, Represented by the managing director H. Krekel, Merlinux GmbH, Represented by the managing director H. Krekel,

View File

@@ -1,24 +1,14 @@
import logging import logging
import sys import sys
import time
from contextlib import contextmanager
from .config import read_config from .config import read_config
from .dictproxy import DictProxy from .dictproxy import DictProxy
from .filedict import FileDict from .filedict import FileDict
from .notifier import Notifier from .notifier import Notifier
from .turnserver import turn_credentials
def _is_valid_token_timestamp(timestamp, now):
# Token if invalid after 90 days
# or if the timestamp is in the future.
return timestamp > now - 3600 * 24 * 90 and timestamp < now + 60
class Metadata: class Metadata:
# each SETMETADATA on this key appends to dictionary # each SETMETADATA on this key appends to a list of unique device tokens
# mapping of unique device tokens
# which only ever get removed if the upstream indicates the token is invalid # which only ever get removed if the upstream indicates the token is invalid
DEVICETOKEN_KEY = "devicetoken" DEVICETOKEN_KEY = "devicetoken"
@@ -28,60 +18,29 @@ class Metadata:
def get_metadata_dict(self, addr): def get_metadata_dict(self, addr):
return FileDict(self.vmail_dir / addr / "metadata.json") return FileDict(self.vmail_dir / addr / "metadata.json")
@contextmanager
def _modify_tokens(self, addr):
with self.get_metadata_dict(addr).modify() as data:
tokens = data.setdefault(self.DEVICETOKEN_KEY, {})
now = int(time.time())
if isinstance(tokens, list):
data[self.DEVICETOKEN_KEY] = tokens = {t: now for t in tokens}
expired_tokens = [
token
for token, timestamp in tokens.items()
if not _is_valid_token_timestamp(tokens[token], now)
]
for expired_token in expired_tokens:
del tokens[expired_token]
yield tokens
def add_token_to_addr(self, addr, token): def add_token_to_addr(self, addr, token):
with self._modify_tokens(addr) as tokens: with self.get_metadata_dict(addr).modify() as data:
tokens[token] = int(time.time()) tokens = data.setdefault(self.DEVICETOKEN_KEY, [])
if token not in tokens:
tokens.append(token)
def remove_token_from_addr(self, addr, token): def remove_token_from_addr(self, addr, token):
with self._modify_tokens(addr) as tokens: with self.get_metadata_dict(addr).modify() as data:
tokens = data.get(self.DEVICETOKEN_KEY, [])
if token in tokens: if token in tokens:
del tokens[token] tokens.remove(token)
def get_tokens_for_addr(self, addr): def get_tokens_for_addr(self, addr):
mdict = self.get_metadata_dict(addr).read() mdict = self.get_metadata_dict(addr).read()
tokens = mdict.get(self.DEVICETOKEN_KEY, {}) return mdict.get(self.DEVICETOKEN_KEY, [])
now = int(time.time())
if isinstance(tokens, dict):
token_list = [
token
for token, timestamp in tokens.items()
if _is_valid_token_timestamp(timestamp, now)
]
if len(token_list) < len(tokens):
# Some tokens have expired, remove them.
with self._modify_tokens(addr) as _tokens:
pass
else:
token_list = []
return token_list
class MetadataDictProxy(DictProxy): class MetadataDictProxy(DictProxy):
def __init__(self, notifier, metadata, iroh_relay=None, turn_hostname=None): def __init__(self, notifier, metadata, iroh_relay=None):
super().__init__() super().__init__()
self.notifier = notifier self.notifier = notifier
self.metadata = metadata self.metadata = metadata
self.iroh_relay = iroh_relay self.iroh_relay = iroh_relay
self.turn_hostname = turn_hostname
def handle_lookup(self, parts): def handle_lookup(self, parts):
# Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org # Lpriv/43f5f508a7ea0366dff30200c15250e3/devicetoken\tlkj123poi@c2.testrun.org
@@ -100,11 +59,6 @@ class MetadataDictProxy(DictProxy):
): ):
# Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay` # Handle `GETMETADATA "" /shared/vendor/deltachat/irohrelay`
return f"O{self.iroh_relay}\n" return f"O{self.iroh_relay}\n"
elif keyname == "vendor/vendor.dovecot/pvt/server/vendor/deltachat/turn":
res = turn_credentials()
port = 3478
return f"O{self.turn_hostname}:{port}:{res}\n"
logging.warning(f"lookup ignored: {parts!r}") logging.warning(f"lookup ignored: {parts!r}")
return "N\n" return "N\n"
@@ -128,7 +82,6 @@ def main():
config = read_config(config_path) config = read_config(config_path)
iroh_relay = config.iroh_relay iroh_relay = config.iroh_relay
mail_domain = config.mail_domain
vmail_dir = config.mailboxes_dir vmail_dir = config.mailboxes_dir
if not vmail_dir.exists(): if not vmail_dir.exists():
@@ -142,10 +95,7 @@ def main():
notifier.start_notification_threads(metadata.remove_token_from_addr) notifier.start_notification_threads(metadata.remove_token_from_addr)
dictproxy = MetadataDictProxy( dictproxy = MetadataDictProxy(
notifier=notifier, notifier=notifier, metadata=metadata, iroh_relay=iroh_relay
metadata=metadata,
iroh_relay=iroh_relay,
turn_hostname=mail_domain,
) )
dictproxy.serve_forever_from_socket(socket) dictproxy.serve_forever_from_socket(socket)

View File

@@ -11,8 +11,6 @@ def main(vmail_dir=None):
ci_accounts = 0 ci_accounts = 0
for path in Path(vmail_dir).iterdir(): for path in Path(vmail_dir).iterdir():
if not path.joinpath("cur").is_dir():
continue
accounts += 1 accounts += 1
if path.name[:3] in ("ci-", "ac_"): if path.name[:3] in ("ci-", "ac_"):
ci_accounts += 1 ci_accounts += 1

View File

@@ -15,7 +15,7 @@ ALPHANUMERIC_PUNCT = string.ascii_letters + string.digits + string.punctuation
def create_newemail_dict(config: Config): def create_newemail_dict(config: Config):
user = "".join(random.choices(ALPHANUMERIC, k=config.username_max_length)) user = "".join(random.choices(ALPHANUMERIC, k=config.username_min_length))
password = "".join( password = "".join(
secrets.choice(ALPHANUMERIC_PUNCT) secrets.choice(ALPHANUMERIC_PUNCT)
for _ in range(config.password_min_length + 3) for _ in range(config.password_min_length + 3)

View File

@@ -17,11 +17,11 @@ and which are scheduled for retry using exponential back-off timing.
If a token notification would be scheduled more than DROP_DEADLINE seconds If a token notification would be scheduled more than DROP_DEADLINE seconds
after its first attempt, it is dropped with a log error. after its first attempt, it is dropped with a log error.
Note that tokens are opaque to the notification machinery here Note that tokens are completely opaque to the notification machinery here
and are encrypted foreclosing all ability to distinguish and will in the future be encrypted foreclosing all ability to distinguish
which device token ultimately goes to which phone-provider notification service, which device token ultimately goes to which phone-provider notification service,
or to understand the relation of "device tokens" and chatmail addresses. or to understand the relation of "device tokens" and chatmail addresses.
The meaning and format of tokens is basically a matter of chatmail Core and The meaning and format of tokens is basically a matter of Delta-Chat Core and
the `notification.delta.chat` service. the `notification.delta.chat` service.
""" """
@@ -95,12 +95,7 @@ class Notifier:
logging.warning(f"removing spurious queue item: {queue_path!r}") logging.warning(f"removing spurious queue item: {queue_path!r}")
queue_path.unlink() queue_path.unlink()
continue continue
try: queue_item = PersistentQueueItem.read_from_path(queue_path)
queue_item = PersistentQueueItem.read_from_path(queue_path)
except ValueError:
logging.warning(f"removing spurious queue item: {queue_path!r}")
queue_path.unlink()
continue
self.queue_for_retry(queue_item) self.queue_for_retry(queue_item)
def queue_for_retry(self, queue_item, retry_num=0): def queue_for_retry(self, queue_item, retry_num=0):

View File

@@ -1,56 +0,0 @@
From: {from_addr}
To: {to_addr}
Autocrypt-Setup-Message: v1
Subject: Autocrypt Setup Message
Date: Tue, 22 Jan 2019 12:56:29 +0100
Content-type: multipart/mixed; boundary="Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ"
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ
Content-Type: text/plain
This message contains all information to transfer your Autocrypt
settings along with your secret key securely from your original
device.
To set up your new device for Autocrypt, please follow the
instuctions that should be presented by your new device.
You can keep this message and use it as a backup for your secret
key. If you want to do this, you should write down the Setup Code
and store it securely.
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ
Content-Type: application/autocrypt-setup
Content-Disposition: attachment; filename="autocrypt-setup-message.html"
<html><body>
<p>
This is the Autocrypt setup file used to transfer settings and
keys between clients. You can decrypt it using the Setup Code
presented on your old device, and then import the contained key
into your keyring.
</p>
<pre>
-----BEGIN PGP MESSAGE-----
Passphrase-Format: numeric9x4
Passphrase-Begin: 17
jA0EBwMCFAxADoCdzeX/0ukBlqI5+pfpKb751qd/7nLNbkpy3gVcaf1QwRPZYt40
Ynp08UqRQ2g48ZlnzHLSwlTGOPTuv2Jt8ka+pgZ45xzvJSG2gau03xP4VsC271kR
VmCjdb0Y6Rk96mAwfGzrkbaRQ9Z7fIoL866GOv6h9neiVIkp+JYlTV6ISD0ZQJ4Q
I6dOQkB/TWZyVjtiJDOQHdfNWliA6NtqaLq19wlu9L5xXjuNpY95KwR8EJXWe0+o
Y3d2U/KxOAkXKghP2Qg1GtlPVeGC5T4p03TGI6pzKT+kHX6Rrm9wK6sM9aTquMmF
Vok84Jg1DFnwivWC2RILR81rXi7k/+Y6MUbveFgJ9cQduqpxnmD7TjOblYu7M6zp
YGAUxh8DRKlIMn2QsA++DBYQ6ACZvwuY8qTDLkqPDo4WqM313dsMJbyGjDdVE7EM
PESS+RlABETpZXz8g/ycr6DIUNdlbPcmYlsBfHWDOuR2GFFTwmlv5slWS39dJv38
E0eIe1CwdxI801Se7t7dUUS/ZF8wb6GlmxOcqGbF8eko1Z0S64IAm7/h13MRQCxI
geQnHfGYVJ2FOimoCMEKwfa9x++RFTDW0u7spDC2uWvK/1viV8OfRppFhLr/kmKb
18lWXuAz80DAjUDUsVqEq2MvJBJGoCJUEyjuRsLkHYRM5jYk4v50LyyR0Om73nWF
nZBqmqNzdr7Xb9PHHdFhnEc0VvoYbrcM0RVYcEMW3YbmejM891j1d6Iv+/n/qND/
NdebGrfWJMmFLf/iEkzTZ3/v5inW9LpWoRc94ioCjJTaEo8Rib6ARRFaJVIsmNXi
YicFGO98D+zX+a2t9Yz6IpPajVslnOp6ScpmXgts/2XWD7oE+JgxSAqo/dLVsHgP
Ufo=
=pulM
-----END PGP MESSAGE-----
</pre></body></html>
--Y6fyGi9SoGeH8WwRaEdC6bbBcYOedDzrQ--

View File

@@ -1,46 +0,0 @@
Date: Fri, 8 Jul 1994 09:21:47 -0400
From: Mail Delivery Subsystem <MAILER-DAEMON@example.org>
Subject: Returned mail: User unknown
To: <owner-ups-mib@CS.UTK.EDU>
Auto-Submitted: auto-replied
MIME-Version: 1.0
Content-Type: multipart/report; report-type=delivery-status;
boundary="JAA13167.773673707/CS.UTK.EDU"
--JAA13167.773673707/CS.UTK.EDU
content-type: text/plain; charset=us-ascii
----- The following addresses had delivery problems -----
<arathib@vnet.ibm.com> (unrecoverable error)
<wsnell@sdcc13.ucsd.edu> (unrecoverable error)
--JAA13167.773673707/CS.UTK.EDU
content-type: message/delivery-status
Reporting-MTA: dns; cs.utk.edu
Original-Recipient: rfc822;arathib@vnet.ibm.com
Final-Recipient: rfc822;arathib@vnet.ibm.com
Action: failed
Status: 5.0.0 (permanent failure)
Diagnostic-Code: smtp;
550 'arathib@vnet.IBM.COM' is not a registered gateway user
Remote-MTA: dns; vnet.ibm.com
Original-Recipient: rfc822;johnh@hpnjld.njd.hp.com
Final-Recipient: rfc822;johnh@hpnjld.njd.hp.com
Action: delayed
Status: 4.0.0 (hpnjld.njd.jp.com: host name lookup failure)
Original-Recipient: rfc822;wsnell@sdcc13.ucsd.edu
Final-Recipient: rfc822;wsnell@sdcc13.ucsd.edu
Action: failed
Status: 5.0.0
Diagnostic-Code: smtp; 550 user unknown
Remote-MTA: dns; sdcc13.ucsd.edu
--JAA13167.773673707/CS.UTK.EDU
content-type: message/rfc822
[original message goes here]
--JAA13167.773673707/CS.UTK.EDU--

View File

@@ -72,8 +72,9 @@ def maildata(request):
def maildata(name, from_addr, to_addr, subject="[...]"): def maildata(name, from_addr, to_addr, subject="[...]"):
# Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines. # Using `.read_bytes().decode()` instead of `.read_text()` to preserve newlines.
data = datadir.joinpath(name).read_bytes().decode() data = datadir.joinpath(name).read_bytes().decode()
text = data.format(from_addr=from_addr, to_addr=to_addr, subject=subject) text = data.format(from_addr=from_addr, to_addr=to_addr, subject=subject)
return BytesParser(policy=policy.SMTP).parsebytes(text.encode()) return BytesParser(policy=policy.default).parsebytes(text.encode())
return maildata return maildata

View File

@@ -15,14 +15,6 @@ def test_read_config_basic(example_config):
assert example_config.mail_domain == "chat.example.org" assert example_config.mail_domain == "chat.example.org"
def test_read_config_basic_using_defaults(tmp_path, maildomain):
inipath = tmp_path.joinpath("chatmail.ini")
inipath.write_text(f"[params]\nmail_domain = {maildomain}")
example_config = read_config(inipath)
assert example_config.max_user_send_per_minute == 60
assert example_config.filtermail_smtp_port_incoming == 10081
def test_read_config_testrun(make_config): def test_read_config_testrun(make_config):
config = make_config("something.testrun.org") config = make_config("something.testrun.org")
assert config.mail_domain == "something.testrun.org" assert config.mail_domain == "something.testrun.org"
@@ -35,7 +27,6 @@ def test_read_config_testrun(make_config):
assert config.max_user_send_per_minute == 60 assert config.max_user_send_per_minute == 60
assert config.max_mailbox_size == "100M" assert config.max_mailbox_size == "100M"
assert config.delete_mails_after == "20" assert config.delete_mails_after == "20"
assert config.delete_large_after == "7"
assert config.username_min_length == 9 assert config.username_min_length == 9
assert config.username_max_length == 9 assert config.username_max_length == 9
assert config.password_min_length == 9 assert config.password_min_length == 9

View File

@@ -1,8 +1,7 @@
import pytest import pytest
from chatmaild.filtermail import ( from chatmaild.filtermail import (
IncomingBeforeQueueHandler, BeforeQueueHandler,
OutgoingBeforeQueueHandler,
SendRateLimiter, SendRateLimiter,
check_armored_payload, check_armored_payload,
check_encrypted, check_encrypted,
@@ -19,13 +18,7 @@ def maildomain():
@pytest.fixture @pytest.fixture
def handler(make_config, maildomain): def handler(make_config, maildomain):
config = make_config(maildomain) config = make_config(maildomain)
return OutgoingBeforeQueueHandler(config) return BeforeQueueHandler(config)
@pytest.fixture
def inhandler(make_config, maildomain):
config = make_config(maildomain)
return IncomingBeforeQueueHandler(config)
def test_reject_forged_from(maildata, gencreds, handler): def test_reject_forged_from(maildata, gencreds, handler):
@@ -36,14 +29,14 @@ def test_reject_forged_from(maildata, gencreds, handler):
# test that the filter lets good mail through # test that the filter lets good mail through
to_addr = gencreds()[0] to_addr = gencreds()[0]
env.content = maildata( env.content = maildata(
"encrypted.eml", from_addr=env.mail_from, to_addr=to_addr "plain.eml", from_addr=env.mail_from, to_addr=to_addr
).as_bytes() ).as_bytes()
assert not handler.check_DATA(envelope=env) assert not handler.check_DATA(envelope=env)
# test that the filter rejects forged mail # test that the filter rejects forged mail
env.content = maildata( env.content = maildata(
"encrypted.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr "plain.eml", from_addr="forged@c3.testrun.org", to_addr=to_addr
).as_bytes() ).as_bytes()
error = handler.check_DATA(envelope=env) error = handler.check_DATA(envelope=env)
assert "500" in error assert "500" in error
@@ -113,7 +106,7 @@ def test_send_rate_limiter():
break break
def test_cleartext_excempt_privacy(maildata, gencreds, handler): def test_excempt_privacy(maildata, gencreds, handler):
from_addr = gencreds()[0] from_addr = gencreds()[0]
to_addr = "privacy@testrun.org" to_addr = "privacy@testrun.org"
handler.config.passthrough_recipients = [to_addr] handler.config.passthrough_recipients = [to_addr]
@@ -134,73 +127,10 @@ def test_cleartext_excempt_privacy(maildata, gencreds, handler):
rcpt_tos = [to_addr, false_to] rcpt_tos = [to_addr, false_to]
content = msg.as_bytes() content = msg.as_bytes()
assert "523" in handler.check_DATA(envelope=env2) assert "500" in handler.check_DATA(envelope=env2)
def test_cleartext_self_send_autocrypt_setup_message(maildata, gencreds, handler): def test_passthrough_domains(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = from_addr
msg = maildata("asm.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert not handler.check_DATA(envelope=env)
def test_cleartext_send_fails(maildata, gencreds, handler):
from_addr = gencreds()[0]
to_addr = gencreds()[0]
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
res = handler.check_DATA(envelope=env)
assert "523 Encryption Needed" in res
def test_cleartext_incoming_fails(maildata, gencreds, inhandler):
from_addr = gencreds()[0]
to_addr, password = gencreds()
msg = maildata("plain.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
user = inhandler.config.get_user(to_addr)
user.set_password(password)
res = inhandler.check_DATA(envelope=env)
assert "523 Encryption Needed" in res
user.allow_incoming_cleartext()
assert not inhandler.check_DATA(envelope=env)
def test_cleartext_incoming_mailer_daemon(maildata, gencreds, inhandler):
from_addr = "mailer-daemon@example.org"
to_addr = gencreds()[0]
msg = maildata("mailer-daemon.eml", from_addr=from_addr, to_addr=to_addr)
class env:
mail_from = from_addr
rcpt_tos = [to_addr]
content = msg.as_bytes()
assert not inhandler.check_DATA(envelope=env)
def test_cleartext_passthrough_domains(maildata, gencreds, handler):
from_addr = gencreds()[0] from_addr = gencreds()[0]
to_addr = "privacy@x.y.z" to_addr = "privacy@x.y.z"
handler.config.passthrough_recipients = ["@x.y.z"] handler.config.passthrough_recipients = ["@x.y.z"]
@@ -221,10 +151,10 @@ def test_cleartext_passthrough_domains(maildata, gencreds, handler):
rcpt_tos = [to_addr, false_to] rcpt_tos = [to_addr, false_to]
content = msg.as_bytes() content = msg.as_bytes()
assert "523" in handler.check_DATA(envelope=env2) assert "500" in handler.check_DATA(envelope=env2)
def test_cleartext_passthrough_senders(gencreds, handler, maildata): def test_passthrough_senders(gencreds, handler, maildata):
acc1 = gencreds()[0] acc1 = gencreds()[0]
to_addr = "recipient@something.org" to_addr = "recipient@something.org"
handler.config.passthrough_senders = [acc1] handler.config.passthrough_senders = [acc1]
@@ -241,9 +171,8 @@ def test_cleartext_passthrough_senders(gencreds, handler, maildata):
def test_check_armored_payload(): def test_check_armored_payload():
prefix = "-----BEGIN PGP MESSAGE-----\r\n" payload = """-----BEGIN PGP MESSAGE-----\r
comment = "Version: ProtonMail\r\n" \r
payload = """\r
wU4DSqFx0d1yqAoSAQdAYkX/ZN/Az4B0k7X47zKyWrXxlDEdS3WOy0Yf2+GJTFgg\r wU4DSqFx0d1yqAoSAQdAYkX/ZN/Az4B0k7X47zKyWrXxlDEdS3WOy0Yf2+GJTFgg\r
Zk5ql0mLG8Ze+ZifCS0XMO4otlemSyJ0K1ZPdFMGzUDBTgNqzkFabxXoXRIBB0AM\r Zk5ql0mLG8Ze+ZifCS0XMO4otlemSyJ0K1ZPdFMGzUDBTgNqzkFabxXoXRIBB0AM\r
755wlX41X6Ay3KhnwBq7yEqSykVH6F3x11iHPKraLCAGZoaS8bKKNy/zg5slda1X\r 755wlX41X6Ay3KhnwBq7yEqSykVH6F3x11iHPKraLCAGZoaS8bKKNy/zg5slda1X\r
@@ -279,25 +208,16 @@ UN4fiB0KR9JyG2ayUdNJVkXZSZLnHyRgiaadlpUo16LVvw==\r
\r \r
""" """
commented_payload = prefix + comment + payload assert check_armored_payload(payload) == True
assert check_armored_payload(commented_payload, outgoing=False) == True
assert check_armored_payload(commented_payload, outgoing=True) == False
payload = prefix + payload
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n") payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload, outgoing=False) == True assert check_armored_payload(payload) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n") payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload, outgoing=False) == True assert check_armored_payload(payload) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = payload.removesuffix("\r\n") payload = payload.removesuffix("\r\n")
assert check_armored_payload(payload, outgoing=False) == True assert check_armored_payload(payload) == True
assert check_armored_payload(payload, outgoing=True) == True
payload = """-----BEGIN PGP MESSAGE-----\r payload = """-----BEGIN PGP MESSAGE-----\r
\r \r
@@ -305,8 +225,7 @@ HELLOWORLD
-----END PGP MESSAGE-----\r -----END PGP MESSAGE-----\r
\r \r
""" """
assert check_armored_payload(payload, outgoing=False) == False assert check_armored_payload(payload) == False
assert check_armored_payload(payload, outgoing=True) == False
payload = """-----BEGIN PGP MESSAGE-----\r payload = """-----BEGIN PGP MESSAGE-----\r
\r \r
@@ -314,48 +233,4 @@ HELLOWORLD
-----END PGP MESSAGE-----\r -----END PGP MESSAGE-----\r
\r \r
""" """
assert check_armored_payload(payload, outgoing=False) == False assert check_armored_payload(payload) == False
assert check_armored_payload(payload, outgoing=True) == False
# Test payload using partial body length
# as generated by GopenPGP.
payload = """-----BEGIN PGP MESSAGE-----\r
\r
wV4DdCVjRfOT3TQSAQdAY5+pjT6mlCxPGdR3be4w7oJJRUGIPI/Vnh+mJxGSm34w\r
LNlVc89S1g22uQYFif2sUJsQWbpoHpNkuWpkSgOaHmNvrZiY/YU5iv+cZ3LbmtUG\r
0uoBisSHh9O1c+5sYZSbrvYZ1NOwlD7Fv/U5/Mw4E5+CjxfdgNGp5o3DDddzPK78\r
jseDhdSXxnaiIJC93hxNX6R1RPt3G2gukyzx69wciPQShcF8zf3W3o75Ed7B8etV\r
QEeB16xzdFhKa9JxdjTu3osgCs21IO7wpcFkjc7nZzlW6jPnELJJaNmv4yOOCjMp\r
6YAkaN/BkL+jHTznHDuDsT5ilnTXpwHDU1Cm9PIx/KFcNCQnIB+2DcdIHPHUH1ci\r
jvqoeXAVWjKXEjS7PqPFuP/xGbrWG2ugs+toXJOKbgRkExvKs1dwPFKrgghvCVbW\r
AcKejQKAPArLwpkA7aD875TZQShvGt74fNs45XBlGOYOnNOAJ1KAmzrXLIDViyyB\r
kDsmTBk785xofuCkjBpXSe6vsMprPzCteDfaUibh8FHeJjucxPerwuOPEmnogNaf\r
YyL4+iy8H8I9/p7pmUqILprxTG0jTOtlk0bTVzeiF56W1xbtSEMuOo4oFbQTyOM2\r
bKXaYo774Jm+rRtKAnnI2dtf9RpK19cog6YNzfYjesLKbXDsPZbN5rmwyFiCvvxC\r
kQ6JLob+B2fPdY2gzy7LypxktS8Zi1HJcWDHJGVmQodaDLqKUObb4M26bXDe6oxI\r
NS8PJz5exVbM3KhZnUOEn6PJRBBf5a/ZqxlhZPcQo/oBuhKpBRpO5kSDwPIUByu3\r
UlXLSkpMqe9pUarAOEuQjfl2RVY7U+RrQYp4YP5keMO+i8NCefAFbowTTufO1JIq\r
2nVgCi/QVnxZyEc9OYt/8AE3g4cdojE+vsSDifZLSWYIetpfrohHv3dT3StD1QRG\r
0QE6qq6oKpg/IL0cjvuX4c7a7bslv2fXp8t75y37RU6253qdIebhxc/cRhPbc/yu\r
p0YLyD4SrvKTLP2ZV95jT4IPEpqm4AN3QmiOzdtqR2gLyb62L8QfqI/FdwsIiRiM\r
hqydwoqt/lfSqG1WKPh+6EkMkH+TDiCC1BQdbN1MNcyUtcjb35PR2c8Ld2TF3guA\r
jLIqMt/Vb7hBoMb2FcsOYY25ka9oV62OwgKWLXnFzk+modMR5fzb4kxVVAYEqP+D\r
T5KO1Vs76v1fyPGOq6BbBCvLwTqe/e6IZInJles4v5jrhnLcGKmNGivCUDe6X6NY\r
UKNt5RsZllwDQpaAb5dMNhyrk8SgIE7TBI7rvqIdUCE52Vy+0JDxFg5olRpFUfO6\r
/MyTW3Yo/ekk/npHr7iYYqJTCc21bDGLWQcIo/XO7WPxrKNWGBNPFnkRdw0MaKr4\r
+cEM3V8NFnSEpC12xA+RX/CezuJtwXZK5MpG76eYqMO6qyC+c25YcFecEufDZDxx\r
ZLqRszVRyxyWPtk/oIeQK2v9wOqY6N9/ff01gHz69vqYqN5bUw/QKZsmx1zW+gPw\r
6x2tDK2BHeYl182gCbhlKISRFwCtbjqZSkiKWao/VtygHkw0fK34avJuyQ/X9YaN\r
BRy+7Lf3VA53pnB5WJ1xwRXN8VDvmZeXzv2krHveCMemj0OjnRoCLu117xN0A5m9\r
Fm/RoDix5PolDHtWTtr2m1n2hp2LHnj8at9lFEd0SKhAYHVL9KjzycwWODZRXt+x\r
zGDDuooEeTvdY5NLyKcl4gETz1ZP4Ez5jGGjhPSwSpq1mU7UaJ9ZXXdr4KHyifW6\r
ggNzNsGhXTap7IWZpTtqXABydfiBshmH2NjqtNDwBweJVSgP10+r0WhMWlaZs6xl\r
V3o5yskJt6GlkwpJxZrTvN6Tiww/eW7HFV6NGf7IRSWY5tJc/iA7/92tOmkdvJ1q\r
myLbG7cJB787QjplEyVe2P/JBO6xYvbkJLf9Q+HaviTO25rugRSrYsoKMDfO8VlQ\r
1CcnTPVtApPZJEQzAWJEgVAM8uIlkqWJJMgyWT34sTkdBeCUFGloXQFs9Yxd0AGf\r
/zHEkYZSTKpVSvAIGu4=\r
=6iHb\r
-----END PGP MESSAGE-----\r
"""
assert check_armored_payload(payload, outgoing=False) == True
assert check_armored_payload(payload, outgoing=True) == True

View File

@@ -242,22 +242,6 @@ def test_requeue_removes_tmp_files(notifier, metadata, testaddr, caplog):
assert queue_item.addr == testaddr assert queue_item.addr == testaddr
def test_requeue_removes_invalid_files(notifier, metadata, testaddr, caplog):
metadata.add_token_to_addr(testaddr, "01234")
notifier.new_message_for_addr(testaddr, metadata)
# empty/invalid files should be ignored
p = notifier.queue_dir.joinpath("1203981203")
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): def test_start_and_stop_notification_threads(notifier, testaddr):
threads = notifier.start_notification_threads(None) threads = notifier.start_notification_threads(None)
for retry_num, threadlist in threads.items(): for retry_num, threadlist in threads.items():

View File

@@ -2,15 +2,8 @@ from chatmaild.metrics import main
def test_main(tmp_path, capsys): def test_main(tmp_path, capsys):
paths = []
for x in ("ci-asllkj", "ac_12l3kj", "qweqwe", "ci-l1k2j31l2k3"): for x in ("ci-asllkj", "ac_12l3kj", "qweqwe", "ci-l1k2j31l2k3"):
p = tmp_path.joinpath(x) tmp_path.joinpath(x).mkdir()
p.mkdir()
p.joinpath("cur").mkdir()
paths.append(p)
tmp_path.joinpath("nomailbox").mkdir()
main(tmp_path) main(tmp_path)
out, _ = capsys.readouterr() out, _ = capsys.readouterr()
d = {} d = {}

View File

@@ -40,17 +40,3 @@ def test_no_mailboxes_dir(testaddr, example_config, tmp_path):
user.set_password("someeqkjwelkqwjleqwe") user.set_password("someeqkjwelkqwjleqwe")
user.set_last_login_timestamp(100000) user.set_last_login_timestamp(100000)
assert user.get_last_login_timestamp() == 86400 assert user.get_last_login_timestamp() == 86400
def test_set_get_cleartext_flag(testaddr, example_config, tmp_path):
p = tmp_path.joinpath("a", "mailboxes")
example_config.mailboxes_dir = p
user = example_config.get_user(testaddr)
user.set_password("someeqkjwelkqwjleqwe")
user.set_last_login_timestamp(100000)
assert user.get_last_login_timestamp() == 86400
assert not user.is_incoming_cleartext_ok()
user.allow_incoming_cleartext()
assert user.is_incoming_cleartext_ok()

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env python3
import socket
def turn_credentials() -> str:
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client_socket:
client_socket.connect("/run/chatmail-turn/turn.socket")
with client_socket.makefile("rb") as file:
return file.readline().decode("utf-8")

View File

@@ -13,7 +13,6 @@ class User:
self.maildir = maildir self.maildir = maildir
self.addr = addr self.addr = addr
self.password_path = password_path self.password_path = password_path
self.enforce_E2EE_path = maildir.joinpath("enforceE2EEincoming")
self.uid = uid self.uid = uid
self.gid = gid self.gid = gid
@@ -36,13 +35,6 @@ class User:
home = str(self.maildir) home = str(self.maildir)
return dict(addr=self.addr, home=home, uid=self.uid, gid=self.gid, password=pw) return dict(addr=self.addr, home=home, uid=self.uid, gid=self.gid, password=pw)
def is_incoming_cleartext_ok(self):
return not self.enforce_E2EE_path.exists()
def allow_incoming_cleartext(self):
if self.enforce_E2EE_path.exists():
self.enforce_E2EE_path.unlink()
def set_password(self, enc_password): def set_password(self, enc_password):
"""Set the specified password for this user. """Set the specified password for this user.
@@ -58,8 +50,6 @@ class User:
if not self.addr.startswith("echo@"): if not self.addr.startswith("echo@"):
logging.error(f"could not write password for: {self.addr}") logging.error(f"could not write password for: {self.addr}")
raise raise
if not self.addr.startswith("echo@"):
self.enforce_E2EE_path.touch()
def set_last_login_timestamp(self, timestamp): def set_last_login_timestamp(self, timestamp):
"""Track login time with daily granularity """Track login time with daily granularity

View File

@@ -41,6 +41,3 @@ lint.select = [
"PLE", # Pylint Error "PLE", # Pylint Error
"PLW", # Pylint Warning "PLW", # Pylint Warning
] ]
lint.ignore = [
"PLC0415" # import-outside-top-level
]

View File

@@ -7,35 +7,17 @@ import io
import shutil import shutil
import subprocess import subprocess
import sys import sys
from io import StringIO
from pathlib import Path from pathlib import Path
from chatmaild.config import Config, read_config from chatmaild.config import Config, read_config
from pyinfra import facts, host, logger from pyinfra import facts, host
from pyinfra.api import FactBase from pyinfra.facts.files import File
from pyinfra.facts.files import File, Sha256File
from pyinfra.facts.server import Sysctl
from pyinfra.facts.systemd import SystemdEnabled from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.operations import apt, files, pip, server, systemd from pyinfra.operations import apt, files, pip, server, systemd
from .acmetool import deploy_acmetool from .acmetool import deploy_acmetool
class Port(FactBase):
"""
Returns the process occuping a port.
"""
def command(self, port: int) -> str:
return (
"ss -lptn 'src :%d' | awk 'NR>1 {print $6,$7}' | sed 's/users:((\"//;s/\".*//'"
% (port,)
)
def process(self, output: [str]) -> str:
return output[0]
def _build_chatmaild(dist_dir) -> None: def _build_chatmaild(dist_dir) -> None:
dist_dir = Path(dist_dir).resolve() dist_dir = Path(dist_dir).resolve()
if dist_dir.exists(): if dist_dir.exists():
@@ -124,15 +106,12 @@ def _install_remote_venv_with_chatmaild(config) -> None:
for fn in ( for fn in (
"doveauth", "doveauth",
"filtermail", "filtermail",
"filtermail-incoming",
"echobot", "echobot",
"chatmail-metadata", "chatmail-metadata",
"lastlogin", "lastlogin",
"turnserver",
): ):
execpath = fn if fn != "filtermail-incoming" else "filtermail"
params = dict( params = dict(
execpath=f"{remote_venv_dir}/bin/{execpath}", execpath=f"{remote_venv_dir}/bin/{fn}",
config_path=remote_chatmail_inipath, config_path=remote_chatmail_inipath,
remote_venv_dir=remote_venv_dir, remote_venv_dir=remote_venv_dir,
mail_domain=config.mail_domain, mail_domain=config.mail_domain,
@@ -236,7 +215,7 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
server.shell( server.shell(
name="Generate OpenDKIM domain keys", name="Generate OpenDKIM domain keys",
commands=[ commands=[
f"/usr/sbin/opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}" f"opendkim-genkey -D /etc/dkimkeys -d {domain} -s {dkim_selector}"
], ],
_use_su_login=True, _use_su_login=True,
_su_user="opendkim", _su_user="opendkim",
@@ -249,6 +228,7 @@ def _configure_opendkim(domain: str, dkim_selector: str = "dkim") -> bool:
) )
need_restart |= service_file.changed need_restart |= service_file.changed
return need_restart return need_restart
@@ -319,40 +299,6 @@ def _configure_postfix(config: Config, debug: bool = False) -> bool:
return need_restart return need_restart
def _install_dovecot_package(package: str, arch: str):
arch = "amd64" if arch == "x86_64" else arch
arch = "arm64" if arch == "aarch64" else arch
url = f"https://download.delta.chat/dovecot/dovecot-{package}_2.3.21%2Bdfsg1-3_{arch}.deb"
deb_filename = "/root/" + url.split("/")[-1]
match (package, arch):
case ("core", "amd64"):
sha256 = "43f593332e22ac7701c62d58b575d2ca409e0f64857a2803be886c22860f5587"
case ("core", "arm64"):
sha256 = "4d21eba1a83f51c100f08f2e49f0c9f8f52f721ebc34f75018e043306da993a7"
case ("imapd", "amd64"):
sha256 = "8d8dc6fc00bbb6cdb25d345844f41ce2f1c53f764b79a838eb2a03103eebfa86"
case ("imapd", "arm64"):
sha256 = "178fa877ddd5df9930e8308b518f4b07df10e759050725f8217a0c1fb3fd707f"
case ("lmtpd", "amd64"):
sha256 = "2f69ba5e35363de50962d42cccbfe4ed8495265044e244007d7ccddad77513ab"
case ("lmtpd", "arm64"):
sha256 = "89f52fb36524f5877a177dff4a713ba771fd3f91f22ed0af7238d495e143b38f"
case _:
apt.packages(packages=[f"dovecot-{package}"])
return
files.download(
name=f"Download dovecot-{package}",
src=url,
dest=deb_filename,
sha256sum=sha256,
cache_time=60 * 60 * 24 * 365 * 10, # never redownload the package
)
apt.deb(name=f"Install dovecot-{package}", src=deb_filename)
def _configure_dovecot(config: Config, debug: bool = False) -> bool: def _configure_dovecot(config: Config, debug: bool = False) -> bool:
"""Configures Dovecot IMAP server.""" """Configures Dovecot IMAP server."""
need_restart = False need_restart = False
@@ -400,10 +346,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
# it is recommended to set the following inotify limits # it is recommended to set the following inotify limits
for name in ("max_user_instances", "max_user_watches"): for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}" key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] > 65535:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl( server.sysctl(
name=f"Change {key}", name=f"Change {key}",
key=key, key=key,
@@ -411,13 +353,6 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
persist=True, persist=True,
) )
timezone_env = files.line(
name="Set TZ environment variable",
path="/etc/environment",
line="TZ=:/etc/localtime",
)
need_restart |= timezone_env.changed
return need_restart return need_restart
@@ -498,77 +433,10 @@ def check_config(config):
return config return config
def deploy_turn_server(config):
(url, sha256sum) = {
"x86_64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-x86_64-linux",
"841e527c15fdc2940b0469e206188ea8f0af48533be12ecb8098520f813d41e4",
),
"aarch64": (
"https://github.com/chatmail/chatmail-turn/releases/download/v0.3/chatmail-turn-aarch64-linux",
"a5fc2d06d937b56a34e098d2cd72a82d3e89967518d159bf246dc69b65e81b42",
),
}[host.get_fact(facts.server.Arch)]
need_restart = False
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/chatmail-turn")
if existing_sha256sum != sha256sum:
server.shell(
name="Download chatmail-turn",
commands=[
f"(curl -L {url} >/usr/local/bin/chatmail-turn.new && (echo '{sha256sum} /usr/local/bin/chatmail-turn.new' | sha256sum -c) && mv /usr/local/bin/chatmail-turn.new /usr/local/bin/chatmail-turn)",
"chmod 755 /usr/local/bin/chatmail-turn",
],
)
need_restart = True
source_path = importlib.resources.files(__package__).joinpath(
"service", "turnserver.service.f"
)
content = source_path.read_text().format(mail_domain=config.mail_domain).encode()
systemd_unit = files.put(
name="Upload turnserver.service",
src=io.BytesIO(content),
dest="/etc/systemd/system/turnserver.service",
user="root",
group="root",
mode="644",
)
need_restart |= systemd_unit.changed
systemd.service(
name="Setup turnserver service",
service="turnserver.service",
running=True,
enabled=True,
restarted=need_restart,
daemon_reload=systemd_unit.changed,
)
def deploy_mtail(config): def deploy_mtail(config):
# Uninstall mtail package, we are going to install a static binary. apt.packages(
apt.packages(name="Uninstall mtail", packages=["mtail"], present=False) name="Install mtail",
packages=["mtail"],
(url, sha256sum) = {
"x86_64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_amd64.tar.gz",
"123c2ee5f48c3eff12ebccee38befd2233d715da736000ccde49e3d5607724e4",
),
"aarch64": (
"https://github.com/google/mtail/releases/download/v3.0.8/mtail_3.0.8_linux_arm64.tar.gz",
"aa04811c0929b6754408676de520e050c45dddeb3401881888a092c9aea89cae",
),
}[host.get_fact(facts.server.Arch)]
server.shell(
name="Download mtail",
commands=[
f"(echo '{sha256sum} /usr/local/bin/mtail' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - mtail -O >/usr/local/bin/mtail.new && mv /usr/local/bin/mtail.new /usr/local/bin/mtail)",
"chmod 755 /usr/local/bin/mtail",
],
) )
# Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`. # Using our own systemd unit instead of `/usr/lib/systemd/system/mtail.service`.
@@ -606,12 +474,12 @@ def deploy_mtail(config):
def deploy_iroh_relay(config) -> None: def deploy_iroh_relay(config) -> None:
(url, sha256sum) = { (url, sha256sum) = {
"x86_64": ( "x86_64": (
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-x86_64-unknown-linux-musl.tar.gz", "https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-x86_64-unknown-linux-musl.tar.gz",
"45c81199dbd70f8c4c30fef7f3b9727ca6e3cea8f2831333eeaf8aa71bf0fac1", "2ffacf7c0622c26b67a5895ee8e07388769599f60e5f52a3bd40a3258db89b2c",
), ),
"aarch64": ( "aarch64": (
"https://github.com/n0-computer/iroh/releases/download/v0.35.0/iroh-relay-v0.35.0-aarch64-unknown-linux-musl.tar.gz", "https://github.com/n0-computer/iroh/releases/download/v0.28.1/iroh-relay-v0.28.1-aarch64-unknown-linux-musl.tar.gz",
"f8ef27631fac213b3ef668d02acd5b3e215292746a3fc71d90c63115446008b1", "b915037bcc1ff1110cc9fcb5de4a17c00ff576fd2f568cd339b3b2d54c420dc4",
), ),
}[host.get_fact(facts.server.Arch)] }[host.get_fact(facts.server.Arch)]
@@ -620,18 +488,15 @@ def deploy_iroh_relay(config) -> None:
packages=["curl"], packages=["curl"],
) )
need_restart = False server.shell(
name="Download iroh-relay",
commands=[
f"(echo '{sha256sum} /usr/local/bin/iroh-relay' | sha256sum -c) || (curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
"chmod 755 /usr/local/bin/iroh-relay",
],
)
existing_sha256sum = host.get_fact(Sha256File, "/usr/local/bin/iroh-relay") need_restart = False
if existing_sha256sum != sha256sum:
server.shell(
name="Download iroh-relay",
commands=[
f"(curl -L {url} | gunzip | tar -x -f - ./iroh-relay -O >/usr/local/bin/iroh-relay.new && (echo '{sha256sum} /usr/local/bin/iroh-relay.new' | sha256sum -c) && mv /usr/local/bin/iroh-relay.new /usr/local/bin/iroh-relay)",
"chmod 755 /usr/local/bin/iroh-relay",
],
)
need_restart = True
systemd_unit = files.put( systemd_unit = files.put(
name="Upload iroh-relay systemd unit", name="Upload iroh-relay systemd unit",
@@ -672,10 +537,11 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
check_config(config) check_config(config)
mail_domain = config.mail_domain mail_domain = config.mail_domain
from .www import build_webpages, get_paths from .www import build_webpages
server.group(name="Create vmail group", group="vmail", system=True) server.group(name="Create vmail group", group="vmail", system=True)
server.user(name="Create vmail user", user="vmail", group="vmail", system=True) server.user(name="Create vmail user", user="vmail", group="vmail", system=True)
server.user(name="Create filtermail user", user="filtermail", system=True)
server.group(name="Create opendkim group", group="opendkim", system=True) server.group(name="Create opendkim group", group="opendkim", system=True)
server.user( server.user(
name="Create opendkim user", name="Create opendkim user",
@@ -707,15 +573,9 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
path="/etc/apt/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/ ./", line="deb [signed-by=/etc/apt/keyrings/obs-home-deltachat.gpg] https://download.opensuse.org/repositories/home:/deltachat/Debian_12/ ./",
escape_regex_characters=True, escape_regex_characters=True,
present=False, ensure_newline=True,
) )
if host.get_fact(Port, port=53) != "unbound":
files.line(
name="Add 9.9.9.9 to resolv.conf",
path="/etc/resolv.conf",
line="nameserver 9.9.9.9",
)
apt.update(name="apt update", cache_time=24 * 3600) apt.update(name="apt update", cache_time=24 * 3600)
apt.upgrade(name="upgrade apt packages", auto_remove=True) apt.upgrade(name="upgrade apt packages", auto_remove=True)
@@ -724,39 +584,9 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
packages=["rsync"], packages=["rsync"],
) )
deploy_turn_server(config)
# Run local DNS resolver `unbound`. # Run local DNS resolver `unbound`.
# `resolvconf` takes care of setting up /etc/resolv.conf # `resolvconf` takes care of setting up /etc/resolv.conf
# to use 127.0.0.1 as the resolver. # to use 127.0.0.1 as the resolver.
from cmdeploy.cmdeploy import Out
port_services = [
(["master", "smtpd"], 25),
("unbound", 53),
("acmetool", 80),
(["imap-login", "dovecot"], 143),
("nginx", 443),
(["master", "smtpd"], 465),
(["master", "smtpd"], 587),
(["imap-login", "dovecot"], 993),
("iroh-relay", 3340),
("nginx", 8443),
(["master", "smtpd"], config.postfix_reinject_port),
(["master", "smtpd"], config.postfix_reinject_port_incoming),
("filtermail", config.filtermail_smtp_port),
("filtermail", config.filtermail_smtp_port_incoming),
]
for service, port in port_services:
print(f"Checking if port {port} is available for {service}...")
running_service = host.get_fact(Port, port=port)
if running_service:
if running_service not in service:
Out().red(
f"Deploy failed: port {port} is occupied by: {running_service}"
)
exit(1)
apt.packages( apt.packages(
name="Install unbound", name="Install unbound",
packages=["unbound", "unbound-anchor", "dnsutils"], packages=["unbound", "unbound-anchor", "dnsutils"],
@@ -780,7 +610,6 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
# Deploy acmetool to have TLS certificates. # Deploy acmetool to have TLS certificates.
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"] tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool( deploy_acmetool(
email=config.acme_email,
domains=tls_domains, domains=tls_domains,
) )
@@ -795,10 +624,10 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
packages="postfix", packages="postfix",
) )
if not "dovecot.service" in host.get_fact(SystemdEnabled): apt.packages(
_install_dovecot_package("core", host.get_fact(facts.server.Arch)) name="Install Dovecot",
_install_dovecot_package("imapd", host.get_fact(facts.server.Arch)) packages=["dovecot-imapd", "dovecot-lmtpd"],
_install_dovecot_package("lmtpd", host.get_fact(facts.server.Arch)) )
apt.packages( apt.packages(
name="Install nginx", name="Install nginx",
@@ -810,16 +639,12 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
packages=["fcgiwrap"], packages=["fcgiwrap"],
) )
www_path, src_dir, build_dir = get_paths(config) www_path = importlib.resources.files(__package__).joinpath("../../../www").resolve()
# if www_folder was set to a non-existing folder, skip upload
if not www_path.is_dir(): build_dir = www_path.joinpath("build")
logger.warning("Building web pages is disabled in chatmail.ini, skipping") src_dir = www_path.joinpath("src")
else: build_webpages(src_dir, build_dir, config)
# if www_folder is a hugo page, build it files.rsync(f"{build_dir}/", "/var/www/html", flags=["-avz"])
if build_dir:
www_path = build_webpages(src_dir, build_dir, config)
# if it is not a hugo page, upload it as is
files.rsync(f"{www_path}/", "/var/www/html", flags=["-avz", "--chown=www-data"])
_install_remote_venv_with_chatmaild(config) _install_remote_venv_with_chatmaild(config)
debug = False debug = False
@@ -867,19 +692,6 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
restarted=nginx_need_restart, restarted=nginx_need_restart,
) )
systemd.service(
name="Start and enable fcgiwrap",
service="fcgiwrap.service",
running=True,
enabled=True,
)
systemd.service(
name="Restart echobot if postfix and dovecot were just started",
service="echobot.service",
restarted=postfix_need_restart and dovecot_need_restart,
)
# This file is used by auth proxy. # This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName # https://wiki.debian.org/EtcMailName
server.shell( server.shell(
@@ -912,19 +724,5 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
name="Ensure cron is installed", name="Ensure cron is installed",
packages=["cron"], packages=["cron"],
) )
try:
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
except Exception:
git_hash = "unknown\n"
try:
git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception:
git_diff = ""
files.put(
name="Upload chatmail relay git commiit hash",
src=StringIO(git_hash + git_diff),
dest="/etc/chatmail-version",
mode="700",
)
deploy_mtail(config) deploy_mtail(config)

View File

@@ -1,5 +1,7 @@
import importlib.resources 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, systemd
@@ -52,6 +54,12 @@ def deploy_acmetool(email="", domains=[]):
group="root", group="root",
mode="644", 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( systemd.service(
name="Setup acmetool-redirector service", name="Setup acmetool-redirector service",

View File

@@ -19,7 +19,7 @@ from packaging import version
from termcolor import colored from termcolor import colored
from . import dns, remote from . import dns, remote
from .sshexec import SSHExec, LocalExec from .sshexec import SSHExec
# #
# cmdeploy sub commands and options # cmdeploy sub commands and options
@@ -32,30 +32,17 @@ def init_cmd_options(parser):
action="store", action="store",
help="fully qualified DNS domain name for your chatmail instance", help="fully qualified DNS domain name for your chatmail instance",
) )
parser.add_argument(
"--force",
dest="recreate_ini",
action="store_true",
help="force reacreate ini file",
)
def init_cmd(args, out): def init_cmd(args, out):
"""Initialize chatmail config file.""" """Initialize chatmail config file."""
mail_domain = args.chatmail_domain mail_domain = args.chatmail_domain
inipath = args.inipath
if args.inipath.exists(): if args.inipath.exists():
if not args.recreate_ini: print(f"Path exists, not modifying: {args.inipath}")
print(f"[WARNING] Path exists, not modifying: {inipath}") return 1
return 1 else:
else: write_initial_config(args.inipath, mail_domain, overrides={})
print( out.green(f"created config file for {mail_domain} in {args.inipath}")
f"[WARNING] Force argument was provided, deleting config file: {inipath}"
)
inipath.unlink()
write_initial_config(inipath, mail_domain, overrides={})
out.green(f"created config file for {mail_domain} in {inipath}")
def run_cmd_options(parser): def run_cmd_options(parser):
@@ -72,24 +59,20 @@ def run_cmd_options(parser):
help="install/upgrade the server, but disable postfix & dovecot for now", help="install/upgrade the server, but disable postfix & dovecot for now",
) )
parser.add_argument( parser.add_argument(
"--skip-dns-check", "--ssh-host",
dest="dns_check_disabled", dest="ssh_host",
action="store_true", help="specify an SSH host to deploy to; uses mail_domain from chatmail.ini by default",
help="disable checks nslookup for dns",
) )
add_ssh_host_option(parser)
def run_cmd(args, out): def run_cmd(args, out):
"""Deploy chatmail services on the remote server.""" """Deploy chatmail services on the remote server."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain sshexec = args.get_sshexec()
sshexec = get_sshexec(ssh_host)
require_iroh = args.config.enable_iroh_relay require_iroh = args.config.enable_iroh_relay
if not args.dns_check_disabled: remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) if not dns.check_initial_remote_data(remote_data, print=out.red):
if not dns.check_initial_remote_data(remote_data, print=out.red): return 1
return 1
env = os.environ.copy() env = os.environ.copy()
env["CHATMAIL_INI"] = args.inipath env["CHATMAIL_INI"] = args.inipath
@@ -97,37 +80,21 @@ def run_cmd(args, out):
env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else "" env["CHATMAIL_REQUIRE_IROH"] = "True" if require_iroh else ""
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve() deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
pyinf = "pyinfra --dry" if args.dry_run else "pyinfra" pyinf = "pyinfra --dry" if args.dry_run else "pyinfra"
ssh_host = args.config.mail_domain if not args.ssh_host else args.ssh_host
cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y" cmd = f"{pyinf} --ssh-user root {ssh_host} {deploy_path} -y"
if ssh_host in ["localhost", "@docker"]:
cmd = f"{pyinf} @local {deploy_path} -y"
if version.parse(pyinfra.__version__) < version.parse("3"): if version.parse(pyinfra.__version__) < version.parse("3"):
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.") out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
return 1 return 1
try: retcode = out.check_call(cmd, env=env)
retcode = out.check_call(cmd, env=env) if retcode == 0:
if retcode == 0: out.green("Deploy completed, call `cmdeploy dns` next.")
if not args.disable_mail: elif not remote_data["acme_account_url"]:
print("\nYou can try out the relay by talking to this echo bot: ") out.red("Deploy completed but letsencrypt not configured")
sshexec = SSHExec(args.config.mail_domain, verbose=args.verbose) out.red("Run 'cmdeploy run' again")
print( retcode = 0
sshexec( else:
call=remote.rshell.shell,
kwargs=dict(command="cat /var/lib/echobot/invite-link.txt"),
)
)
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")
except subprocess.CalledProcessError:
out.red("Deploy failed") out.red("Deploy failed")
retcode = 1
return retcode return retcode
@@ -139,13 +106,11 @@ def dns_cmd_options(parser):
default=None, default=None,
help="write out a zonefile", help="write out a zonefile",
) )
add_ssh_host_option(parser)
def dns_cmd(args, out): def dns_cmd(args, out):
"""Check DNS entries and optionally generate dns zone file.""" """Check DNS entries and optionally generate dns zone file."""
ssh_host = args.ssh_host if args.ssh_host else args.config.mail_domain sshexec = args.get_sshexec()
sshexec = get_sshexec(ssh_host, verbose=args.verbose)
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain) remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not remote_data: if not remote_data:
return 1 return 1
@@ -299,15 +264,6 @@ class Out:
return proc.returncode return proc.returncode
def add_ssh_host_option(parser):
parser.add_argument(
"--ssh-host",
dest="ssh_host",
help="Run commands on 'localhost', via '@docker', or on a specific SSH host "
"instead of chatmail.ini's mail_domain.",
)
def add_config_option(parser): def add_config_option(parser):
parser.add_argument( parser.add_argument(
"--config", "--config",
@@ -363,16 +319,6 @@ def get_parser():
return parser return parser
def get_sshexec(ssh_host: str, verbose=True):
if ssh_host in ["localhost", "@local"]:
return LocalExec(verbose, docker=False)
elif ssh_host == "@docker":
return LocalExec(verbose, docker=True)
if verbose:
print(f"[ssh] login to {ssh_host}")
return SSHExec(ssh_host, verbose=verbose)
def main(args=None): def main(args=None):
"""Provide main entry point for 'cmdeploy' CLI invocation.""" """Provide main entry point for 'cmdeploy' CLI invocation."""
parser = get_parser() parser = get_parser()
@@ -380,6 +326,12 @@ def main(args=None):
if not hasattr(args, "func"): if not hasattr(args, "func"):
return parser.parse_args(["-h"]) return parser.parse_args(["-h"])
def get_sshexec():
print(f"[ssh] login to {args.config.mail_domain}")
return SSHExec(args.config.mail_domain, verbose=args.verbose)
args.get_sshexec = get_sshexec
out = Out() out = Out()
kwargs = {} kwargs = {}
if args.func.__name__ not in ("init_cmd", "fmt_cmd"): if args.func.__name__ not in ("init_cmd", "fmt_cmd"):

View File

@@ -45,7 +45,8 @@ def check_full_zone(sshexec, remote_data, out, zonefile) -> int:
and return (exitcode, remote_data) tuple.""" and return (exitcode, remote_data) tuple."""
required_diff, recommended_diff = sshexec.logged( required_diff, recommended_diff = sshexec.logged(
remote.rdns.check_zonefile, kwargs=dict(zonefile=zonefile, verbose=False), remote.rdns.check_zonefile,
kwargs=dict(zonefile=zonefile, mail_domain=remote_data["mail_domain"]),
) )
returncode = 0 returncode = 0

View File

@@ -177,34 +177,20 @@ service auth-worker {
} }
service imap-login { service imap-login {
# High-performance mode as described in # High-security mode.
# <https://doc.dovecot.org/2.3/admin_manual/login_processes/#high-performance-mode> # Each process serves a single connection and exits afterwards.
# # This is the default, but we set it explicitly to be sure.
# So-called high-security mode described in # See <https://doc.dovecot.org/admin_manual/login_processes/#high-security-mode> for details.
# <https://doc.dovecot.org/2.3/admin_manual/login_processes/#high-security-mode> service_count = 1
# and enabled by default with `service_count = 1` starts one process per connection
# and has problems logging in thousands of users after Dovecot restart.
service_count = 0
# Increase virtual memory size limit. # Inrease the number of simultaneous connections.
# Since imap-login processes handle TLS connections
# even after logging users in
# and many connections are handled by each process,
# memory size limit should be increased.
# #
# Otherwise the whole process eventually dies # As of Dovecot 2.3.19.1 the default is 100 processes.
# with an error similar to # Combined with `service_count = 1` it means only 100 connections
# imap-login: Fatal: master: service(imap-login): # can be handled simultaneously.
# child 1422951 returned error 83 process_limit = 10000
# (Out of memory (service imap-login { vsz_limit=256 MB },
# you may need to increase it)
# and takes down all its TLS connections at once.
vsz_limit = 1G
# Avoid startup latency for new connections. # Avoid startup latency for new connections.
#
# Should be set to at least the number of CPU cores
# according to the documentation.
process_min_avail = 10 process_min_avail = 10
} }

View File

@@ -1,5 +1,5 @@
# delete already seen big mails after 7 days, in the INBOX # delete already seen big mails after 7 days, in the INBOX
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_large_after }} -size +200k -type f -delete 2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +7 -size +200k -type f -delete
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox # 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 2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# or in any IMAP subfolder # or in any IMAP subfolder

View File

@@ -2,6 +2,15 @@ function dovecot_lua_notify_begin_txn(user)
return user return user
end 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) function dovecot_lua_notify_event_message_new(user, event)
local mbox = user:mailbox(event.mailbox) local mbox = user:mailbox(event.mailbox)
mbox:sync() mbox:sync()

View File

@@ -1,11 +1,5 @@
enable_relay = true enable_relay = true
http_bind_addr = "[::]:3340" http_bind_addr = "[::]:3340"
enable_stun = true
# Disable built-in STUN server in iroh-relay 0.35
# as we deploy our own TURN server instead.
# STUN server is going to be removed in iroh-relay 1.0
# and this line can be removed after upgrade.
enable_stun = false
enable_metrics = false enable_metrics = false
metrics_bind_addr = "127.0.0.1:9092" metrics_bind_addr = "127.0.0.1:9092"

View File

@@ -3,7 +3,7 @@ Description=mtail
[Service] [Service]
Type=simple Type=simple
ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/local/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs -" ExecStart=/bin/sh -c "journalctl -f -o short-iso -n 0 | /usr/bin/mtail --address={{ address }} --port={{ port }} --progs /etc/mtail --logtostderr --logs /dev/stdin"
Restart=on-failure Restart=on-failure
[Install] [Install]

View File

@@ -2,25 +2,11 @@ load_module modules/ngx_stream_module.so;
user www-data; user www-data;
worker_processes auto; worker_processes auto;
# Increase the number of connections
# that a worker process can open
# to avoid errors such as
# accept4() failed (24: Too many open files)
# and
# socket() failed (24: Too many open files) while connecting to upstream
# in the logs.
# <https://nginx.org/en/docs/ngx_core_module.html#worker_rlimit_nofile>
worker_rlimit_nofile 2048;
pid /run/nginx.pid; pid /run/nginx.pid;
error_log syslog:server=unix:/dev/log,facility=local3; error_log syslog:server=unix:/dev/log,facility=local3;
events { events {
# Increase to avoid errors such as worker_connections 768;
# 768 worker_connections are not enough while connecting to upstream
# in the logs.
# <https://nginx.org/en/docs/ngx_core_module.html#worker_connections>
worker_connections 2048;
# multi_accept on; # multi_accept on;
} }
@@ -60,13 +46,16 @@ http {
server { server {
listen 127.0.0.1:8443 ssl default_server; listen 8443 ssl default_server;
{% if not disable_ipv6 %}
listen [::]:8443 ssl default_server;
{% endif %}
root /var/www/html; root /var/www/html;
index index.html index.htm; index index.html index.htm;
server_name {{ config.domain_name }} www.{{ config.domain_name }} mta-sts.{{ config.domain_name }}; server_name _;
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;
@@ -131,7 +120,10 @@ http {
# Redirect www. to non-www # Redirect www. to non-www
server { server {
listen 127.0.0.1:8443 ssl; listen 8443 ssl;
{% if not disable_ipv6 %}
listen [::]:8443 ssl;
{% endif %}
server_name www.{{ config.domain_name }}; server_name www.{{ config.domain_name }};
return 301 $scheme://{{ config.domain_name }}$request_uri; return 301 $scheme://{{ config.domain_name }}$request_uri;
access_log syslog:server=unix:/dev/log,facility=local7; access_log syslog:server=unix:/dev/log,facility=local7;

View File

@@ -13,7 +13,6 @@ OversignHeaders From
On-BadSignature reject On-BadSignature reject
On-KeyNotFound reject On-KeyNotFound reject
On-NoSignature reject On-NoSignature reject
DNSTimeout 60
# Signing domain, selector, and key (required). For example, perform signing # Signing domain, selector, and key (required). For example, perform signing
# for domain "example.com" with selector "2020" (2020._domainkey.example.com), # for domain "example.com" with selector "2020" (2020._domainkey.example.com),

View File

@@ -21,9 +21,6 @@ smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=verify smtp_tls_security_level=verify
# Send SNI extension when connecting to other servers.
# <https://www.postfix.org/postconf.5.html#smtp_tls_servername>
smtp_tls_servername = hostname
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = inline:{nauta.cu=may} smtp_tls_policy_maps = inline:{nauta.cu=may}
smtpd_tls_protocols = >=TLSv1.2 smtpd_tls_protocols = >=TLSv1.2

View File

@@ -14,7 +14,7 @@ smtp inet n - y - - smtpd -v
{%- else %} {%- else %}
smtp inet n - y - - smtpd smtp inet n - y - - smtpd
{%- endif %} {%- endif %}
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }} -o smtpd_milters=unix:opendkim/opendkim.sock
submission inet n - y - 5000 smtpd submission inet n - y - 5000 smtpd
-o syslog_name=postfix/submission -o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt -o smtpd_tls_security_level=encrypt
@@ -32,6 +32,7 @@ submission inet n - y - 5000 smtpd
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -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 - 5000 smtpd
-o syslog_name=postfix/smtps -o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes -o smtpd_tls_wrappermode=yes
@@ -49,6 +50,7 @@ smtps inet n - y - 5000 smtpd
-o smtpd_client_connection_count_limit=1000 -o smtpd_client_connection_count_limit=1000
-o milter_macro_daemon_name=ORIGINATING -o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }} -o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port }}
-o cleanup_service_name=authclean
#628 inet n - y - - qmqpd #628 inet n - y - - qmqpd
pickup unix n - y 60 1 pickup pickup unix n - y 60 1 pickup
cleanup unix n - y - 0 cleanup cleanup unix n - y - 0 cleanup
@@ -76,16 +78,10 @@ anvil unix - - y - 1 anvil
scache unix - - y - 1 scache scache unix - - y - 1 scache
postlog unix-dgram n - n - 1 postlogd postlog unix-dgram n - n - 1 postlogd
filter unix - n n - - lmtp filter unix - n n - - lmtp
# Local SMTP server for reinjecting outgoing filtered mail. # Local SMTP server for reinjecting filered mail.
127.0.0.1:{{ config.postfix_reinject_port }} inet n - n - 100 smtpd localhost:{{ config.postfix_reinject_port }} inet n - n - 10 smtpd
-o syslog_name=postfix/reinject -o syslog_name=postfix/reinject
-o smtpd_milters=unix:opendkim/opendkim.sock -o smtpd_milters=unix:opendkim/opendkim.sock
-o cleanup_service_name=authclean
# Local SMTP server for reinjecting incoming filtered mail
127.0.0.1:{{ config.postfix_reinject_port_incoming }} inet n - n - 100 smtpd
-o syslog_name=postfix/reinject_incoming
-o smtpd_milters=unix:opendkim/opendkim.sock
# Cleanup `Received` headers for authenticated mail # Cleanup `Received` headers for authenticated mail
# to avoid leaking client IP. # to avoid leaking client IP.
@@ -93,10 +89,5 @@ filter unix - n n - - lmtp
# We do not do this for received mails # We do not do this for received mails
# as this will break DKIM signatures # as this will break DKIM signatures
# if `Received` header is signed. # if `Received` header is signed.
#
# This service also rewrites
# Subject with `[...]`
# to make sure the users
# cannot send unprotected Subject.
authclean unix n - - - 0 cleanup authclean unix n - - - 0 cleanup
-o header_checks=regexp:/etc/postfix/submission_header_cleanup -o header_checks=regexp:/etc/postfix/submission_header_cleanup

View File

@@ -12,23 +12,23 @@ All functions of this module
import re import re
from .rshell import CalledProcessError, shell, log_progress from .rshell import CalledProcessError, shell
def perform_initial_checks(mail_domain, pre_command=""): def perform_initial_checks(mail_domain):
"""Collecting initial DNS settings.""" """Collecting initial DNS settings."""
assert mail_domain assert mail_domain
if not shell("dig", fail_ok=True, print=log_progress): if not shell("dig", fail_ok=True):
shell("apt-get update && apt-get install -y dnsutils", print=log_progress) shell("apt-get install -y dnsutils")
A = query_dns("A", mail_domain) A = query_dns("A", mail_domain)
AAAA = query_dns("AAAA", mail_domain) AAAA = query_dns("AAAA", mail_domain)
MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}") MTA_STS = query_dns("CNAME", f"mta-sts.{mail_domain}")
WWW = query_dns("CNAME", f"www.{mail_domain}") WWW = query_dns("CNAME", f"www.{mail_domain}")
res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS, WWW=WWW) res = dict(mail_domain=mail_domain, A=A, AAAA=AAAA, MTA_STS=MTA_STS, WWW=WWW)
res["acme_account_url"] = shell(pre_command + "acmetool account-url", fail_ok=True, print=log_progress) res["acme_account_url"] = shell("acmetool account-url", fail_ok=True)
res["dkim_entry"], res["web_dkim_entry"] = get_dkim_entry( res["dkim_entry"], res["web_dkim_entry"] = get_dkim_entry(
mail_domain, pre_command, dkim_selector="opendkim" mail_domain, dkim_selector="opendkim"
) )
if not MTA_STS or not WWW or (not A and not AAAA): if not MTA_STS or not WWW or (not A and not AAAA):
@@ -40,12 +40,11 @@ def perform_initial_checks(mail_domain, pre_command=""):
return res return res
def get_dkim_entry(mail_domain, pre_command, dkim_selector): def get_dkim_entry(mail_domain, dkim_selector):
try: try:
dkim_pubkey = shell( dkim_pubkey = shell(
f"{pre_command}openssl rsa -in /etc/dkimkeys/{dkim_selector}.private " f"openssl rsa -in /etc/dkimkeys/{dkim_selector}.private "
"-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'", "-pubout 2>/dev/null | awk '/-/{next}{printf(\"%s\",$0)}'"
print=log_progress
) )
except CalledProcessError: except CalledProcessError:
return return
@@ -62,7 +61,7 @@ def query_dns(typ, domain):
# Get autoritative nameserver from the SOA record. # Get autoritative nameserver from the SOA record.
soa_answers = [ soa_answers = [
x.split() x.split()
for x in shell(f"dig -r -q {domain} -t SOA +noall +authority +answer", print=log_progress).split( for x in shell(f"dig -r -q {domain} -t SOA +noall +authority +answer").split(
"\n" "\n"
) )
] ]
@@ -72,13 +71,13 @@ def query_dns(typ, domain):
ns = soa[0][4] ns = soa[0][4]
# Query authoritative nameserver directly to bypass DNS cache. # Query authoritative nameserver directly to bypass DNS cache.
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress) res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short")
if res: if res:
return res.split("\n")[0] return res.split("\n")[0]
return "" return ""
def check_zonefile(zonefile, verbose=True): def check_zonefile(zonefile, mail_domain):
"""Check expected zone file entries.""" """Check expected zone file entries."""
required = True required = True
required_diff = [] required_diff = []
@@ -90,7 +89,7 @@ def check_zonefile(zonefile, verbose=True):
continue continue
if not zf_line.strip() or zf_line.startswith(";"): if not zf_line.strip() or zf_line.startswith(";"):
continue continue
print(f"dns-checking {zf_line!r}") if verbose else log_progress("") print(f"dns-checking {zf_line!r}")
zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2) zf_domain, zf_typ, zf_value = zf_line.split(maxsplit=2)
zf_domain = zf_domain.rstrip(".") zf_domain = zf_domain.rstrip(".")
zf_value = zf_value.strip() zf_value = zf_value.strip()

View File

@@ -1,20 +1,10 @@
import sys from subprocess import CalledProcessError, check_output
from subprocess import DEVNULL, CalledProcessError, check_output
def log_progress(data): def shell(command, fail_ok=False):
sys.stderr.write(".")
sys.stderr.flush()
def shell(command, fail_ok=False, print=print):
print(f"$ {command}") print(f"$ {command}")
args = dict(shell=True)
if fail_ok:
args["stderr"] = DEVNULL
try: try:
return check_output(command, **args).decode().rstrip() return check_output(command, shell=True).decode().rstrip()
except CalledProcessError: except CalledProcessError:
if not fail_ok: if not fail_ok:
raise raise
@@ -24,22 +14,3 @@ def shell(command, fail_ok=False, print=print):
def get_systemd_running(): def get_systemd_running():
lines = shell("systemctl --type=service --state=running").split("\n") lines = shell("systemctl --type=service --state=running").split("\n")
return [line for line in lines if line.startswith(" ")] return [line for line in lines if line.startswith(" ")]
def write_numbytes(path, num):
with open(path, "w") as f:
f.write("x" * num)
def dovecot_recalc_quota(user):
shell(f"doveadm quota recalc -u {user}")
output = shell(f"doveadm quota get -u {user}")
#
# Quota name Type Value Limit %
# User quota STORAGE 5 102400 0
# User quota MESSAGE 2 - 0
#
for line in output.split("\n"):
parts = line.split()
if parts[2] == "STORAGE":
return dict(value=int(parts[3]), limit=int(parts[4]), percent=int(parts[5]))

View File

@@ -1,12 +0,0 @@
[Unit]
Description=Incoming Chatmail Postfix before queue filter
[Service]
ExecStart={execpath} {config_path} incoming
Restart=always
RestartSec=30
User=vmail
[Install]
WantedBy=multi-user.target

View File

@@ -1,11 +1,11 @@
[Unit] [Unit]
Description=Outgoing Chatmail Postfix before queue filter Description=Chatmail Postfix before queue filter
[Service] [Service]
ExecStart={execpath} {config_path} outgoing ExecStart={execpath} {config_path}
Restart=always Restart=always
RestartSec=30 RestartSec=30
User=vmail User=filtermail
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -1,16 +0,0 @@
[Unit]
Description=A wrapper for the TURN server
After=network.target
[Service]
Type=simple
Restart=always
ExecStart=/usr/local/bin/chatmail-turn --realm {mail_domain} --socket /run/chatmail-turn/turn.socket
# Create /run/chatmail-turn
RuntimeDirectory=chatmail-turn
User=vmail
Group=vmail
[Install]
WantedBy=multi-user.target

View File

@@ -42,7 +42,6 @@ def bootstrap_remote(gateway, remote=remote):
def print_stderr(item="", end="\n"): def print_stderr(item="", end="\n"):
print(item, file=sys.stderr, end=end) print(item, file=sys.stderr, end=end)
sys.stderr.flush()
class SSHExec: class SSHExec:
@@ -71,6 +70,10 @@ class SSHExec:
raise self.FuncError(data) raise self.FuncError(data)
def logged(self, call, kwargs): def logged(self, call, kwargs):
def log_progress(data):
sys.stderr.write(".")
sys.stderr.flush()
title = call.__doc__ title = call.__doc__
if not title: if not title:
title = call.__name__ title = call.__name__
@@ -79,22 +82,6 @@ class SSHExec:
return self(call, kwargs, log_callback=print_stderr) return self(call, kwargs, log_callback=print_stderr)
else: else:
print_stderr(title, end="") print_stderr(title, end="")
res = self(call, kwargs, log_callback=remote.rshell.log_progress) res = self(call, kwargs, log_callback=log_progress)
print_stderr() print_stderr()
return res return res
class LocalExec:
def __init__(self, verbose=False, docker=False):
self.verbose = verbose
self.docker = docker
def logged(self, call, kwargs: dict):
where = "locally"
if self.docker:
if call == remote.rdns.perform_initial_checks:
kwargs['pre_command'] = "docker exec chatmail "
where = "in docker"
if self.verbose:
print(f"Running {where}: {call.__name__}(**{kwargs})")
return call(**kwargs)

View File

@@ -37,7 +37,7 @@ class TestDC:
def test_ping_pong(self, benchmark, cmfactory): def test_ping_pong(self, benchmark, cmfactory):
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(ac1, ac2) chat = cmfactory.get_accepted_chat(ac1, ac2)
def dc_ping_pong(): def dc_ping_pong():
chat.send_text("ping") chat.send_text("ping")
@@ -49,7 +49,7 @@ class TestDC:
def test_send_10_receive_10(self, benchmark, cmfactory, lp): def test_send_10_receive_10(self, benchmark, cmfactory, lp):
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(ac1, ac2) chat = cmfactory.get_accepted_chat(ac1, ac2)
def dc_send_10_receive_10(): def dc_send_10_receive_10():
for i in range(10): for i in range(10):

View File

@@ -90,13 +90,8 @@ def test_concurrent_logins_same_account(
def test_no_vrfy(chatmail_config): def test_no_vrfy(chatmail_config):
domain = chatmail_config.mail_domain
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10) sock.connect((chatmail_config.mail_domain, 25))
try:
sock.connect((domain, 25))
except socket.timeout:
pytest.skip(f"port 25 not reachable for {domain}")
banner = sock.recv(1024) banner = sock.recv(1024)
print(banner) print(banner)
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),)) sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))

View File

@@ -1,8 +1,5 @@
import datetime import datetime
import smtplib import smtplib
import socket
import subprocess
import time
import pytest import pytest
@@ -32,8 +29,7 @@ class TestSSHExecutor:
) )
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert err.startswith("Collecting") assert err.startswith("Collecting")
# XXX could not figure out how capturing can be made to work properly assert err.endswith("....\n")
#assert err.endswith("....\n")
assert err.count("\n") == 1 assert err.count("\n") == 1
sshexec.verbose = True sshexec.verbose = True
@@ -42,8 +38,7 @@ class TestSSHExecutor:
) )
out, err = capsys.readouterr() out, err = capsys.readouterr()
lines = err.split("\n") lines = err.split("\n")
# XXX could not figure out how capturing can be made to work properly assert len(lines) > 4
#assert len(lines) > 4
assert remote.rdns.perform_initial_checks.__doc__ in lines[0] assert remote.rdns.perform_initial_checks.__doc__ in lines[0]
def test_exception(self, sshexec, capsys): def test_exception(self, sshexec, capsys):
@@ -60,20 +55,11 @@ class TestSSHExecutor:
def test_opendkim_restarted(self, sshexec): def test_opendkim_restarted(self, sshexec):
"""check that opendkim is not running for longer than a day.""" """check that opendkim is not running for longer than a day."""
cmd = "systemctl show opendkim --timestamp=utc --property=ActiveEnterTimestamp" out = sshexec(call=remote.rshell.shell, kwargs=dict(command="systemctl status opendkim"))
out = sshexec(call=remote.rshell.shell, kwargs=dict(command=cmd)) assert type(out) == str
datestring = out.split("=")[1] since_date_str = out.split("since ")[1].split(";")[0]
since_date = datetime.datetime.strptime(datestring, "%a %Y-%m-%d %H:%M:%S %Z") since_date = datetime.datetime.strptime(since_date_str, "%a %Y-%m-%d %H:%M:%S %Z")
now = datetime.datetime.now(since_date.tzinfo) assert (datetime.datetime.now() - since_date).total_seconds() < 60 * 60 * 24
assert (now - since_date).total_seconds() < 60 * 60 * 51
def test_timezone_env(remote):
for line in remote.iter_output("env"):
print(line)
if line == "tz=:/etc/localtime":
return
pytest.fail("TZ is not set")
def test_remote(remote, imap_or_smtp): def test_remote(remote, imap_or_smtp):
@@ -130,35 +116,14 @@ def test_authenticated_from(cmsetup, maildata):
@pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"]) @pytest.mark.parametrize("from_addr", ["fake@example.org", "fake@testrun.org"])
def test_reject_missing_dkim(cmsetup, maildata, from_addr): def test_reject_missing_dkim(cmsetup, maildata, from_addr):
domain = cmsetup.maildomain """Test that emails with missing or wrong DMARC, DKIM, and SPF entries are rejected."""
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
try:
sock.connect((domain, 25))
except socket.timeout:
pytest.skip(f"port 25 not reachable for {domain}")
recipient = cmsetup.gen_users(1)[0] recipient = cmsetup.gen_users(1)[0]
msg = maildata( msg = maildata("plain.eml", from_addr=from_addr, to_addr=recipient.addr).as_string()
"encrypted.eml", from_addr=from_addr, to_addr=recipient.addr with smtplib.SMTP(cmsetup.maildomain, 25) as s:
).as_string()
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10)
with conn as s:
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"): with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):
s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg) s.sendmail(from_addr=from_addr, to_addrs=recipient.addr, msg=msg)
def try_n_times(n, f):
for _ in range(n - 1):
try:
return f()
except Exception:
time.sleep(1)
return f()
def test_rewrite_subject(cmsetup, maildata): def test_rewrite_subject(cmsetup, maildata):
"""Test that subject gets replaced with [...].""" """Test that subject gets replaced with [...]."""
user1, user2 = cmsetup.gen_users(2) user1, user2 = cmsetup.gen_users(2)
@@ -171,8 +136,7 @@ def test_rewrite_subject(cmsetup, maildata):
).as_string() ).as_string()
user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user2.addr], msg=sent_msg) user1.smtp.sendmail(from_addr=user1.addr, to_addrs=[user2.addr], msg=sent_msg)
# The message may need some time to get delivered by postfix. messages = user2.imap.fetch_all_messages()
messages = try_n_times(5, user2.imap.fetch_all_messages)
assert len(messages) == 1 assert len(messages) == 1
rcvd_msg = messages[0] rcvd_msg = messages[0]
assert "Subject: [...]" not in sent_msg assert "Subject: [...]" not in sent_msg
@@ -213,31 +177,6 @@ def test_expunged(remote, chatmail_config):
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",
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",
] ]
outdated_days = int(chatmail_config.delete_large_after) + 1
find_cmds.append(
"find {chatmail_config.mailboxes_dir} -path '*/cur/*' -mtime +{outdated_days} -size +200k -type f"
)
for cmd in find_cmds: for cmd in find_cmds:
for line in remote.iter_output(cmd): for line in remote.iter_output(cmd):
assert not line assert not line
def test_deployed_state(remote):
try:
git_hash = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode()
except Exception:
git_hash = "unknown\n"
try:
git_diff = subprocess.check_output(["git", "diff"]).decode()
except Exception:
git_diff = ""
git_status = [git_hash.strip()]
for line in git_diff.splitlines():
git_status.append(line.strip().lower())
remote_version = []
for line in remote.iter_output("cat /etc/chatmail-version"):
print(line)
remote_version.append(line)
# assert len(git_status) == len(remote_version) # for some reason, we only get 11 lines from remote.iter_output()
for i in range(len(remote_version)):
assert git_status[i] == remote_version[i], "You have undeployed changes."

View File

@@ -1,4 +1,5 @@
import ipaddress import ipaddress
import random
import re import re
import time import time
@@ -6,9 +7,6 @@ import imap_tools
import pytest import pytest
import requests import requests
from cmdeploy.remote import rshell
from cmdeploy.sshexec import SSHExec
@pytest.fixture @pytest.fixture
def imap_mailbox(cmfactory): def imap_mailbox(cmfactory):
@@ -56,23 +54,22 @@ class TestEndToEndDeltaChat:
"""Test that a DC account can send a message to a second DC account """Test that a DC account can send a message to a second DC account
on the same chat-mail instance.""" on the same chat-mail instance."""
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(ac1, ac2) chat = cmfactory.get_accepted_chat(ac1, ac2)
lp.sec("ac1: prepare and send text message to ac2")
chat.send_text("message0") chat.send_text("message0")
lp.sec("wait for ac2 to receive message") lp.sec("wait for ac2 to receive message")
msg2 = ac2._evtracker.wait_next_incoming_message() msg2 = ac2._evtracker.wait_next_incoming_message()
assert msg2.text == "message0" assert msg2.text == "message0"
def test_exceed_quota( @pytest.mark.slow
self, cmfactory, lp, tmpdir, remote, chatmail_config, sshdomain def test_exceed_quota(self, cmfactory, lp, tmpdir, remote, chatmail_config):
):
"""This is a very slow test as it needs to upload >100MB of mail data """This is a very slow test as it needs to upload >100MB of mail data
before quota is exceeded, and thus depends on the speed of the upload. before quota is exceeded, and thus depends on the speed of the upload.
""" """
ac1, ac2 = cmfactory.get_online_accounts(2) ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(ac1, ac2) chat = cmfactory.get_accepted_chat(ac1, ac2)
user = ac2.get_config("configured_addr")
def parse_size_limit(limit: str) -> int: def parse_size_limit(limit: str) -> int:
"""Parse a size limit and return the number of bytes as integer. """Parse a size limit and return the number of bytes as integer.
@@ -85,27 +82,49 @@ class TestEndToEndDeltaChat:
return int(float(number) * units[unit]) return int(float(number) * units[unit])
quota = parse_size_limit(chatmail_config.max_mailbox_size) quota = parse_size_limit(chatmail_config.max_mailbox_size)
attachsize = 1 * 1024 * 1024
num_to_send = quota // attachsize + 2
lp.sec(f"ac1: send {num_to_send} large files to ac2")
lp.indent(f"per-user quota is assumed to be: {quota / (1024 * 1024)}MB")
alphanumeric = "abcdefghijklmnopqrstuvwxyz1234567890"
msgs = []
for i in range(num_to_send):
attachment = tmpdir / f"attachment{i}"
data = "".join(random.choice(alphanumeric) for i in range(1024))
with open(attachment, "w+") as f:
for j in range(attachsize // len(data)):
f.write(data)
lp.sec(f"filling remote inbox for {user}") msg = chat.send_file(str(attachment))
fn = f"7743102289.M843172P2484002.c20,S={quota},W=2398:2," msgs.append(msg)
path = chatmail_config.mailboxes_dir.joinpath(user, "cur", fn) lp.indent(f"Sent out msg {i}, size {attachsize / (1024 * 1024)}MB")
sshexec = SSHExec(sshdomain)
sshexec(call=rshell.write_numbytes, kwargs=dict(path=str(path), num=120))
res = sshexec(call=rshell.dovecot_recalc_quota, kwargs=dict(user=user))
assert res["percent"] >= 100
lp.sec("ac2: check quota is triggered") lp.sec("ac2: check messages are arriving until quota is reached")
starting = True 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 -n0 -f -u dovecot"):
if starting: if addr not in line:
chat.send_text("hello")
starting = False
if user not in line:
# print(line) # print(line)
continue continue
if "quota exceeded" in line: if "quota" in line:
return if "quota exceeded" in line:
if saved_ok < num_to_send // 2:
pytest.fail(
f"quota exceeded too early: after {saved_ok} messages already"
)
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
):
saved_ok += 1
print(f"{saved_ok}: {line}")
if saved_ok >= num_to_send:
break
pytest.fail("sending succeeded although messages should exceed quota")
def test_securejoin(self, cmfactory, lp, maildomain2): def test_securejoin(self, cmfactory, lp, maildomain2):
ac1 = cmfactory.new_online_configuring_account(cache=False) ac1 = cmfactory.new_online_configuring_account(cache=False)
@@ -153,7 +172,7 @@ def test_hide_senders_ip_address(cmfactory):
assert ipaddress.ip_address(public_ip) assert ipaddress.ip_address(public_ip)
user1, user2 = cmfactory.get_online_accounts(2) user1, user2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(user1, user2) chat = cmfactory.get_accepted_chat(user1, user2)
chat.send_text("testing submission header cleanup") chat.send_text("testing submission header cleanup")
user2._evtracker.wait_next_incoming_message() user2._evtracker.wait_next_incoming_message()
@@ -162,18 +181,11 @@ def test_hide_senders_ip_address(cmfactory):
assert public_ip not in msg.obj.as_string() assert public_ip not in msg.obj.as_string()
def test_echobot(cmfactory, chatmail_config, lp, sshdomain): def test_echobot(cmfactory, chatmail_config, lp):
ac = cmfactory.get_online_accounts(1)[0] ac = cmfactory.get_online_accounts(1)[0]
# establish contact with echobot lp.sec(f"Send message to echo@{chatmail_config.mail_domain}")
sshexec = SSHExec(sshdomain) chat = ac.create_chat(f"echo@{chatmail_config.mail_domain}")
command = "cat /var/lib/echobot/invite-link.txt"
echo_invite_link = sshexec(call=rshell.shell, kwargs=dict(command=command))
chat = ac.qr_setup_contact(echo_invite_link)
ac._evtracker.wait_securejoin_joiner_progress(1000)
# send message and check it gets replied back
lp.sec("Send message to echobot")
text = "hi, I hope you text me back" text = "hi, I hope you text me back"
chat.send_text(text) chat.send_text(text)
lp.sec("Wait for reply from echobot") lp.sec("Wait for reply from echobot")

View File

@@ -62,7 +62,7 @@ def sshdomain(maildomain):
def maildomain2(): def maildomain2():
domain = os.environ.get("CHATMAIL_DOMAIN2") domain = os.environ.get("CHATMAIL_DOMAIN2")
if not domain: if not domain:
pytest.skip("set CHATMAIL_DOMAIN2 to a second chatmail server") pytest.skip("set CHATMAIL_DOMAIN2 to a ssh-reachable chatmail instance")
return domain return domain
@@ -302,13 +302,10 @@ def cmfactory(request, gencreds, tmpdir, maildomain):
pytest.importorskip("deltachat") pytest.importorskip("deltachat")
from deltachat.testplugin import ACFactory from deltachat.testplugin import ACFactory
data = request.getfixturevalue("data")
testproc = ChatmailTestProcess(request.config, maildomain, gencreds) testproc = ChatmailTestProcess(request.config, maildomain, gencreds)
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=data)
class Data:
def read_path(self, path):
return
am = ACFactory(request=request, tmpdir=tmpdir, testprocess=testproc, data=Data())
# nb. a bit hacky # nb. a bit hacky
# would probably be better if deltachat's test machinery grows native support # would probably be better if deltachat's test machinery grows native support

View File

@@ -1,10 +1,8 @@
import importlib
import os import os
import pytest import pytest
from cmdeploy.cmdeploy import get_parser, main from cmdeploy.cmdeploy import get_parser, main
from cmdeploy.www import get_paths
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -26,36 +24,6 @@ class TestCmdline:
def test_init_not_overwrite(self, capsys): def test_init_not_overwrite(self, capsys):
assert main(["init", "chat.example.org"]) == 0 assert main(["init", "chat.example.org"]) == 0
capsys.readouterr() capsys.readouterr()
assert main(["init", "chat.example.org"]) == 1 assert main(["init", "chat.example.org"]) == 1
out, err = capsys.readouterr() out, err = capsys.readouterr()
assert "path exists" in out.lower() assert "path exists" in out.lower()
assert main(["init", "chat.example.org", "--force"]) == 0
out, err = capsys.readouterr()
assert "deleting config file" in out.lower()
def test_www_folder(example_config, tmp_path):
reporoot = importlib.resources.files(__package__).joinpath("../../../../").resolve()
assert not example_config.www_folder
www_path, src_dir, build_dir = get_paths(example_config)
assert www_path.absolute() == reporoot.joinpath("www").absolute()
assert src_dir == reporoot.joinpath("www").joinpath("src")
assert build_dir == reporoot.joinpath("www").joinpath("build")
example_config.www_folder = "disabled"
www_path, _, _ = get_paths(example_config)
assert not www_path.is_dir()
example_config.www_folder = str(tmp_path)
www_path, src_dir, build_dir = get_paths(example_config)
assert www_path == tmp_path
assert not src_dir.exists()
assert not build_dir
src_path = tmp_path.joinpath("src")
os.mkdir(src_path)
with open(src_path / "index.md", "w") as f:
f.write("# Test")
www_path, src_dir, build_dir = get_paths(example_config)
assert www_path == tmp_path
assert src_dir == src_path
assert build_dir == tmp_path.joinpath("build")

View File

@@ -89,14 +89,18 @@ class TestZonefileChecks:
def test_check_zonefile_all_ok(self, cm_data, mockdns_base): def test_check_zonefile_all_ok(self, cm_data, mockdns_base):
zonefile = cm_data.get("zftest.zone") zonefile = cm_data.get("zftest.zone")
parse_zonefile_into_dict(zonefile, mockdns_base) parse_zonefile_into_dict(zonefile, mockdns_base)
required_diff, recommended_diff = remote.rdns.check_zonefile(zonefile) required_diff, recommended_diff = remote.rdns.check_zonefile(
zonefile, "some.domain"
)
assert not required_diff and not recommended_diff assert not required_diff and not recommended_diff
def test_check_zonefile_recommended_not_set(self, cm_data, mockdns_base): def test_check_zonefile_recommended_not_set(self, cm_data, mockdns_base):
zonefile = cm_data.get("zftest.zone") zonefile = cm_data.get("zftest.zone")
zonefile_mocked = zonefile.split("; Recommended")[0] zonefile_mocked = zonefile.split("; Recommended")[0]
parse_zonefile_into_dict(zonefile_mocked, mockdns_base) parse_zonefile_into_dict(zonefile_mocked, mockdns_base)
required_diff, recommended_diff = remote.rdns.check_zonefile(zonefile) required_diff, recommended_diff = remote.rdns.check_zonefile(
zonefile, "some.domain"
)
assert not required_diff assert not required_diff
assert len(recommended_diff) == 8 assert len(recommended_diff) == 8

View File

@@ -3,7 +3,6 @@ import importlib.resources
import time import time
import traceback import traceback
import webbrowser import webbrowser
from pathlib import Path
import markdown import markdown
from chatmaild.config import read_config from chatmaild.config import read_config
@@ -31,25 +30,9 @@ def prepare_template(source):
return render_vars, page_layout return render_vars, page_layout
def get_paths(config) -> (Path, Path, Path): def build_webpages(src_dir, build_dir, config):
reporoot = importlib.resources.files(__package__).joinpath("../../../").resolve()
www_path = Path(config.www_folder)
# if www_folder was not set, use default directory
if config.www_folder == "":
www_path = reporoot.joinpath("www")
src_dir = www_path.joinpath("src")
# if www_folder is a hugo page, build it
if src_dir.joinpath("index.md").is_file():
build_dir = www_path.joinpath("build")
# if it is not a hugo page, upload it as is
else:
build_dir = None
return www_path, src_dir, build_dir
def build_webpages(src_dir, build_dir, config) -> Path:
try: try:
return _build_webpages(src_dir, build_dir, config) _build_webpages(src_dir, build_dir, config)
except Exception: except Exception:
print(traceback.format_exc()) print(traceback.format_exc())
@@ -123,11 +106,15 @@ def main():
config = read_config(inipath) config = read_config(inipath)
config.webdev = True config.webdev = True
assert config.mail_domain assert config.mail_domain
www_path = reporoot.joinpath("www")
src_path = www_path.joinpath("src")
stats = None
build_dir = www_path.joinpath("build")
src_dir = www_path.joinpath("src")
index_path = build_dir.joinpath("index.html")
# start web page generation, open a browser and wait for changes # start web page generation, open a browser and wait for changes
www_path, src_path, build_dir = get_paths(config) build_webpages(src_dir, build_dir, config)
build_dir = build_webpages(src_path, build_dir, config)
index_path = build_dir.joinpath("index.html")
webbrowser.open(str(index_path)) webbrowser.open(str(index_path))
stats = snapshot_dir_stats(src_path) stats = snapshot_dir_stats(src_path)
print(f"\nOpened URL: file://{index_path.resolve()}\n") print(f"\nOpened URL: file://{index_path.resolve()}\n")
@@ -148,7 +135,7 @@ def main():
changenum += 1 changenum += 1
stats = newstats stats = newstats
build_webpages(src_path, build_dir, config) build_webpages(src_dir, build_dir, config)
print(f"[{changenum}] regenerated web pages at: {index_path}") print(f"[{changenum}] regenerated web pages at: {index_path}")
print(f"URL: file://{index_path.resolve()}\n\n") print(f"URL: file://{index_path.resolve()}\n\n")
count = 0 count = 0

View File

@@ -1,23 +1,5 @@
#!/bin/sh #!/bin/sh
set -e set -e
if command -v lsb_release 2>&1 >/dev/null; then
case "$(lsb_release -is)" in
Ubuntu | Debian )
if ! dpkg -l | grep python3-dev 2>&1 >/dev/null
then
echo "You need to install python3-dev for installing the other dependencies."
exit 1
fi
if ! gcc --version 2>&1 >/dev/null
then
echo "You need to install gcc for building Python dependencies."
exit 1
fi
;;
esac
fi
python3 -m venv --upgrade-deps venv python3 -m venv --upgrade-deps venv
venv/bin/pip install -e chatmaild venv/bin/pip install -e chatmaild

View File

@@ -6,6 +6,29 @@ interoperable e-mail service for everyone. What's behind a `chatmail` is
effectively a normal e-mail address just like any other but optimized effectively a normal e-mail address just like any other but optimized
for the usage in chats, especially DeltaChat. 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 `Create a profile` then `Use other server` and choose `Classic e-mail login`. Here fill the two fields like this:
- `E-Mail Address`: invent a word with
{% if username_min_length == username_max_length %}
*exactly* {{ username_min_length }}
{% else %}
{{ username_min_length}}
{% if username_max_length == "more" %}
or more
{% else %}
to {{ username_max_length }}
{% endif %}
{% endif %}
characters
and append `@{{config.mail_domain}}` to it.
- `Existing Password`: invent at least {{ password_min_length }} characters.
If the e-mail address is not yet taken, you'll get that account.
The first login sets your password.
### Rate and storage limits ### Rate and storage limits
@@ -15,10 +38,9 @@ for the usage in chats, especially DeltaChat.
- You may send up to {{ config.max_user_send_per_minute }} messages per minute. - You may send up to {{ config.max_user_send_per_minute }} messages per minute.
- 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). - Messages are unconditionally removed {{ config.delete_mails_after }} days after arriving on the server.
- Messages are unconditionally removed latest {{ config.delete_mails_after }} days 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).
Earlier, if storage may exceed otherwise.
### <a name="account-deletion"></a> Account deletion ### <a name="account-deletion"></a> Account deletion