Compare commits

...

60 Commits

Author SHA1 Message Date
holger krekel
a77f5c5f42 use a Deployer for setting the remote git hash 2025-11-10 18:08:59 +01:00
holger krekel
84b970f1ea strike unneccessary *,** argument flexibility 2025-11-10 18:04:07 +01:00
holger krekel
1f4ba7bf43 remove static method and Make Deployer instances not set any default state 2025-11-10 18:01:01 +01:00
holger krekel
5de5ba46ad now that Deployer class is clean and not mixed with what is in Deployment, use the simpler "install", "configure" and "activate" namings instead of *_impl 2025-11-10 17:47:52 +01:00
holger krekel
337e045284 further reduce indirections for staged install 2025-11-10 17:41:47 +01:00
holger krekel
b60bc1862a simplify required_users configuration (a method is not needed for now) 2025-11-10 17:24:58 +01:00
holger krekel
579e7c767d remove indirection with "stages" 2025-11-10 17:23:53 +01:00
holger krekel
a735543c80 strike unnccessary deployer variables 2025-11-10 17:23:49 +01:00
cliffmccarthy
948efff70d refactor: Move doveauth out of ChatmailVenvDeployer
- Revised DovecotDeployer to use _configure_remote_units() and
  _activate_remote_units() to deploy doveauth.  This keeps the
  Dovecot-related services in a single deployer class, leaving only
  services that are part of the chatmail project in
  ChatmailVenvDeployer.
- Removed doveauth from the unit list in ChatmailVenvDeployer.
2025-11-08 10:16:35 -06:00
cliffmccarthy
9945402d4c refactor: Move echobot out of ChatmailVenvDeployer
- Revised EchobotDeployer to use _configure_remote_units() and
  _activate_remote_units().  The 'activate' stage of
  ChatmailVenvDeployer was unconditionally restarting the service
  every time, so EchobotDeployer no longer needs to depend on the
  was_restarted attributes of the postfix and dovecot deployers in an
  attempt to avoid restarting it; we can just handle the unconditional
  restart in EchobotDeployer.activate_impl().
- Removed echobot from the unit list in ChatmailVenvDeployer.
- Removed now-unused was_restarted attribute from PostfixDeployer and
  DovecotDeployer.
2025-11-08 10:16:35 -06:00
cliffmccarthy
b39c27c5cc refactor: Move turnserver out of ChatmailVenvDeployer
- Revised TurnDeployer to use _configure_remote_units() and
  _activate_remote_units().  This class no longer uses need_restart
  and daemon_reload attributes to keep track of state.  The activate
  stage of ChatmailVenvDeployer was unconditionally restarting the
  service every time, so we don't need to keep track of extra state in
  an attempt to avoid restarting it; we can just handle the
  unconditional restart in TurnDeployer.activate_impl().
- Removed turnserver from the unit list in ChatmailVenvDeployer.
2025-11-08 10:16:35 -06:00
cliffmccarthy
38ba28be8a refactor: Move unit list to ChatmailVenvDeployer
- Split _configure_remote_venv_with_chatmaild() into two functions.
  _configure_remote_venv_with_chatmaild() handles details specific to
  the "venv", while the new _configure_remote_units() is a more
  general function that is applicable to several services.
- Renamed _activate_remote_venv_with_chatmaild() to
  _activate_remote_units() because doesn't have anything
  venv-specific.
- Removed list of units from helper functions (where it appeared
  twice); moved it to ChatmailVenvDeployer, where its is passed as an
  argument to _configure_remote_units() and _activate_remote_units().
2025-11-08 10:16:35 -06:00
cliffmccarthy
1aa4896260 chore: Add CHANGELOG.md entry for cmdeploy refactor 2025-11-08 10:16:35 -06:00
cliffmccarthy
823bf88c74 docs: Change wording in ARCHITECTURE.md
- Changed "server" to "relay".
- Suggested in review by missytake.
2025-11-08 10:16:35 -06:00
cliffmccarthy
78b326dbd1 feat: Reorder deployers
- Moved fcgiwrap before nginx.
- Exchanged order of turn and unbound.
- Moved journald as early as possible.
- Suggested in review by missytake.
2025-11-08 10:16:35 -06:00
cliffmccarthy
e708027edb feat: Remove obs-home-deltachat.gpg
- We don't install Dovecot from OBS anymore.
- Removed files.put() that creates
  /etc/apt/keyrings/obs-home-deltachat.gpg; replaced this with a
  files.file() that sets present=False to remove the file from any
  existing installations where it already has been installed.
- Removed now-unused obs-home-deltachat.gpg file.
- Clarified description of sources.list operation.
- Suggested in review by missytake and hpk42.
2025-11-08 10:16:35 -06:00
cliffmccarthy
27b72039c8 fix: Block unbound from starting up on install
- On an IPv4-only system, if unbound is started but not configured, it
  causes subsequent steps to fail to resolve hosts.
- Revised UnboundDeployer.install_impl() to use policy-rc.d to prevent
  the service from starting when installed.  This is the same
  mechanism used to keep nginx from starting on install.
2025-11-08 10:16:35 -06:00
cliffmccarthy
32534251bf docs: Add architectural information about deployer classes
- Updated ARCHITECTURE.md to describe the Deployer class hierarchy and
  the motivations behind it.
2025-11-08 10:16:35 -06:00
cliffmccarthy
1a03d56c80 refactor: Call install, configure, and activate methods in loops
- Revised deploy_chatmail() to use all_deployers to call the
  install(), configure(), and activate() methods on all the deployers,
  rather than listing them explicitly in the code.
2025-11-08 10:16:35 -06:00
cliffmccarthy
e043c1baff refactor: Reorder deploy_chatmail()
- The previous commits that added Deployer classes mostly kept
  deployment operations in the same order that they were in before.
  To organize the process into separate stages for install, configure,
  and activate, we need to reorder the method calls.  This is the
  commit that does that, and thus this is the commit that has the
  largest effect on the order of operations.
- The calls for the deployer objects are all reordered here so that
  the methods are called in the same sequence for each stage.  This
  will allow us to collect the calls into loops in the next commit.
  This commit provides a way to see a diff showing exactly how the
  sequence changed.
- The sequence of deployers was largely based on preserving the order
  of the "activate" stage, as this seems like the place order might be
  the most likely to matter.  Installation of packages and
  configuration of files should generally be able to run in any order.
  (ChatmailDeployer handles updating the apt data, and therefore needs
  to be first, however.)
2025-11-08 10:16:35 -06:00
cliffmccarthy
a9d3709663 refactor: Move curl installation from IrohDeployer to ChatmailDeployer
- The 'curl' program is used in TurnDeployer and IrohDeployer, so it
  makes more sense to install it at the beginning in ChatmailDeployer,
  rather than have each thing that uses it install it separately.
