Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3826de8
Add installation via docker compose (MVP 1)
Aug 9, 2025
5aef295
Merge branch 'main' into docker
Aug 17, 2025
b81e471
Merge pull request #1 from Keonik1/docker
Keonik1 Aug 17, 2025
4a92e50
Update Changelog
Aug 17, 2025
6425a83
Fix description for is_development_instance option
Aug 17, 2025
1c4c7b9
revert page-layout logo link
Aug 17, 2025
aea6366
rename dockerfile
Aug 23, 2025
b6dce61
add port 80 to docker-compose-default
Aug 23, 2025
a6e5b9e
add 465 port
Aug 23, 2025
a01eebe
add RECREATE_VENV var
Aug 23, 2025
d545fc8
Add traefik config files
Aug 23, 2025
9037409
change "restart nginx" to "reload nginx"
Aug 23, 2025
dc6d8b4
pass values to `MAIL_DOMAIN` and `ACME_EMAIL` from vars for docker-co…
Aug 23, 2025
4fc672c
Fix bug with attaching certs
Aug 23, 2025
4c42d0f
fix for lint test
Aug 23, 2025
87615b6
fix docs - nginx "restart" to "reload"
Aug 23, 2025
1b3f419
Delete ssh connection from docker installation
Aug 23, 2025
d5329fa
Fix issue with acmetool
Aug 24, 2025
5dcb002
delete default value for ACME_EMAIL
Aug 25, 2025
f027afd
delete sudo from traefik init container cmd
Aug 25, 2025
e1ca74e
fix unlink if default nginx conf is not exist
Aug 25, 2025
c372c55
try to fix tests
Aug 25, 2025
929383d
fix docs; revert tests
Aug 25, 2025
910eeea
Fix colored output file; return original exit code
Aug 26, 2025
a1301fa
add a workaround to pass the test_init_not_overwrite test
Aug 26, 2025
3d51c08
set dafault value for fs_inotify_max_user_instances_and_watchers param
Sep 5, 2025
a3c5c73
delete after a decision has been made during the discussion
Sep 5, 2025
3c14692
fix wording
Sep 5, 2025
657a00d
make posix compattable if statements
Sep 5, 2025
490776e
Add comment why variables are passed in setup_chatmail.service
Sep 5, 2025
ed3cba7
delete CERTS_ROOT_DIR_HOST. Add hardcoded paths to certificates
Sep 5, 2025
87ac465
Add the ability to render the site only in the preferred languages
Sep 5, 2025
b86c977
- pedantic fix
Sep 6, 2025
74faefa
delete ancestral legacy
Sep 6, 2025
dfe2c00
delete tabs if only one language selected
Sep 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -164,3 +164,10 @@ cython_debug/
#.idea/

chatmail.zone

# docker
/data/
/custom/
docker-compose.yaml
.env
/traefik/data/
30 changes: 30 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,36 @@

## untagged

