Compare commits

..

28 Commits

Author SHA1 Message Date
missytake
fbcf071e89 doveauth-http: deploying HTTP route for account creation 2023-12-01 18:32:45 +01:00
missytake
ccfbb59e17 doveauth: flask app to create accounts via HTTP 2023-12-01 18:05:40 +01:00
missytake
a86e135967 opendkim: correctly specify SigningTable in opendkim.conf 2023-11-26 07:40:25 +01:00
missytake
776bd87888 moved mta-sts-resolver to /usr/local/lib 2023-11-25 00:39:27 +01:00
link2xt
d7683ed3f7 Move ssl_certificate back to http and fix indentation 2023-11-25 00:39:27 +01:00
missytake
0cc9f18468 acmetool: request one TLS cert for all domains 2023-11-25 00:39:27 +01:00
missytake
889e18f803 generate-dns-zone.sh doesn't need to support CHATMAIL_SERVER env var for now, let's assume A/AAAA point to the chatmail server, too 2023-11-25 00:39:27 +01:00
missytake
773b8d1e00 MTA-STS: fixing lint issues 2023-11-25 00:39:27 +01:00
missytake
dca6d35a6f MTA-STS: adding correct line breaks to config 2023-11-25 00:39:27 +01:00
missytake
d29d2d147b MTA-STS: the HTTPS route needs to be mta-sts.@ not _mta-sts 2023-11-25 00:39:27 +01:00
missytake
347dae1f84 MTA-STS: CNAME doesn't work, it needs to be A and AAAA 2023-11-25 00:39:27 +01:00
missytake
63cbb83344 fix: hetzner doesn't accept whitespace in TXT and CAA records apparently 2023-11-25 00:39:27 +01:00
missytake
27d135fee7 python3-venv was missing 2023-11-25 00:39:27 +01:00
missytake
ccd7c789f0 postfix: install MTA-STS resolver daemon 2023-11-25 00:39:27 +01:00
missytake
c7625fad81 DNS: distinguish between mail_server and mail_domain 2023-11-25 00:39:27 +01:00
missytake
5305dfab12 Added MTA-STS records and .well-known file 2023-11-25 00:39:27 +01:00
holger krekel
4478270fc9 properly call logging.exception 2023-11-20 22:54:15 +01:00
holger krekel
e7c9992fdc it's unclear what this limit really means -- with ipv6 one can easily create lots of IP addresses anyway 2023-11-20 22:54:15 +01:00
holger krekel
a9d43c42f4 - tune down logging for filtermail
- allow higher smtp connection limit
2023-11-20 22:54:15 +01:00
holger krekel
bbf2f0dd36 with help/side-comments from alex i fixed the concurrent account creation problem 2023-11-20 22:54:15 +01:00
holger krekel
43c02377ef make headlines as big as normal text 2023-11-16 11:46:47 +01:00
missytake
70f330b0e4 Changed typo to sans-serif, feel free to revert 2023-11-16 11:46:47 +01:00
holger krekel
02eaa55441 reduce retro-ness of design after @hocuri's comment :) 2023-11-16 11:46:47 +01:00
holger krekel
6c3ec903c2 Update www/nine.testrun.org/index.html
Co-authored-by: Hocuri <hocuri@gmx.de>
2023-11-15 20:48:30 +01:00
holger krekel
7d9b81863f refining the entry point, more info, more directly speaking to DC users
(we don't want to get arbitrary users to report issues)
2023-11-15 20:48:30 +01:00
missytake
af90d0a7de rename doveauth-dictproxy to doveauth 2023-11-15 15:00:27 +01:00
link2xt
322bc9a3aa Set critical flag on generated CAA record
This does not really matter as Let's Encrypt
supports current CAA `issue` syntax,
but may be useful if more records are added and this flag is copy-pasted.

For reference: <https://www.rfc-editor.org/rfc/rfc8659#name-critical-flag>
2023-11-13 15:12:32 +00:00
link2xt
e4009854dc Add NOTIFY capability
Delta Chat does not use it now,
but should: <https://github.com/deltachat/deltachat-core-rust/issues/4983>
Having no capability will confuse whoever develops it.
2023-11-12 20:41:29 +01:00
20 changed files with 317 additions and 101 deletions

View File

@@ -81,10 +81,11 @@ comprised of minimal setups of
as well as two custom services that are integrated with these two:
- `chatmaild/src/chatmaild/dictproxy.py` implements
- `chatmaild/src/chatmaild/doveauth.py` implements
create-on-login account creation semantics and is used
by Dovecot during login authentication and by Postfix
which in turn uses Dovecot SASL to authenticate users
which in turn uses [Dovecot SASL](https://doc.dovecot.org/configuration_manual/authentication/dict/#complete-example-for-authenticating-via-a-unix-socket)
to authenticate users
to send mails for them.
- `chatmaild/src/chatmaild/filtermail.py` prevents

View File

@@ -6,15 +6,21 @@ build-backend = "setuptools.build_meta"
name = "chatmaild"
version = "0.1"
dependencies = [
"aiosmtpd"
"aiosmtpd",
"flask",
"gunicorn",
]
[project.scripts]
doveauth-dictproxy = "chatmaild.dictproxy:main"
doveauth = "chatmaild.doveauth:main"
doveauth-http = "chatmaild.web:main"
filtermail = "chatmaild.filtermail:main"
[tool.pytest.ini_options]
addopts = "-v -ra --strict-markers"
log_format = "%(asctime)s %(levelname)s %(message)s"
log_date_format = "%Y-%m-%d %H:%M:%S"
log_level = "INFO"
[tool.tox]
legacy_tox_ini = """

View File

@@ -0,0 +1,10 @@
[Unit]
Description=HTTP endpoint for creating chatmail accounts
[Service]
ExecStart=/usr/local/bin/gunicorn --timeout 60 -b :3691 -w 1 chatmaild.web:main
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -3,7 +3,6 @@ import os
import time
import sys
import json
import crypt
from socketserver import (
UnixStreamServer,
StreamRequestHandler,
@@ -12,39 +11,7 @@ from socketserver import (
import pwd
from .database import Database
NOCREATE_FILE = "/etc/chatmail-nocreate"
def encrypt_password(password: str):
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
return "{SHA512-CRYPT}" + passhash
def is_allowed_to_create(user, cleartext_password) -> bool:
"""Return True if user and password are admissable."""
if os.path.exists(NOCREATE_FILE):
logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.")
return False
if len(cleartext_password) < 10:
logging.warning("Password needs to be at least 10 characters long")
return False
parts = user.split("@")
if len(parts) != 2:
logging.warning(f"user {user!r} is not a proper e-mail address")
return False
localpart, domain = parts
if domain == "nine.testrun.org":
# nine.testrun.org policy, username has to be exactly nine chars
if len(localpart) != 9:
logging.warning(f"localpart {localpart!r} has not exactly nine chars")
return False
return True
from .util import is_allowed_to_create, encrypt_password, get_mail_domain
def get_user_data(db, user):
@@ -116,26 +83,31 @@ def handle_dovecot_request(msg, db, mail_domain):
class ThreadedUnixStreamServer(ThreadingMixIn, UnixStreamServer):
pass
request_queue_size = 100
def main():
socket = sys.argv[1]
passwd_entry = pwd.getpwnam(sys.argv[2])
db = Database(sys.argv[3])
with open("/etc/mailname", "r") as fp:
mail_domain = fp.read().strip()
mail_domain = get_mail_domain()
class Handler(StreamRequestHandler):
def handle(self):
while True:
msg = self.rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, mail_domain)
if res:
self.wfile.write(res.encode("ascii"))
self.wfile.flush()
try:
while True:
msg = self.rfile.readline().strip().decode()
if not msg:
break
res = handle_dovecot_request(msg, db, mail_domain)
if res:
self.wfile.write(res.encode("ascii"))
self.wfile.flush()
else:
logging.warn("request had no answer: %r", msg)
except Exception:
logging.exception("Exception in the handler")
raise
try:
os.unlink(socket)

View File

@@ -2,7 +2,7 @@
Description=Dict authentication proxy for dovecot
[Service]
ExecStart=/usr/local/bin/doveauth-dictproxy /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite
ExecStart=/usr/local/bin/doveauth /run/dovecot/doveauth.socket vmail /home/vmail/passdb.sqlite
Restart=always
RestartSec=30

View File

@@ -149,7 +149,7 @@ class SendRateLimiter:
def main():
args = sys.argv[1:]
assert len(args) == 1
logging.basicConfig(level=logging.INFO)
logging.basicConfig(level=logging.WARN)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
task = asyncmain_beforequeue(port=int(args[0]))

View File

@@ -0,0 +1,56 @@
import base64
import random
import logging
import os
import crypt
NOCREATE_FILE = "/etc/chatmail-nocreate"
def is_allowed_to_create(user, cleartext_password) -> bool:
"""Return True if user and password are admissable."""
if os.path.exists(NOCREATE_FILE):
logging.warning(f"blocked account creation because {NOCREATE_FILE!r} exists.")
return False
if len(cleartext_password) < 10:
logging.warning("Password needs to be at least 10 characters long")
return False
parts = user.split("@")
if len(parts) != 2:
logging.warning(f"user {user!r} is not a proper e-mail address")
return False
localpart, domain = parts
if domain == "nine.testrun.org":
# nine.testrun.org policy, username has to be exactly nine chars
if len(localpart) != 9:
logging.warning(f"localpart {localpart!r} has not exactly nine chars")
return False
return True
def gen_password():
with open("/dev/urandom", "rb") as f:
s = f.read(21)
return base64.b64encode(s).decode("ascii")[:12]
def encrypt_password(password: str):
# https://doc.dovecot.org/configuration_manual/authentication/password_schemes/
passhash = crypt.crypt(password, crypt.METHOD_SHA512)
return "{SHA512-CRYPT}" + passhash
def get_mail_domain():
with open("/etc/mailname", "r") as fp:
return fp.read().strip()
def get_valid_email_addr(length=9, chars="2345789acdefghjkmnpqrstuvwxyz"):
localpart = "".join(random.choice(chars) for i in range(length))
mail_domain = get_mail_domain()
return f"{localpart}@{mail_domain}"

View File

@@ -0,0 +1,48 @@
from flask import Flask, jsonify, request
import time
import os
from database import Database
from util import gen_password, get_valid_email_addr, encrypt_password
from doveauth import get_user_data
def create_app_from_db_path(db_path=None):
db = Database(db_path)
return create_app_from_db(db)
def create_app_from_db(db):
app = Flask("chatmaild-http")
app.db = db
@app.route("/", methods=["POST"])
def new_email():
for i in range(10):
addr = get_valid_email_addr()
if not get_user_data(db, addr):
cleartext_password = gen_password()
encrypted_password = encrypt_password(cleartext_password)
q = """INSERT INTO users (addr, password, last_login)
VALUES (?, ?, ?)"""
with db.write_transaction() as conn:
conn.execute(q, (addr, encrypted_password, int(time.time())))
return jsonify(
email=addr,
password=cleartext_password,
)
return jsonify(
type="error",
status_code=409,
reason="all 10 email addresses we tried are taken"
)
return app
def main():
"""(debugging-only!) serve http account creation Web API on localhost"""
db_path = os.getenv("CHATMAIL_DATABASE", "/home/vmail/passdb.sqlite")
app = create_app_from_db_path(db_path)
if __name__ == "__main__":
app.run(debug=True, host="localhost", port=3691)

View File

@@ -7,6 +7,7 @@ from pathlib import Path
from pyinfra import host
from pyinfra.operations import apt, files, server, systemd
from pyinfra.facts.files import File
from pyinfra.facts.systemd import SystemdEnabled
from .acmetool import deploy_acmetool
@@ -24,8 +25,8 @@ def _install_chatmaild() -> None:
)
apt.packages(
name="apt install python3-aiosmtpd",
packages=["python3-aiosmtpd", "python3-pip"],
name="apt install python3-aiosmtpd python3-pip python3-venv",
packages=["python3-aiosmtpd", "python3-pip", "python3-venv"],
)
# --no-deps because aiosmtplib is installed with `apt`.
@@ -34,8 +35,18 @@ def _install_chatmaild() -> None:
commands=[f"pip install --break-system-packages {remote_path}"],
)
# disable legacy doveauth-dictproxy.service
if host.get_fact(SystemdEnabled).get("doveauth-dictproxy.service"):
systemd.service(
name="Disable legacy doveauth-dictproxy.service",
service="doveauth-dictproxy.service",
running=False,
enabled=False,
)
for fn in (
"doveauth-dictproxy",
"doveauth",
"doveauth-http",
"filtermail",
):
files.put(
@@ -123,6 +134,44 @@ def _configure_opendkim(domain: str, dkim_selector: str) -> bool:
return need_restart
def _install_mta_sts_daemon() -> bool:
need_restart = False
config = files.put(
name="upload postfix-mta-sts-resolver config",
src=importlib.resources.files(__package__).joinpath(
"postfix/mta-sts-daemon.yml"
),
dest="/etc/mta-sts-daemon.yml",
user="root",
group="root",
mode="644",
)
need_restart |= config.changed
server.shell(
name="install postfix-mta-sts-resolver with pip",
commands=[
"python3 -m venv /usr/local/lib/postfix-mta-sts-resolver",
"/usr/local/lib/postfix-mta-sts-resolver/bin/pip install postfix-mta-sts-resolver",
],
)
systemd_unit = files.put(
name="upload mta-sts-daemon systemd unit",
src=importlib.resources.files(__package__).joinpath(
"postfix/mta-sts-daemon.service"
),
dest="/etc/systemd/system/mta-sts-daemon.service",
user="root",
group="root",
mode="644",
)
need_restart |= systemd_unit.changed
return need_restart
def _configure_postfix(domain: str, debug: bool = False) -> bool:
"""Configures Postfix SMTP server."""
need_restart = False
@@ -221,6 +270,16 @@ def _configure_nginx(domain: str, debug: bool = False) -> bool:
)
need_restart |= autoconfig.changed
mta_sts_config = files.template(
src=importlib.resources.files(__package__).joinpath("nginx/mta-sts.txt.j2"),
dest="/var/www/html/.well-known/mta-sts.txt",
user="root",
group="root",
mode="644",
config={"domain_name": domain},
)
need_restart |= mta_sts_config.changed
return need_restart
@@ -245,7 +304,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
)
# Deploy acmetool to have TLS certificates.
deploy_acmetool(nginx_hook=True, domains=[mail_server])
deploy_acmetool(nginx_hook=True, domains=[mail_server, f"mta-sts.{mail_server}"])
apt.packages(
name="Install Postfix",
@@ -276,6 +335,7 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
postfix_need_restart = _configure_postfix(mail_domain, debug=debug)
opendkim_need_restart = _configure_opendkim(mail_domain, dkim_selector)
nginx_need_restart = _configure_nginx(mail_domain)
mta_sts_need_restart = _install_mta_sts_daemon()
# deploy web pages and info if we have them
pkg_root = importlib.resources.files(__package__)
@@ -291,6 +351,15 @@ def deploy_chatmail(mail_domain: str, mail_server: str, dkim_selector: str) -> N
restarted=opendkim_need_restart,
)
systemd.service(
name="Start and enable MTA-STS daemon",
service="mta-sts-daemon.service",
daemon_reload=True,
running=True,
enabled=True,
restarted=mta_sts_need_restart,
)
systemd.service(
name="Start and enable Postfix",
service="postfix.service",

View File

@@ -46,8 +46,7 @@ def deploy_acmetool(nginx_hook=False, email="", domains=[]):
mode="644",
)
for domain in domains:
server.shell(
name=f"Request certificate for {domain}",
commands=[f"acmetool want {domain}"],
)
server.shell(
name=f"Request certificate for: { ', '.join(domains) }",
commands=[f"acmetool want { ' '.join(domains)}"],
)

View File

@@ -0,0 +1,4 @@
version: STSv1
mode: enforce
mx: {{ config.domain_name }}
max_age: 2419200

View File

@@ -42,6 +42,10 @@ http {
# as directory, then fall back to displaying a 404.
try_files $uri $uri/ =404;
}
location /new_email {
proxy_pass http://localhost:3691/;
}
}
}

View File

@@ -20,7 +20,7 @@ Domain {{ config.domain_name }}
Selector {{ config.opendkim_selector }}
KeyFile /etc/dkimkeys/{{ config.opendkim_selector }}.private
KeyTable /etc/dkimkeys/KeyTable
SigningTable /etc/dkimkeys/SigningTable
SigningTable refile:/etc/dkimkeys/SigningTable
# In Debian, opendkim runs as user "opendkim". A umask of 007 is required when
# using a local socket with MTAs that access the socket as a non-privileged

View File

@@ -23,6 +23,7 @@ smtpd_tls_security_level=may
smtp_tls_CApath=/etc/ssl/certs
smtp_tls_security_level=may
smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache
smtp_tls_policy_maps = socketmap:inet:127.0.0.1:8461:postfix
smtpd_relay_restrictions = permit_mynetworks permit_sasl_authenticated defer_unauth_destination
myhostname = {{ config.domain_name }}

View File

@@ -32,6 +32,7 @@ submission inet n - y - - smtpd
-o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_client_connection_count_limit=1000
-o smtpd_proxy_filter=127.0.0.1:10080
smtps inet n - y - - smtpd
-o syslog_name=postfix/smtps
@@ -46,6 +47,7 @@ smtps inet n - y - - smtpd
-o smtpd_sender_restrictions=$mua_sender_restrictions
-o smtpd_recipient_restrictions=
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_client_connection_count_limit=1000
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_proxy_filter=127.0.0.1:10080
#628 inet n - y - - qmqpd

View File

@@ -0,0 +1,10 @@
[Unit]
Description=Postfix MTA-STS resolver daemon
[Service]
ExecStart=/usr/local/lib/postfix-mta-sts-resolver/bin/mta-sts-daemon
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
host: 127.0.0.1
port: 8461
reuse_port: true
shutdown_timeout: 20
cache:
type: internal
options:
cache_size: 10000
proactive_policy_fetching:
enabled: true
default_zone:
strict_testing: false
timeout: 4

View File

@@ -15,6 +15,9 @@ _submission._tcp.$CHATMAIL_DOMAIN. SRV 0 1 587 $CHATMAIL_DOMAIN.
_submissions._tcp.$CHATMAIL_DOMAIN. SRV 0 1 465 $CHATMAIL_DOMAIN.
_imap._tcp.$CHATMAIL_DOMAIN. SRV 0 1 143 $CHATMAIL_DOMAIN.
_imaps._tcp.$CHATMAIL_DOMAIN. SRV 0 1 993 $CHATMAIL_DOMAIN.
$CHATMAIL_DOMAIN. IN CAA 0 issue "letsencrypt.org; accounturi=$ACME_ACCOUNT_URL"
$CHATMAIL_DOMAIN. IN CAA 128 issue "letsencrypt.org;accounturi=$ACME_ACCOUNT_URL"
_mta-sts.$CHATMAIL_DOMAIN. IN TXT "v=STSv1; id=$(date -u '+%Y%m%d%H%M')"
mta-sts.$CHATMAIL_DOMAIN. IN CNAME $CHATMAIL_DOMAIN.
_smtp._tls.$CHATMAIL_DOMAIN. IN TXT "v=TLSRPTv1;rua=mailto:$EMAIL"
EOF
$SSH opendkim-genzone -F | sed 's/^;.*$//;/^$/d'

View File

@@ -1,12 +1,12 @@
import json
import sys
import pytest
import threading
import queue
import traceback
import chatmaild.dictproxy
from chatmaild.dictproxy import get_user_data, lookup_passdb, handle_dovecot_request
import chatmaild.doveauth
from chatmaild.doveauth import get_user_data, lookup_passdb, handle_dovecot_request
from chatmaild.database import Database, DBError
@@ -30,7 +30,7 @@ def test_dont_overwrite_password_on_wrong_login(db):
def test_nocreate_file(db, monkeypatch, tmpdir):
p = tmpdir.join("nocreate")
p.write("")
monkeypatch.setattr(chatmaild.dictproxy, "NOCREATE_FILE", str(p))
monkeypatch.setattr(chatmaild.doveauth, "NOCREATE_FILE", str(p))
lookup_passdb(db, "newuser1@something.org", "zequ0Aimuchoodaechik")
assert not get_user_data(db, "newuser1@something.org")
@@ -60,27 +60,31 @@ def test_handle_dovecot_request(db):
assert userdata["password"].startswith("{SHA512-CRYPT}")
def test_100_concurrent_lookups(db):
num = 100
dbs = [Database(db.path) for i in range(num)]
print(f"created {num} databases")
def test_100_concurrent_lookups_different_accounts(db, gencreds):
num_threads = 100
req_per_thread = 5
results = queue.Queue()
def lookup(db):
try:
lookup_passdb(db, "something@c1.testrun.org", "Pieg9aeToe3eghuthe5u")
except Exception:
results.put(traceback.format_exc())
else:
results.put(None)
for i in range(req_per_thread):
addr, password = gencreds()
try:
lookup_passdb(db, addr, password)
except Exception:
results.put(traceback.format_exc())
else:
results.put(None)
threads = [threading.Thread(target=lookup, args=(db,), daemon=True) for db in dbs]
threads = []
for i in range(num_threads):
thread = threading.Thread(target=lookup, args=(db,), daemon=True)
threads.append(thread)
print(f"created {num} threads, starting them and waiting for results")
print(f"created {num_threads} threads, starting them and waiting for results")
for thread in threads:
thread.start()
for _ in dbs:
for i in range(num_threads * req_per_thread):
res = results.get()
if res is not None:
pytest.fail(f"concurrent lookup failed\n{res}")

View File

@@ -20,15 +20,14 @@
box-sizing: border-box;
padding: 9px;
font-size: 18px;
font-family: "Courier New", monospace;
color: white;
background-position: left top;
background-image: url(collage-bg.png);
background-repeat: no-repeat;
background-size: 100% 100%;
font-family: "Swansea", "Helvetica", sans-serif;
color: black;
}
a {
color: black;
}
h1, h2, h3 {
font-size: 16px;
font-size: 18px;
font-weight: bold;
}
</style>
@@ -37,24 +36,39 @@
<div class="wrapper">
<img class="section" src="collage-top.png" />
<div class="section text">
<h1>welcome to nine.testrun.org</h1>
<h1>Dear Delta Chat users and newcomers,</h1>
<p>
to get an account,
invent a word with <i>exactly</i> nine characters
and append @nine.testrun.org to it.
eg. <b>hellofits@nine.testrun.org</b>
welcome to the first public "chat-mail instance",
a small and lean e-mail provider for smooth chatting.
Install Delta Chat or add an account:
<ul>
<li>Tap "LOG INTO YOUR E-MAIL ACCOUNT".</li>
<li>Address: invent a word with <i>exactly</i> nine characters
and append @nine.testrun.org to it.</li>
<li>Password: invent at least 10 characters. The first login sets your password.</li>
</ul>
If the e-mail address is not yet taken, you'll get that account.
</p>
<p>
if the email address is not yet taken, you'll get that account.
the first login sets your password.
that's it.
</p>
</div>
<img class="section" src="collage-down.png" />
<div class="section text">
<h1>faq</h1>
<p><i>why are other email providers 1000 times more complicated?</i></p>
<p>because they want to for $reasons</p>
<img class="section" src="collage-down.png" />
<h2>What's behind it, how does it operate?</h2>
<p>nine.testrun.org is run
by a small group of devs and sysadmins, reachable via root@.
They want to keep this instance running at least until end 2024.
Current limits:
<ul>
<li>Un-encrypted mails can not leave the chat-mail instance.</li>
<li>Use <a href="https://delta.chat/en/help#howtoe2ee">
guaranteed end-to-end encryption via QR code scans</a>
to setup contact with users outside of the chat-mail instance.
</li>
<li>You may send up to 60 messages per minute.</li>
<li>Messages are unconditionally removed 40 days after arrival.</li>
<li>Max storage per user is 100MB.</li>
</ul>
<h2>Why are other email providers 1000 times more complicated?</h2>
<p>¯\_(ツ)_/¯</p>
</div>
</div>
</body>