2025-11-08 10:16:35 -06:00
cliffmccarthy
741948682f refactor: Add TurnDeployer
- This splits the existing deploy_turn_server() routine into methods
  for the install, configure, and activate stages.
2025-11-08 10:16:35 -06:00
cliffmccarthy
68b78bf6d2 refactor: Add WebsiteDeployer
- This adds a step to create /var/www in the install stage, because
  the directory needs to exist for the rsync in the configure stage to
  work.
2025-11-08 10:16:35 -06:00
cliffmccarthy
0e756c653d refactor: Add EchobotDeployer
- This class is a special case because it has a dependency on the
  Postfix and Dovecot deployers.  When deciding whether to restart the
  echobot service, it needs to know whether the Postfix and Dovecot
  deployers restarted their services.  To support this dependency, the
  PostfixDeployer and DovecotDeployer objects are passed to the
  EchobotDeployer object, so it can check their was_restarted
  attributes.
2025-11-08 10:16:35 -06:00
cliffmccarthy
69de709b0f refactor: Add FcgiwrapDeployer 2025-11-08 10:16:35 -06:00
cliffmccarthy
d11163629b refactor: Add ChatmailDeployer
- This moves the installation of cron earlier in the deployment sequence.
2025-11-08 10:16:35 -06:00
cliffmccarthy
e92c8766f6 refactor: Add ChatmailVenvDeployer 2025-11-08 10:16:35 -06:00
cliffmccarthy
334e468889 refactor: Split _install_remote_venv_with_chatmaild into stages
- Split _install_remote_venv_with_chatmaild() into three routines, to
  handle the install, configure, and activate stages.
- This moves the upload of chatmail.ini later in the deployment
  process, because it is a configuration file specific to the
  instance, not software installation that would be uniform across all
  deployments.
2025-11-08 10:16:35 -06:00
cliffmccarthy
33e6807ca4 refactor: Add RspamdDeployer
- This replaces the existing _remove_rspamd() routine with a method
  for the install stage.
2025-11-08 10:16:35 -06:00
cliffmccarthy
2029acc5a9 refactor: Add MtastsDeployer
- This splits the existing _uninstall_mta_sts_daemon() routine into
  methods for the configure and activate stages.
2025-11-08 10:16:35 -06:00
cliffmccarthy
d1788b7c65 refactor: Add MtailDeployer
- This splits the existing deploy_mtail() routine into methods for the
  install, configure, and activate stages.
2025-11-08 10:16:35 -06:00
cliffmccarthy
84ab4bb6b8 refactor: Add AcmetoolDeployer
- This splits the existing deploy_acmetool() routine into methods for
  the install, configure, and activate stages.
2025-11-08 10:16:35 -06:00
cliffmccarthy
04451ad537 refactor: Add JournaldDeployer 2025-11-08 10:16:35 -06:00
cliffmccarthy
d09a118e54 refactor: Add IrohDeployer
- This splits the existing deploy_iroh_relay() routine into methods
  for the install, configure, and activate stages.
2025-11-08 10:16:35 -06:00
cliffmccarthy
5b982a3b0f refactor: Add UnboundDeployer 2025-11-08 10:16:35 -06:00
cliffmccarthy
71636b8250 refactor: Add OpendkimDeployer
- Note that this moves the installation of the opendkim package
  earlier in the deployment sequence.  Previously, it was installed
  during the _configure_opendkim() routine.
2025-11-08 10:16:35 -06:00
cliffmccarthy
e7df1a43a3 refactor: Add NginxDeployer
- Use policy-rc.d during nginx install.  This is needed to keep nginx
  from starting up and interfering with acmetool.  For more information see:
    - https://serverfault.com/questions/861583/how-to-stop-nginx-from-being-automatically-started-on-install
    - https://major.io/p/install-debian-packages-without-starting-daemons/
    - https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt
2025-11-08 10:16:35 -06:00
cliffmccarthy
cfc94a37b3 refactor: Add PostfixDeployer
- Removed now-unused 'debug' variable from deploy_chatmail().
2025-11-08 10:16:35 -06:00
cliffmccarthy
22b77168ed refactor: Add DovecotDeployer 2025-11-08 10:16:35 -06:00
cliffmccarthy
ffcd657a88 refactor: Add Deployer base class
- Added a Deployer class that defines the base for objects that will
  handle installation of individual components, with install,
  configure, and activate stages.  Subclasses will override the
  implementation methods of those stages as needed, while the base
  class handles all the logic of deciding which stages to execute.
- The CMDEPLOY_STAGES environment variable is used to determine what
  stages to run.  If this is not defined, all stages run as usual.
- Added import of Deployer to cmdeploy/__init__.py.  This is not yet
  used, but the next series of commits will use it.
- In deploy_chatmail(), define an empty list of deployers, and call
  the create_groups() and create_users() methods for the items in the
  list.  This list will get filled with Deployer objects in the next
  series of commits.
2025-11-08 10:16:35 -06:00
cliffmccarthy
39fd04473c refactor: Move addition of 9.9.9.9 resolver earlier
- Moved the "Add 9.9.9.9 to resolv.conf" step earlier, before the
  creation of users or updates to any config files.  This should not
  affect any of those operations.  Moving this step earlier makes it
  easier to accommodate the restructuring of the deployment process
  into separate components with separate stages for install,
  configure, and activate.
2025-11-08 10:16:35 -06:00
cliffmccarthy
49613f7e71 refactor: Move all imports to top of cmdeploy/__init__.py 2025-11-08 10:16:35 -06:00
missytake
ded9dd470d www: add changelog 2025-11-06 16:19:02 +01:00
Alexander
b94ad729fd Update cmdeploy/src/cmdeploy/__init__.py
Co-authored-by: missytake <missytake@systemli.org>
2025-11-06 16:17:12 +01:00
Alexander Dietrich
b60267f37f Skip www_folder if merge conflict marker found 2025-11-06 16:17:12 +01:00
missytake
a0aa2912dd ci: fix test methods for deltachat 2.23.0 2025-11-06 12:33:59 +01:00
Serge Matveenko
76108c1c03 Test dig output with dns comments 2025-11-06 11:26:02 +01:00
Serge Matveenko
61b8dc4637 Improve dns responses parsing 2025-11-06 11:26:02 +01:00
Lars-Dominik Braun
d42f579291 turnserver: Strip newline from response. 2025-11-03 22:57:43 +00:00
Serge Matveenko
dd3cf4d449 Update dovecot-core deb sha256 sums 2025-10-30 11:23:19 +01:00
holger krekel
7361cc9350 fix changelog references 2025-10-29 13:33:25 +01:00
missytake
00f199816d unpublish mutual help group invite link 2025-10-28 16:12:07 +01:00
link2xt
8d7e1dad0e Require STARTTLS for incoming port 25 connections
We already require that outgoing connections
use STARTTLS so other servers need a valid TLS
certificate to accept messages from us.
It is then very unlikely that they cannot use TLS
to send messages to us.