- Add installation via docker compose (MVP 1). The instructions, known issues and limitations are located in `/docs`
([#614](https://github.com/chatmail/relay/pull/614))

- Add markdown tabs blocks for rendering multilingual pages. Add russian language support to `index.md`, `privacy.md`, and `info.md`.
([#614](https://github.com/chatmail/relay/pull/614))

- Fix [Issue 604](https://github.com/chatmail/relay/issues/604), now the `--ssh_host` argument of the `cmdeploy run` command works correctly and does not depend on `config.mail_domain`.
([#614](https://github.com/chatmail/relay/pull/614))

- Add `--skip-dns-check` argument to `cmdeploy run` command, which disables DNS record checking before installation.
([#614](https://github.com/chatmail/relay/pull/614))

- Add `--force` argument to `cmdeploy init` command, which recreates the `chatmail.ini` file.
([#614](https://github.com/chatmail/relay/pull/614))

- Add startup for `fcgiwrap.service` because sometimes it did not start automatically.
([#614](https://github.com/chatmail/relay/pull/614))

- Add extended check when installing `unbound.service`. Now, if it is not shown who exactly is occupying port 53, but `unbound.service` is running, it is considered that the port is occupied by `unbound.service`.
([#614](https://github.com/chatmail/relay/pull/614))

- Add configuration parameters
([#614](https://github.com/chatmail/relay/pull/614)):
- `is_development_instance` - Indicates that this instance is installed as a temporary/test one (default: `True`)
- `use_foreign_cert_manager` - Use a third-party certificate manager instead of acmetool (default: `False`)
- `acme_email` - Email address used by acmetool to obtain Let's Encrypt certificates (default: empty)
- `change_kernel_settings` - Whether to change kernel parameters during installation (default: `True`)
- `fs_inotify_max_user_instances_and_watchers` - Value for kernel parameters `fs.inotify.max_user_instances` and `fs.inotify.max_user_watches` (default: `65535`)
- `languages`- List of website languages. (default: `EN`. possible: `EN RU` or `ALL`). Current instances can continue to use the current markdown files, the change will not affect rendering.

- Check whether GCC is installed in initenv.sh
([#608](https://github.com/chatmail/relay/pull/608))

Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,22 +74,23 @@ Please substitute it with your own domain.
```
git clone https://github.com/chatmail/relay
cd relay
scripts/initenv.sh
```

3. On your local PC, create chatmail configuration file `chatmail.ini`:
### Manual installation
1. On your local PC, create chatmail configuration file `chatmail.ini`:

```
scripts/initenv.sh
scripts/cmdeploy init chat.example.org # <-- use your domain
```

4. Verify that SSH root login to your remote server works:
2. Verify that SSH root login to your remote server works:

```
ssh [email protected] # <-- use your domain
```

5. From your local PC, deploy the remote chatmail relay server:
3. From your local PC, deploy the remote chatmail relay server:

```
scripts/cmdeploy run
Expand All @@ -99,6 +100,9 @@ Please substitute it with your own domain.
which you should configure at your DNS provider
(it can take some time until they are public).

### Docker installation
Installation using docker compose is presented [here](./docs/DOCKER_INSTALLATION_EN.md)

### Other helpful commands

To check the status of your remotely running chatmail service:
Expand Down
16 changes: 16 additions & 0 deletions chatmaild/src/chatmaild/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,12 @@ def __init__(self, inipath, params):
self.password_min_length = int(params["password_min_length"])
self.passthrough_senders = params["passthrough_senders"].split()
self.passthrough_recipients = params["passthrough_recipients"].split()
self.languages = (
params.get("languages", "EN").split()
)
self.is_development_instance = (
params.get("is_development_instance", "true").lower() == "true"
)
self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.filtermail_smtp_port_incoming = int(
params["filtermail_smtp_port_incoming"]
Expand All @@ -43,6 +49,16 @@ def __init__(self, inipath, params):
)
self.mtail_address = params.get("mtail_address")
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.use_foreign_cert_manager = (
params.get("use_foreign_cert_manager", "false").lower() == "true"
)
self.acme_email = params["acme_email"]
self.change_kernel_settings = (
params.get("change_kernel_settings", "true").lower() == "true"
)
self.fs_inotify_max_user_instances_and_watchers = int(
params.get("fs_inotify_max_user_instances_and_watchers", "65535")
)
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
if "iroh_relay" not in params:
self.iroh_relay = "https://" + params["mail_domain"]
Expand Down
25 changes: 25 additions & 0 deletions chatmaild/src/chatmaild/ini/chatmail.ini.f
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@
# Deployment Details
#

# A space-separated list of languages to be displayed on the site.
# Now available languages: EN RU
# You can also use the keyword "ALL"
# NOTE: The order of languages affects their order on the page
languages = EN

# set to "False" to remove the "development instance" banner on the main page.
is_development_instance = True

# SMTP outgoing filtermail and reinjection
filtermail_smtp_port = 10080
postfix_reinject_port = 10025
Expand All @@ -60,6 +69,22 @@
# if set to "True" IPv6 is disabled
disable_ipv6 = False

# if you set "True", acmetool will not be installed and you will have to manage certificates yourself.
use_foreign_cert_manager = False

# Your email adress, which will be used in acmetool to manage Let's Encrypt SSL certificates. Required if `use_foreign_cert_manager` param set as "False".
acme_email =

#
# Kernel settings
#

# if you set "True", the kernel settings will be configured according to the values below
change_kernel_settings = True

# change fs.inotify.max_user_instances and fs.inotify.max_user_watches kernel settings
fs_inotify_max_user_instances_and_watchers = 65535

# Defaults to https://iroh.{{mail_domain}} and running `iroh-relay` on the chatmail
# service.
# If you set it to anything else, the service will be disabled
Expand Down
1 change: 1 addition & 0 deletions cmdeploy/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
"pytest-xdist",
"execnet",
"imap_tools",
"pymdown-extensions",
]

[project.scripts]
Expand Down
48 changes: 30 additions & 18 deletions cmdeploy/src/cmdeploy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from pyinfra.api import FactBase
from pyinfra.facts.files import File
from pyinfra.facts.server import Sysctl
from pyinfra.facts.systemd import SystemdEnabled
from pyinfra.facts.systemd import SystemdEnabled, SystemdStatus
from pyinfra.operations import apt, files, pip, server, systemd

from .acmetool import deploy_acmetool
Expand Down Expand Up @@ -395,20 +395,21 @@ def _configure_dovecot(config: Config, debug: bool = False) -> bool:
config=config,
)

# as per https://doc.dovecot.org/configuration_manual/os/
# as per https://doc.dovecot.org/2.3/configuration_manual/os/
# it is recommended to set the following inotify limits
for name in ("max_user_instances", "max_user_watches"):
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(
name=f"Change {key}",
key=key,
value=65535,
persist=True,
)
if config.change_kernel_settings:
for name in ("max_user_instances", "max_user_watches"):
key = f"fs.inotify.{name}"
if host.get_fact(Sysctl)[key] == config.fs_inotify_max_user_instances_and_watchers:
# Skip updating limits if already sufficient
# (enables running in incus containers where sysctl readonly)
continue
server.sysctl(
name=f"Change {key}",
key=key,
value=config.fs_inotify_max_user_instances_and_watchers,
persist=True,
)

timezone_env = files.line(
name="Set TZ environment variable",
Expand Down Expand Up @@ -676,6 +677,8 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
from cmdeploy.cmdeploy import Out

process_on_53 = host.get_fact(Port, port=53)
if host.get_fact(SystemdStatus, services="unbound").get("unbound.service"):
process_on_53 = "unbound"
if process_on_53 not in (None, "unbound"):
Out().red(f"Can't install unbound: port 53 is occupied by: {process_on_53}")
exit(1)
Expand All @@ -700,10 +703,12 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
deploy_iroh_relay(config)

# Deploy acmetool to have TLS certificates.
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool(
domains=tls_domains,
)
if not config.use_foreign_cert_manager:
tls_domains = [mail_domain, f"mta-sts.{mail_domain}", f"www.{mail_domain}"]
deploy_acmetool(
email = config.acme_email,
domains=tls_domains,
)

apt.packages(
# required for setfacl for echobot
Expand Down Expand Up @@ -783,6 +788,13 @@ def deploy_chatmail(config_path: Path, disable_mail: bool) -> None:
enabled=True,
restarted=nginx_need_restart,
)

systemd.service(
name="Start and enable fcgiwrap",
service="fcgiwrap.service",
running=True,
enabled=True,
)

# This file is used by auth proxy.
# https://wiki.debian.org/EtcMailName
Expand Down
72 changes: 59 additions & 13 deletions cmdeploy/src/cmdeploy/cmdeploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,24 +25,37 @@
# cmdeploy sub commands and options
#

def is_pytest():
return "PYTEST_CURRENT_TEST" in os.environ

def init_cmd_options(parser):
parser.add_argument(
"chatmail_domain",
action="store",
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):
"""Initialize chatmail config file."""
mail_domain = args.chatmail_domain
inipath = args.inipath
if args.inipath.exists():
print(f"Path exists, not modifying: {args.inipath}")
return 1
else:
write_initial_config(args.inipath, mail_domain, overrides={})
out.green(f"created config file for {mail_domain} in {args.inipath}")
if not args.recreate_ini:
out.green(f"[WARNING] Path exists, not modifying: {inipath}")
return 1
else:
out.yellow(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):
Expand All @@ -63,16 +76,23 @@ def run_cmd_options(parser):
dest="ssh_host",
help="specify an SSH host to deploy to; uses mail_domain from chatmail.ini by default",
)
parser.add_argument(
"--skip-dns-check",
dest="dns_check_disabled",
action="store_true",
help="disable nslookup checks for DNS",
)


def run_cmd(args, out):
"""Deploy chatmail services on the remote server."""

sshexec = args.get_sshexec()
require_iroh = args.config.enable_iroh_relay
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, print=out.red):
return 1
if not args.dns_check_disabled:
remote_data = dns.get_initial_remote_data(sshexec, args.config.mail_domain)
if not dns.check_initial_remote_data(remote_data, print=out.red):
return 1

env = os.environ.copy()
env["CHATMAIL_INI"] = args.inipath
Expand All @@ -81,14 +101,21 @@ def run_cmd(args, out):
deploy_path = importlib.resources.files(__package__).joinpath("deploy.py").resolve()
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"
if sshexec == "localhost":
cmd = f"{pyinf} @local {deploy_path} -y"
Comment on lines +106 to +107
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good quick fix, maybe we can add something like this to the cmdeploy dns calls as well, so they don't try to request DNS records from the chatmail relay itself, but from the local (docker) host.


if version.parse(pyinfra.__version__) < version.parse("3"):
out.red("Please re-run scripts/initenv.sh to update pyinfra to version 3.")
return 1

try:
retcode = out.check_call(cmd, env=env)
if retcode == 0:
server_deployed_message = f"Chatmail server started: https://{args.config.mail_domain}/"
delimiter_line = "=" * len(server_deployed_message)
out.green(f"{delimiter_line}\n{server_deployed_message}\n{delimiter_line}")
out.green("Deploy completed, call `cmdeploy dns` next.")
elif not remote_data["acme_account_url"]:
out.red("Deploy completed but letsencrypt not configured")
Expand Down Expand Up @@ -248,11 +275,20 @@ class Out:
def red(self, msg, file=sys.stderr):
print(colored(msg, "red"), file=file)

def green(self, msg, file=sys.stderr):
def green(self, msg, file=sys.stdout):
print(colored(msg, "green"), file=file)

def __call__(self, msg, red=False, green=False, file=sys.stdout):
color = "red" if red else ("green" if green else None)
def yellow(self, msg, file=sys.stdout):
print(colored(msg, "yellow"), file=file)

def __call__(self, msg, red=False, green=False, yellow=False, file=sys.stdout):
color = None
if red:
color = "red"
elif green:
color = "green"
elif yellow:
color = "yellow"
print(colored(msg, color), file=file)

def check_call(self, arg, env=None, quiet=False):
Expand Down Expand Up @@ -331,12 +367,22 @@ def main(args=None):
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)
host = args.ssh_host if hasattr(args, "ssh_host") and args.ssh_host else args.config.mail_domain
if host in [ "@local", "localhost" ]:
return "localhost"

print(f"[ssh] login to {host}")
return SSHExec(host, verbose=args.verbose)

args.get_sshexec = get_sshexec

out = Out()
if is_pytest(): ## issue: https://github.com/chatmail/relay/issues/622
out.green = print
out.red = print
out.yellow = print
out.__call__ = print

kwargs = {}
if args.func.__name__ not in ("init_cmd", "fmt_cmd"):
if not args.inipath.exists():
Expand Down
Loading