Conversely, if they only can send messages to use without TLS,
it likely does not have STARTLS on its port 25
and then we don't want to accept messages from them
because we will likely not be able to reply.
2025-10-28 01:44:14 +00:00
link2xt
c0da7bb3bf docs: chatmail-turn listens on 3478 UDP, not TCP port 2025-10-28 01:08:06 +00:00
holger krekel
863ded6480 try to limit index cache max size 2025-10-28 01:42:37 +01:00
missytake
d75321b355 doc: write down some basic infos on chatmail-turn (#693)
Co-authored-by: l <link2xt@testrun.org>
2025-10-27 09:00:07 +01:00
link2xt
9148b16d81 acmetool: use ECDSA keys instead of RSA 2025-10-25 08:00:31 +00:00
holger krekel
fa9aa5b015 guard expire/fsreport file iteration against vanishing, improve reporting
also activates actual deletion (after quite some dry test runs on nine)
2025-10-22 20:30:12 +02:00
link2xt
0155f32df6 Require TLS 1.2 for outgoing SMTP connections 2025-10-22 02:46:29 +00:00
holger krekel
9ddd5d8b2b Replace expiry "find" commands with a new chatmaild.expire python module + a reporting one 2025-10-21 20:50:46 +00:00
32 changed files with 1519 additions and 565 deletions

View File

@@ -1,5 +1 @@
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

@@ -1,4 +1,6 @@
This diagram shows components of the chatmail server; this is a draft
## Chatmail Relay
This diagram shows components of the chatmail relay; this is a draft
overview as of mid-August 2025:
```mermaid
@@ -19,7 +21,6 @@ graph LR;
/var/lib/acme`")] --> nginx-internal;
cron --- chatmail-metrics;
cron --- acmetool;
cron --- expunge;
chatmail-metrics --- website;
acmetool --> certs[("`TLS certs
/var/lib/acme`")];
@@ -35,7 +36,8 @@ graph LR;
dovecot --- users;
dovecot --- |metadata.socket|chatmail-metadata;
doveauth --- users;
expunge --- users;
chatmail-expire-daily --- users;
chatmail-fsreport-daily --- users;
chatmail-metadata --- iroh-relay;
certs-nginx --> postfix;
certs-nginx --> dovecot;
@@ -48,3 +50,66 @@ graph LR;
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.
## cmdeploy
cmdeploy is a Python program that uses the pyinfra library to deploy
chatmail servers, with all the necessary software, configuration, and
services. The deployment process performs three primary types of operation:
1. Installation of software, universal across all deployments.
2. Configuration of software, with deploy-specific variations.
3. Activation of services.
The process is implemented through a family of "deployer" objects
which all derive from a common `Deployer` base class, defined in
[deployer.py](cmdeploy/src/cmdeploy/deployer.py). Each object
provides implementation methods for the three stages -- install,
configure, and activate. The top-level procedure in
`deploy_chatmail()` calls these methods for all the deployer objects,
first calling all the install methods, then the configure methods,
then the activate methods.
The base class also implements support for a CMDEPLOY_STAGES
environment variable, which allows limiting the process to specific
stages. Note that some deployers are stateful between the stages
(this is one reason why they are implemented as objects), and that
state will not get propagated between stages when run in separate
invocations of cmdeploy. This environment variable is intended for
use in future revisions to support building Docker images with
software pre-installed, and configuration of containers at run time
from environmnet variables.
The `install_impl()` method for the deployer classes is static, to
ensure that it does not rely on any object state, in particular, the
configuration details of the deployment. This helps ensure that all
install methods are suitable for running as part of a container image
build.
Operations that start services for systemd-based deployments should
only be called from the `activate_impl()` methods. These methods will
not be called in non-systemd container environments.
### Deployer objects
One might ask why the deployers are implemented as object classes, as
opposed to callable functions or the like. There are various reasons
why objects are a good fit for the deployment process.
1. Objects provide a way to organize the install, configure, and
deploy operations for each component that is installed, supporting a
"driver" type of pattern. This could be implemented in other ways
without objects, such as function jump tables, but objects provide a
clean and formalized way to do essentially the same thing.
2. Class inheritance provides a natural way to define
component-specific operations for the various stages of deployment, by
overriding the no-op stub methods in the base class. The base class
handles policy decisions about which stages are to be executed,
ensuring consistent handling of the stages in a central location.
3. Some of the components track state between stages, basing decisions
like whether to restart a service on whether the software or
configuration of that service was changed in an earlier stage.
Keeping track of state between method calls is an ideal use case for
objects.

View File

@@ -2,6 +2,21 @@
## untagged
- Organized cmdeploy into install, configure, and activate stages
([#695](https://github.com/chatmail/relay/pull/695))
- don't deploy the website if there are merge conflicts in the www folder
([#714](https://github.com/chatmail/relay/pull/714))
- acmetool: use ECDSA keys instead of RSA
([#689](https://github.com/chatmail/relay/pull/689))
- Require TLS 1.2 for outgoing SMTP connections
([#685](https://github.com/chatmail/relay/pull/685))
- require STARTTLS for incoming port 25 connections
([#684](https://github.com/chatmail/relay/pull/684))
- filtermail: run CPU-intensive handle_DATA in a thread pool executor
([#676](https://github.com/chatmail/relay/pull/676))
@@ -21,7 +36,7 @@
([#650](https://github.com/chatmail/relay/pull/650))
- filtermail: accept mails from Protonmail
([#616](https://github.com/chatmail/relay/pull/655))
([#616](https://github.com/chatmail/relay/pull/616))
- Ignore all RCPT TO: parameters
([#651](https://github.com/chatmail/relay/pull/651))
@@ -50,6 +65,13 @@
- Add `--skip-dns-check` argument to `cmdeploy run` command, which disables DNS record checking before installation.
([#661](https://github.com/chatmail/relay/pull/661))
- Rework expiry of message files and mailboxes in Python
to only do a single iteration over sometimes millions of messages
instead of doing "find" commands that iterate 9 times over the messages.
Provide an "fsreport" CLI for more fine grained analysis of message files.
([#637](https://github.com/chatmail/relay/pull/637))
## 1.7.0 2025-09-11
- Make www upload path configurable

View File

@@ -180,6 +180,10 @@ The components of chatmail are:
- [Iroh relay](https://www.iroh.computer/docs/concepts/relay)
which helps client devices to establish Peer-to-Peer connections
- [TURN](https://github.com/chatmail/chatmail-turn)
to enable relay users to start webRTC calls
even if a p2p connection can't be established
- and the chatmaild services, explained in the next section:
### chatmaild
@@ -304,6 +308,8 @@ Chatmail address creation will be denied while this file is present.
[Nginx](https://www.nginx.com/) listens on port 8443 (HTTPS-ALT) and 443 (HTTPS).
Port 443 multiplexes HTTPS, IMAP and SMTP using ALPN to redirect connections to ports 8443, 465 or 993.
[acmetool](https://hlandau.github.io/acmetool/) listens on port 80 (HTTP).
[chatmail-turn](https://github.com/chatmail/chatmail-turn) listens on UDP port 3478 (STUN/TURN),
and temporarily opens UDP ports when users request them. UDP port range is not restricted, any free port may be allocated.
chatmail-core based 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.

View File

@@ -27,7 +27,8 @@ chatmail-metadata = "chatmaild.metadata:main"
filtermail = "chatmaild.filtermail:main"
echobot = "chatmaild.echo:main"
chatmail-metrics = "chatmaild.metrics:main"
delete_inactive_users = "chatmaild.delete_inactive_users:main"
chatmail-expire = "chatmaild.expire:main"
chatmail-fsreport = "chatmaild.fsreport:main"
lastlogin = "chatmaild.lastlogin:main"
turnserver = "chatmaild.turnserver:main"

View File

@@ -1,31 +0,0 @@
"""
Remove inactive users
"""
import os
import shutil
import sys
import time
from .config import read_config
def delete_inactive_users(config):
cutoff_date = time.time() - config.delete_inactive_users_after * 86400
for addr in os.listdir(config.mailboxes_dir):
try:
user = config.get_user(addr)
except ValueError:
continue
read_timestamp = user.get_last_login_timestamp()
if read_timestamp and read_timestamp < cutoff_date:
path = config.mailboxes_dir.joinpath(addr)
assert path == user.maildir
shutil.rmtree(path, ignore_errors=True)
def main():
(cfgpath,) = sys.argv[1:]
config = read_config(cfgpath)
delete_inactive_users(config)

View File

@@ -0,0 +1,218 @@
"""
Expire old messages and addresses.
"""
import os
import shutil
import sys
import time
from argparse import ArgumentParser
from collections import namedtuple
from datetime import datetime
from stat import S_ISREG
from chatmaild.config import read_config
FileEntry = namedtuple("FileEntry", ("relpath", "mtime", "size"))
def iter_mailboxes(basedir, maxnum):
if not os.path.exists(basedir):
print_info(f"no mailboxes found at: {basedir}")
return
for name in os_listdir_if_exists(basedir)[:maxnum]:
if "@" in name:
yield MailboxStat(basedir + "/" + name)
def get_file_entry(path):
"""return a FileEntry or None if the path does not exist or is not a regular file."""
try:
st = os.stat(path)
except FileNotFoundError:
return None
if not S_ISREG(st.st_mode):
return None
return FileEntry(path, st.st_mtime, st.st_size)
def os_listdir_if_exists(path):
"""return a list of names obtained from os.listdir or an empty list if the path does not exist."""
try:
return os.listdir(path)
except FileNotFoundError:
return []
class MailboxStat:
last_login = None
def __init__(self, basedir):
self.basedir = str(basedir)
# all detected messages in cur/new/tmp folders
self.messages = []
# all detected files in mailbox top dir
self.extrafiles = []
# scan all relevant files (without recursion)
old_cwd = os.getcwd()
try:
os.chdir(self.basedir)
except FileNotFoundError:
return
for name in os_listdir_if_exists("."):
if name in ("cur", "new", "tmp"):
for msg_name in os_listdir_if_exists(name):
entry = get_file_entry(f"{name}/{msg_name}")
if entry is not None:
self.messages.append(entry)
else:
entry = get_file_entry(name)
if entry is not None:
self.extrafiles.append(entry)
if name == "password":
self.last_login = entry.mtime
self.extrafiles.sort(key=lambda x: -x.size)
os.chdir(old_cwd)
def print_info(msg):
print(msg, file=sys.stderr)
class Expiry:
def __init__(self, config, dry, now, verbose):
self.config = config
self.dry = dry
self.now = now
self.verbose = verbose
self.del_mboxes = 0
self.all_mboxes = 0
self.del_files = 0
self.all_files = 0
self.start = time.time()
def remove_mailbox(self, mboxdir):
if self.verbose:
print_info(f"removing {mboxdir}")
if not self.dry:
shutil.rmtree(mboxdir)
self.del_mboxes += 1
def remove_file(self, path, mtime=None):
if self.verbose:
if mtime is not None:
date = datetime.fromtimestamp(mtime).strftime("%b %d")
print_info(f"removing {date} {path}")
else:
print_info(f"removing {path}")
if not self.dry:
try:
os.unlink(path)
except FileNotFoundError:
print_info(f"file not found/vanished {path}")
self.del_files += 1
def process_mailbox_stat(self, mbox):
cutoff_without_login = (
self.now - int(self.config.delete_inactive_users_after) * 86400
)
cutoff_mails = self.now - int(self.config.delete_mails_after) * 86400
cutoff_large_mails = self.now - int(self.config.delete_large_after) * 86400
self.all_mboxes += 1
changed = False
if mbox.last_login and mbox.last_login < cutoff_without_login:
self.remove_mailbox(mbox.basedir)
return
# all to-be-removed files are relative to the mailbox basedir
try:
os.chdir(mbox.basedir)
except FileNotFoundError:
print_info(f"mailbox not found/vanished {mbox.basedir}")
return
mboxname = os.path.basename(mbox.basedir)
if self.verbose:
date = datetime.fromtimestamp(mbox.last_login) if mbox.last_login else None
if date:
print_info(f"checking mailbox {date.strftime('%b %d')} {mboxname}")
else:
print_info(f"checking mailbox (no last_login) {mboxname}")
self.all_files += len(mbox.messages)
for message in mbox.messages:
if message.mtime < cutoff_mails:
self.remove_file(message.relpath, mtime=message.mtime)
elif message.size > 200000 and message.mtime < cutoff_large_mails:
# we only remove noticed large files (not unnoticed ones in new/)
if message.relpath.startswith("cur/"):
self.remove_file(message.relpath, mtime=message.mtime)
else:
continue
changed = True
if changed:
self.remove_file("maildirsize")
def get_summary(self):
return (
f"Removed {self.del_mboxes} out of {self.all_mboxes} mailboxes "
f"and {self.del_files} out of {self.all_files} files in existing mailboxes "
f"in {time.time() - self.start:2.2f} seconds"
)
def main(args=None):
"""Expire mailboxes and messages according to chatmail config"""
parser = ArgumentParser(description=main.__doc__)
ini = "/usr/local/lib/chatmaild/chatmail.ini"
parser.add_argument(
"chatmail_ini",
action="store",
nargs="?",
help=f"path pointing to chatmail.ini file, default: {ini}",
default=ini,
)
parser.add_argument(
"--days", action="store", help="assume date to be days older than now"
)
parser.add_argument(
"--maxnum",
default=None,
action="store",
help="maximum number of mailboxes to iterate on",
)
parser.add_argument(
"-v",
dest="verbose",
action="store_true",
help="print out removed files and mailboxes",
)
parser.add_argument(
"--remove",
dest="remove",
action="store_true",
help="actually remove all expired files and dirs",
)
args = parser.parse_args(args)
config = read_config(args.chatmail_ini)
now = datetime.utcnow().timestamp()
if args.days:
now = now - 86400 * int(args.days)
maxnum = int(args.maxnum) if args.maxnum else None
exp = Expiry(config, dry=not args.remove, now=now, verbose=args.verbose)
for mailbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
exp.process_mailbox_stat(mailbox)
print(exp.get_summary())
if __name__ == "__main__":
main(sys.argv[1:])

View File

@@ -0,0 +1,168 @@
"""
command line tool to analyze mailbox message storage
example invocation:
python -m chatmaild.fsreport /path/to/chatmail.ini
to show storage summaries for all "cur" folders
python -m chatmaild.fsreport /path/to/chatmail.ini --mdir cur
to show storage summaries only for first 1000 mailboxes
python -m chatmaild.fsreport /path/to/chatmail.ini --maxnum 1000
"""
import os
from argparse import ArgumentParser
from datetime import datetime
from chatmaild.config import read_config
from chatmaild.expire import iter_mailboxes
DAYSECONDS = 24 * 60 * 60
MONTHSECONDS = DAYSECONDS * 30
def HSize(size: int):
"""Format a size integer as a Human-readable string Kilobyte, Megabyte or Gigabyte"""
if size < 10000:
return f"{size / 1000:5.2f}K"
if size < 1000 * 1000:
return f"{size / 1000:5.0f}K"
if size < 1000 * 1000 * 1000:
return f"{int(size / 1000000):5.0f}M"
return f"{size / 1000000000:5.2f}G"
class Report:
def __init__(self, now, min_login_age, mdir):
self.size_extra = 0
self.size_messages = 0
self.now = now
self.min_login_age = min_login_age
self.mdir = mdir
self.num_ci_logins = self.num_all_logins = 0
self.login_buckets = {x: 0 for x in (1, 10, 30, 40, 80, 100, 150)}
self.message_buckets = {x: 0 for x in (0, 160000, 500000, 2000000)}
def process_mailbox_stat(self, mailbox):
# categorize login times
last_login = mailbox.last_login
if last_login:
self.num_all_logins += 1
if os.path.basename(mailbox.basedir)[:3] == "ci-":
self.num_ci_logins += 1
else:
for days in self.login_buckets:
if last_login >= self.now - days * DAYSECONDS:
self.login_buckets[days] += 1
cutoff_login_date = self.now - self.min_login_age * DAYSECONDS
if last_login and last_login <= cutoff_login_date:
# categorize message sizes
for size in self.message_buckets:
for msg in mailbox.messages:
if msg.size >= size:
if self.mdir and not msg.relpath.startswith(self.mdir):
continue
self.message_buckets[size] += msg.size
self.size_messages += sum(entry.size for entry in mailbox.messages)
self.size_extra += sum(entry.size for entry in mailbox.extrafiles)
def dump_summary(self):
all_messages = self.size_messages
print()
print("## Mailbox storage use analysis")
print(f"Mailbox data total size: {HSize(self.size_extra + all_messages)}")
print(f"Messages total size : {HSize(all_messages)}")
try:
percent = self.size_extra / (self.size_extra + all_messages) * 100
except ZeroDivisionError:
percent = 100
print(f"Extra files : {HSize(self.size_extra)} ({percent:.2f}%)")
print()
if self.min_login_age:
print(f"### Message storage for {self.min_login_age} days old logins")
pref = f"[{self.mdir}] " if self.mdir else ""
for minsize, sumsize in self.message_buckets.items():
percent = (sumsize / all_messages * 100) if all_messages else 0
print(
f"{pref}larger than {HSize(minsize)}: {HSize(sumsize)} ({percent:.2f}%)"
)
user_logins = self.num_all_logins - self.num_ci_logins
def p(num):
return f"({num / user_logins * 100:2.2f}%)" if user_logins else "100%"
print()
print(f"## Login stats, from date reference {datetime.fromtimestamp(self.now)}")
print(f"all: {HSize(self.num_all_logins)}")
print(f"non-ci: {HSize(user_logins)}")
print(f"ci: {HSize(self.num_ci_logins)}")
for days, active in self.login_buckets.items():
print(f"last {days:3} days: {HSize(active)} {p(active)}")
def main(args=None):
"""Report about filesystem storage usage of all mailboxes and messages"""
parser = ArgumentParser(description=main.__doc__)
ini = "/usr/local/lib/chatmaild/chatmail.ini"
parser.add_argument(
"chatmail_ini",
action="store",
nargs="?",
help=f"path pointing to chatmail.ini file, default: {ini}",
default=ini,
)
parser.add_argument(
"--days",
default=0,
action="store",
help="assume date to be days older than now",
)
parser.add_argument(
"--min-login-age",
default=0,
dest="min_login_age",
action="store",
help="only sum up message size if last login is at least min-login-age days old",
)
parser.add_argument(
"--mdir",
action="store",
help="only consider 'cur' or 'new' or 'tmp' messages for summary",
)
parser.add_argument(
"--maxnum",
default=None,
action="store",
help="maximum number of mailboxes to iterate on",
)
args = parser.parse_args(args)
config = read_config(args.chatmail_ini)
now = datetime.utcnow().timestamp()
if args.days:
now = now - 86400 * int(args.days)
maxnum = int(args.maxnum) if args.maxnum else None
rep = Report(now=now, min_login_age=int(args.min_login_age), mdir=args.mdir)
for mbox in iter_mailboxes(str(config.mailboxes_dir), maxnum=maxnum):
rep.process_mailbox_stat(mbox)
rep.dump_summary()
if __name__ == "__main__":
main()

View File

@@ -1,7 +1,7 @@
import time
from chatmaild.delete_inactive_users import delete_inactive_users
from chatmaild.doveauth import AuthDictProxy
from chatmaild.expire import main as main_expire
def test_login_timestamps(example_config):
@@ -45,7 +45,12 @@ def test_delete_inactive_users(example_config):
for addr in to_remove:
assert example_config.get_user(addr).maildir.exists()
delete_inactive_users(example_config)
main_expire(
args=[
"--remove",
str(example_config._inipath),
]
)
for p in example_config.mailboxes_dir.iterdir():
assert not p.name.startswith("old")

View File

@@ -0,0 +1,150 @@
import os
import random
from datetime import datetime
from fnmatch import fnmatch
from pathlib import Path
import pytest
from chatmaild.expire import (
FileEntry,
MailboxStat,
get_file_entry,
iter_mailboxes,
os_listdir_if_exists,
)
from chatmaild.expire import main as expiry_main
from chatmaild.fsreport import main as report_main
def fill_mbox(basedir):
basedir1 = basedir.joinpath("mailbox1@example.org")
basedir1.mkdir()
password = basedir1.joinpath("password")
password.write_text("xxx")
basedir1.joinpath("maildirsize").write_text("xxx")
garbagedir = basedir1.joinpath("garbagedir")
garbagedir.mkdir()
create_new_messages(basedir1, ["cur/msg1"], size=500)
create_new_messages(basedir1, ["new/msg2"], size=600)
return basedir1
def create_new_messages(basedir, relpaths, size=1000, days=0):
now = datetime.utcnow().timestamp()
for relpath in relpaths:
msg_path = Path(basedir).joinpath(relpath)
msg_path.parent.mkdir(parents=True, exist_ok=True)
msg_path.write_text("x" * size)
# accessed now, modified N days ago
os.utime(msg_path, (now, now - days * 86400))
@pytest.fixture
def mbox1(example_config):
basedir1 = fill_mbox(example_config.mailboxes_dir)
return MailboxStat(basedir1)
def test_filentry_ordering(tmp_path):
l = [FileEntry(f"x{i}", size=i + 10, mtime=1000 - i) for i in range(10)]
sorted = list(l)
random.shuffle(l)
l.sort(key=lambda x: x.size)
assert l == sorted
def test_no_mailbxoes(tmp_path, capsys):
assert [] == list(iter_mailboxes(str(tmp_path.joinpath("notexists")), maxnum=10))
out, err = capsys.readouterr()
assert "no mailboxes" in err
def test_stats_mailbox(mbox1):
password = Path(mbox1.basedir).joinpath("password")
assert mbox1.last_login == password.stat().st_mtime
assert len(mbox1.messages) == 2
msgs = list(sorted(mbox1.messages, key=lambda x: x.size))
assert len(msgs) == 2
assert msgs[0].size == 500 # cur
assert msgs[1].size == 600 # new
create_new_messages(mbox1.basedir, ["large-extra"], size=1000)
create_new_messages(mbox1.basedir, ["index-something"], size=3)
mbox2 = MailboxStat(mbox1.basedir)
assert len(mbox2.extrafiles) == 4
assert mbox2.extrafiles[0].size == 1000
# cope well with mailbox dirs that have no password (for whatever reason)
Path(mbox1.basedir).joinpath("password").unlink()
mbox3 = MailboxStat(mbox1.basedir)
assert mbox3.last_login is None
def test_report_no_mailboxes(example_config):
args = (str(example_config._inipath),)
report_main(args)
def test_report(mbox1, example_config):
args = (str(example_config._inipath),)
report_main(args)
args = list(args) + "--days 1".split()
report_main(args)
args = list(args) + "--min-login-age 1".split()
report_main(args)
args = list(args) + "--mdir cur".split()
report_main(args)
def test_expiry_cli_basic(example_config, mbox1):
args = (str(example_config._inipath),)
expiry_main(args)
def test_expiry_cli_old_files(capsys, example_config, mbox1):
relpaths_old = ["cur/msg_old1", "cur/msg_old1"]
cutoff_days = int(example_config.delete_mails_after) + 1
create_new_messages(mbox1.basedir, relpaths_old, size=1000, days=cutoff_days)
relpaths_large = ["cur/msg_old_large1", "new/msg_old_large2"]
cutoff_days = int(example_config.delete_large_after) + 1
create_new_messages(
mbox1.basedir, relpaths_large, size=1000 * 300, days=cutoff_days
)
create_new_messages(mbox1.basedir, ["cur/shouldstay"], size=1000 * 300, days=1)
args = str(example_config._inipath), "--remove", "-v"
expiry_main(args)
out, err = capsys.readouterr()
allpaths = relpaths_old + relpaths_large + ["maildirsize"]
for path in allpaths:
for line in err.split("\n"):
if fnmatch(line, f"removing*{path}"):
break
else:
if path != "new/msg_old_large2":
pytest.fail(f"failed to remove {path}\n{err}")
assert "shouldstay" not in err
def test_get_file_entry(tmp_path):
assert get_file_entry(str(tmp_path.joinpath("123123"))) is None
p = tmp_path.joinpath("x")
p.write_text("hello")
entry = get_file_entry(str(p))
assert entry.size == 5
assert entry.mtime
def test_os_listdir_if_exists(tmp_path):
tmp_path.joinpath("x").write_text("hello")
assert len(os_listdir_if_exists(str(tmp_path))) == 1
assert len(os_listdir_if_exists(str(tmp_path.joinpath("123123")))) == 0

View File

@@ -6,4 +6,4 @@ 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")
return file.readline().decode("utf-8").strip()

File diff suppressed because it is too large Load Diff

View File

@@ -2,66 +2,79 @@ import importlib.resources
from pyinfra.operations import apt, files, server, systemd
from ..deployer import Deployer
def deploy_acmetool(email="", domains=[]):
"""Deploy acmetool."""
apt.packages(
name="Install acmetool",
packages=["acmetool"],
)
files.put(
src=importlib.resources.files(__package__).joinpath("acmetool.cron").open("rb"),
dest="/etc/cron.d/acmetool",
user="root",
group="root",
mode="644",
)
class AcmetoolDeployer(Deployer):
def __init__(self, *, email, domains, **kwargs):
super().__init__(**kwargs)
self.domains = domains
self.email = email
self.need_restart = False
files.put(
src=importlib.resources.files(__package__).joinpath("acmetool.hook").open("rb"),
dest="/usr/lib/acme/hooks/nginx",
user="root",
group="root",
mode="744",
)
@staticmethod
def install_impl():
apt.packages(
name="Install acmetool",
packages=["acmetool"],
)
files.template(
src=importlib.resources.files(__package__).joinpath("response-file.yaml.j2"),
dest="/var/lib/acme/conf/responses",
user="root",
group="root",
mode="644",
email=email,
)
def configure_impl(self):
files.put(
src=importlib.resources.files(__package__).joinpath("acmetool.cron").open("rb"),
dest="/etc/cron.d/acmetool",
user="root",
group="root",
mode="644",
)
files.template(
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
dest="/var/lib/acme/conf/target",
user="root",
group="root",
mode="644",
)
files.put(
src=importlib.resources.files(__package__).joinpath("acmetool.hook").open("rb"),
dest="/usr/lib/acme/hooks/nginx",
user="root",
group="root",
mode="744",
)
service_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-redirector.service"
),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root",
group="root",
mode="644",
)
files.template(
src=importlib.resources.files(__package__).joinpath("response-file.yaml.j2"),
dest="/var/lib/acme/conf/responses",
user="root",
group="root",
mode="644",
email=self.email,
)
systemd.service(
name="Setup acmetool-redirector service",
service="acmetool-redirector.service",
running=True,
enabled=True,
restarted=service_file.changed,
)
files.template(
src=importlib.resources.files(__package__).joinpath("target.yaml.j2"),
dest="/var/lib/acme/conf/target",
user="root",
group="root",
mode="644",
)
server.shell(
name=f"Request certificate for: {', '.join(domains)}",
commands=[f"acmetool want --xlog.severity=debug {' '.join(domains)}"],
)
service_file = files.put(
src=importlib.resources.files(__package__).joinpath(
"acmetool-redirector.service"
),
dest="/etc/systemd/system/acmetool-redirector.service",
user="root",
group="root",
mode="644",
)
self.need_restart = service_file.changed
def activate_impl(self):
systemd.service(
name="Setup acmetool-redirector service",
service="acmetool-redirector.service",
running=True,
enabled=True,
restarted=self.need_restart,
)
self.need_restart = False
server.shell(
name=f"Request certificate for: {', '.join(self.domains)}",
commands=[f"acmetool want --xlog.severity=debug {' '.join(self.domains)}"],
)

View File

@@ -1,7 +1,8 @@
request:
provider: https://acme-v02.api.letsencrypt.org/directory
key:
type: rsa
type: ecdsa
ecdsa-curve: nistp256
challenge:
webroot-paths:
- /var/www/html/.well-known/acme-challenge

View File

@@ -0,0 +1,59 @@
import os
from pyinfra.operations import server
class Deployment:
def install(self, deployer):
# optional 'required_users' contains a list of (user, group, secondary-group-list) tuples.
# If the group is None, no group is created corresponding to that user.
# If the secondary group list is not None, all listed groups are created as well.
required_users = getattr(deployer, "required_users", [])
for user, group, groups in required_users:
if group is not None:
server.group(
name="Create {} group".format(group), group=group, system=True
)
if groups is not None:
for group2 in groups:
server.group(
name="Create {} group".format(group2), group=group2, system=True
)
server.user(
name="Create {} user".format(user),
user=user,
group=group,
groups=groups,
system=True,
)
ret = bool(deployer.install())
if ret:
deployer.need_restart = True
def configure(self, deployer):
deployer.configure()
def activate(self, deployer):
deployer.activate()
def perform_stages(self, deployers):
default_stages = "install,configure,activate"
stages = os.getenv("CMDEPLOY_STAGES", default_stages).split(",")
for stage in stages:
for deployer in deployers:
getattr(self, stage)(deployer)
class Deployer:
need_restart = False
def install(self):
pass
def configure(self):
pass
def activate(self):
pass

View File

@@ -70,6 +70,12 @@ userdb {
# Mailboxes are stored in the "mail" directory of the vmail user home.
mail_location = maildir:{{ config.mailboxes_dir }}/%u
# index/cache files are not very useful for chatmail relay operations
# but it's not clear how to disable them completely.
# According to https://doc.dovecot.org/2.3/settings/advanced/#core_setting-mail_cache_max_size
# if the cache file becomes larger than the specified size, it is truncated by dovecot
mail_cache_max_size = 500K
namespace inbox {
inbox = yes

View File

@@ -1,14 +0,0 @@
# 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
# delete all mails after {{ config.delete_mails_after }} days, in the Inbox
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# or in any IMAP subfolder
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/cur/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# even if they are unseen
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/new/*' -mtime +{{ config.delete_mails_after }} -type f -delete
# or only temporary (but then they shouldn't be around after {{ config.delete_mails_after }} days anyway).
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
2 0 * * * vmail find {{ config.mailboxes_dir }} -path '*/.*/tmp/*' -mtime +{{ config.delete_mails_after }} -type f -delete
3 0 * * * vmail find {{ config.mailboxes_dir }} -name 'maildirsize' -type f -delete
4 0 * * * vmail /usr/local/lib/chatmaild/venv/bin/delete_inactive_users /usr/local/lib/chatmaild/chatmail.ini

View File

@@ -0,0 +1,3 @@
#!/bin/sh
echo "All runlevel operations denied by policy" >&2
exit 101

View File

@@ -26,6 +26,7 @@ smtp_tls_security_level=verify
smtp_tls_servername = hostname
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = inline:{nauta.cu=may}
smtp_tls_protocols = >=TLSv1.2
smtpd_tls_protocols = >=TLSv1.2
# Disable anonymous cipher suites

View File

@@ -14,6 +14,7 @@ smtp inet n - y - - smtpd -v
{%- else %}
smtp inet n - y - - smtpd
{%- endif %}
-o smtpd_tls_security_level=encrypt
-o smtpd_proxy_filter=127.0.0.1:{{ config.filtermail_smtp_port_incoming }}
submission inet n - y - 5000 smtpd
-o syslog_name=postfix/submission

View File

@@ -73,9 +73,7 @@ def query_dns(typ, domain):
# Query authoritative nameserver directly to bypass DNS cache.
res = shell(f"dig @{ns} -r -q {domain} -t {typ} +short", print=log_progress)
if res:
return res.split("\n")[0]
return ""
return next((line for line in res.split("\n") if not line.startswith(';')), '')
def check_zonefile(zonefile, verbose=True):

View File

@@ -0,0 +1,9 @@
[Unit]
Description=chatmail mail storage expiration job
After=network.target
[Service]
Type=oneshot
User=vmail
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-expire /usr/local/lib/chatmaild/chatmail.ini -v --remove

View File

@@ -0,0 +1,8 @@
[Unit]
Description=Run Daily chatmail-expire job
[Timer]
OnCalendar=*-*-* 00:02:00
[Install]
WantedBy=timers.target

View File

@@ -0,0 +1,9 @@
[Unit]
Description=chatmail file system storage reporting job
After=network.target
[Service]
Type=oneshot
User=vmail
ExecStart=/usr/local/lib/chatmaild/venv/bin/chatmail-fsreport /usr/local/lib/chatmaild/chatmail.ini

View File

@@ -0,0 +1,9 @@
[Unit]
Description=Run Daily Chatmail fsreport Job
[Timer]
OnCalendar=*-*-* 08:02:00
Persistent=true
[Install]
WantedBy=timers.target

View File

@@ -37,7 +37,7 @@ class TestDC:
def test_ping_pong(self, benchmark, cmfactory):
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():
chat.send_text("ping")
@@ -49,7 +49,7 @@ class TestDC:
def test_send_10_receive_10(self, benchmark, cmfactory, lp):
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():
for i in range(10):

View File

@@ -1,5 +1,5 @@
import queue
import socket
import smtplib
import threading
import pytest
@@ -91,25 +91,23 @@ def test_concurrent_logins_same_account(
def test_no_vrfy(chatmail_config):
domain = chatmail_config.mail_domain
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}")
banner = sock.recv(1024)
print(banner)
sock.send(b"VRFY wrongaddress@%s\r\n" % (chatmail_config.mail_domain.encode(),))
result = sock.recv(1024)
s = smtplib.SMTP(domain)
s.starttls()
s.putcmd("vrfy", f"wrongaddress@{chatmail_config.mail_domain}")
result = s.getreply()
print(result)
sock.send(b"VRFY echo@%s\r\n" % (chatmail_config.mail_domain.encode(),))
result2 = sock.recv(1024)
s.putcmd("vrfy", f"echo@{chatmail_config.mail_domain}")
result2 = s.getreply()
print(result2)
assert result[0:10] == result2[0:10]
sock.send(b"VRFY wrongaddress\r\n")
result = sock.recv(1024)
assert result[0] == result2[0] == 252
assert result[1][0:6] == result2[1][0:6] == b"2.0.0 "
s.putcmd("vrfy", "wrongaddress")
result = s.getreply()
print(result)
sock.send(b"VRFY echo\r\n")
result2 = sock.recv(1024)
s.putcmd("vrfy", "echo")
result2 = s.getreply()
print(result2)
assert result[0:10] == result2[0:10] == b"252 2.0.0 "
assert result[0] == result2[0] == 252
assert result[1][0:6] == result2[1][0:6] == b"2.0.0 "

View File

@@ -143,6 +143,7 @@ def test_reject_missing_dkim(cmsetup, maildata, from_addr):
"encrypted.eml", from_addr=from_addr, to_addr=recipient.addr
).as_string()
conn = smtplib.SMTP(cmsetup.maildomain, 25, timeout=10)
conn.starttls()
with conn as s:
with pytest.raises(smtplib.SMTPDataError, match="No valid DKIM signature"):

View File

@@ -56,7 +56,7 @@ class TestEndToEndDeltaChat:
"""Test that a DC account can send a message to a second DC account
on the same chat-mail instance."""
ac1, ac2 = cmfactory.get_online_accounts(2)
chat = cmfactory.get_protected_chat(ac1, ac2)
chat = cmfactory.get_accepted_chat(ac1, ac2)
chat.send_text("message0")
lp.sec("wait for ac2 to receive message")
@@ -70,7 +70,7 @@ class TestEndToEndDeltaChat:
before quota is exceeded, and thus depends on the speed of the upload.
"""
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")
@@ -153,7 +153,7 @@ def test_hide_senders_ip_address(cmfactory):
assert ipaddress.ip_address(public_ip)
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")
user2._evtracker.wait_next_incoming_message()

View File

@@ -1,3 +1,5 @@
from copy import deepcopy
import pytest
from cmdeploy import remote
@@ -8,38 +10,63 @@ from cmdeploy.dns import check_full_zone, check_initial_remote_data
def mockdns_base(monkeypatch):
qdict = {}
def query_dns(typ, domain):
try:
return qdict[typ][domain]
except KeyError:
return ""
def shell(command, fail_ok=False, print=print):
if command.startswith("dig"):
if command == "dig":
return "."
if "SOA" in command:
return (
"delta.chat. 21600 IN SOA ns1.first-ns.de. dns.hetzner.com."
" 2025102800 14400 1800 604800 3600"
)
command_chunks = command.split()
domain, typ = command_chunks[4], command_chunks[6]
try:
return qdict[typ][domain]
except KeyError:
return ""
return remote.rshell.shell(command=command, fail_ok=fail_ok, print=print)
monkeypatch.setattr(remote.rdns, query_dns.__name__, query_dns)
monkeypatch.setattr(remote.rdns, shell.__name__, shell)
return qdict
@pytest.fixture
def mockdns(mockdns_base):
mockdns_base.update(
{
"A": {"some.domain": "1.1.1.1"},
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
"CNAME": {
"mta-sts.some.domain": "some.domain.",
"www.some.domain": "some.domain.",
},
}
)
def mockdns_expected():
return {
"A": {"some.domain": "1.1.1.1"},
"AAAA": {"some.domain": "fde5:cd7a:9e1c:3240:5a99:936f:cdac:53ae"},
"CNAME": {
"mta-sts.some.domain": "some.domain.",
"www.some.domain": "some.domain.",
},
}
@pytest.fixture(params=["plain", "with-dns-comments"])
def mockdns(request, mockdns_base, mockdns_expected):
mockdns_base.update(deepcopy(mockdns_expected))
match request.param:
case "plain":
pass
case "with-dns-comments":
for typ, data in mockdns_base.items():
for host, result in data.items():
mockdns_base[typ][host] = (
";; some unsuccessful attempt result\n"
"; and another with a single semicolon\n"
f"{result}"
)
return mockdns_base
class TestPerformInitialChecks:
def test_perform_initial_checks_ok1(self, mockdns):
def test_perform_initial_checks_ok1(self, mockdns, mockdns_expected):
remote_data = remote.rdns.perform_initial_checks("some.domain")
assert remote_data["A"] == mockdns["A"]["some.domain"]
assert remote_data["AAAA"] == mockdns["AAAA"]["some.domain"]
assert remote_data["MTA_STS"] == mockdns["CNAME"]["mta-sts.some.domain"]
assert remote_data["WWW"] == mockdns["CNAME"]["www.some.domain"]
assert remote_data["A"] == mockdns_expected["A"]["some.domain"]
assert remote_data["AAAA"] == mockdns_expected["AAAA"]["some.domain"]
assert remote_data["MTA_STS"] == mockdns_expected["CNAME"]["mta-sts.some.domain"]
assert remote_data["WWW"] == mockdns_expected["CNAME"]["www.some.domain"]
@pytest.mark.parametrize("drop", ["A", "AAAA"])
def test_perform_initial_checks_with_one_of_A_AAAA(self, mockdns, drop):

View File

@@ -4,6 +4,7 @@ import time
import traceback
import webbrowser
from pathlib import Path
import re
import markdown
from chatmaild.config import read_config
@@ -12,6 +13,9 @@ from jinja2 import Template
from .genqr import gen_qr_png_data
_MERGE_CONFLICT_RE = re.compile(r"^<<<<<<<.+^=======.+^>>>>>>>", re.DOTALL | re.MULTILINE)
def snapshot_dir_stats(somedir):
d = {}
for path in somedir.iterdir():
@@ -116,6 +120,17 @@ def _build_webpages(src_dir, build_dir, config):
return build_dir
def find_merge_conflict(src_dir) -> Path:
assert src_dir.exists(), src_dir
result = None
for path in src_dir.iterdir():
if path.suffix in [".css", ".html", ".md"]:
if _MERGE_CONFLICT_RE.search(path.read_text()):
result = path
break
return result
def main():
path = importlib.resources.files(__package__)
reporoot = path.joinpath("../../../").resolve()