Compare commits

...

8 Commits
1.4.0 ... 1.4.1

Author SHA1 Message Date
holger krekel
93423ee1d1 make another release 2024-07-31 21:59:55 +02:00
holger krekel
888f7e669a simplify handle_set method for dictproxy subclasses 2024-07-31 21:51:35 +02:00
link2xt
1f1d1fdf59 fix: use separate transaction storage for each DictProxy handler
DictProxy can have transactions with the same name
(most frequently `1`) processed in parallel.
Dovecot expects that transaction names on each connection
are independent.
2024-07-31 18:49:18 +00:00
zrknlzr
dcab097e00 www: update custom chatmail address steps 2024-07-31 20:25:10 +02:00
missytake
a9bdc3d1d0 www: add button to sign-up on chatmail server 2024-07-31 11:21:42 +02:00
holger krekel
a7101be284 introduce imap_rawlog option for debugging 2024-07-31 02:01:06 +02:00
holger krekel
3ee0b7e288 fix #385 2024-07-30 17:37:33 +02:00
link2xt
e3f0bb195d Fix doveauth logging for created accounts
Currently logs look like this:
`Created address: <chatmaild.user.User object at 0x7fafac36bcd0>`
2024-07-30 12:09:12 +02:00
17 changed files with 126 additions and 62 deletions

View File

@@ -1,5 +1,17 @@
# Changelog for chatmail deployment # Changelog for chatmail deployment
## 1.4.1 2024-07-31
- fix metadata dictproxy which would confuse transactions
resulting in missed notifications and other issues.
([#393](https://github.com/deltachat/chatmail/pull/388))
([#394](https://github.com/deltachat/chatmail/pull/389))
- add optional "imap_rawlog" config option. If true,
.in/.out files are created in user home dirs
containing the imap protocol messages.
([#389](https://github.com/deltachat/chatmail/pull/389))
## 1.4.0 2024-07-28 ## 1.4.0 2024-07-28
- Add `disable_ipv6` config option to chatmail.ini. - Add `disable_ipv6` config option to chatmail.ini.

View File

@@ -31,6 +31,7 @@ class Config:
self.filtermail_smtp_port = int(params["filtermail_smtp_port"]) self.filtermail_smtp_port = int(params["filtermail_smtp_port"])
self.postfix_reinject_port = int(params["postfix_reinject_port"]) self.postfix_reinject_port = int(params["postfix_reinject_port"])
self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true" self.disable_ipv6 = params.get("disable_ipv6", "false").lower() == "true"
self.imap_rawlog = params.get("imap_rawlog", "false").lower() == "true"
self.iroh_relay = params.get("iroh_relay") self.iroh_relay = params.get("iroh_relay")
self.privacy_postal = params.get("privacy_postal") self.privacy_postal = params.get("privacy_postal")
self.privacy_mail = params.get("privacy_mail") self.privacy_mail = params.get("privacy_mail")

View File

@@ -4,21 +4,24 @@ from socketserver import StreamRequestHandler, ThreadingUnixStreamServer
class DictProxy: class DictProxy:
def __init__(self):
self.transactions = {}
def loop_forever(self, rfile, wfile): def loop_forever(self, rfile, wfile):
# Transaction storage is local to each handler loop.
# Dovecot reuses transaction IDs across connections,
# starting transaction with the name `1`
# on two different connections to the same proxy sometimes.
transactions = {}
while True: while True:
msg = rfile.readline().strip().decode() msg = rfile.readline().strip().decode()
if not msg: if not msg:
break break
res = self.handle_dovecot_request(msg) res = self.handle_dovecot_request(msg, transactions)
if res: if res:
wfile.write(res.encode("ascii")) wfile.write(res.encode("ascii"))
wfile.flush() wfile.flush()
def handle_dovecot_request(self, msg): def handle_dovecot_request(self, msg, transactions):
# see https://doc.dovecot.org/developer_manual/design/dict_protocol/#dovecot-dict-protocol # see https://doc.dovecot.org/developer_manual/design/dict_protocol/#dovecot-dict-protocol
short_command = msg[0] short_command = msg[0]
parts = msg[1:].split("\t") parts = msg[1:].split("\t")
@@ -37,11 +40,14 @@ class DictProxy:
transaction_id = parts[0] transaction_id = parts[0]
if short_command == "B": if short_command == "B":
return self.handle_begin_transaction(transaction_id, parts) return self.handle_begin_transaction(transaction_id, parts, transactions)
elif short_command == "C": elif short_command == "C":
return self.handle_commit_transaction(transaction_id, parts) return self.handle_commit_transaction(transaction_id, parts, transactions)
elif short_command == "S": elif short_command == "S":
return self.handle_set(transaction_id, parts) addr = transactions[transaction_id]["addr"]
if not self.handle_set(addr, parts):
transactions[transaction_id]["res"] = "F\n"
logging.error(f"dictproxy-set failed for {addr!r}: {msg!r}")
def handle_lookup(self, parts): def handle_lookup(self, parts):
logging.warning(f"lookup ignored: {parts!r}") logging.warning(f"lookup ignored: {parts!r}")
@@ -52,19 +58,18 @@ class DictProxy:
# If we don't return empty line Dovecot will timeout. # If we don't return empty line Dovecot will timeout.
return "\n" return "\n"
def handle_begin_transaction(self, transaction_id, parts): def handle_begin_transaction(self, transaction_id, parts, transactions):
addr = parts[1] addr = parts[1]
self.transactions[transaction_id] = dict(addr=addr, res="O\n") transactions[transaction_id] = dict(addr=addr, res="O\n")
def handle_set(self, transaction_id, parts): def handle_set(self, addr, parts):
# For documentation on key structure see # For documentation on key structure see
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h # https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
return False
self.transactions[transaction_id]["res"] = "F\n" def handle_commit_transaction(self, transaction_id, parts, transactions):
def handle_commit_transaction(self, transaction_id, parts):
# return whatever "set" command(s) set as result. # return whatever "set" command(s) set as result.
return self.transactions.pop(transaction_id)["res"] return transactions.pop(transaction_id)["res"]
def serve_forever_from_socket(self, socket): def serve_forever_from_socket(self, socket):
dictproxy = self dictproxy = self

View File

@@ -141,7 +141,7 @@ class AuthDictProxy(DictProxy):
return return
user.set_password(encrypt_password(cleartext_password)) user.set_password(encrypt_password(cleartext_password))
print(f"Created address: {user}", file=sys.stderr) print(f"Created address: {addr}", file=sys.stderr)
return user.get_userdb_dict() return user.get_userdb_dict()

View File

@@ -54,6 +54,17 @@ postfix_reinject_port = 10025
# if set to "True" IPv6 is disabled # if set to "True" IPv6 is disabled
disable_ipv6 = False disable_ipv6 = False
#
# Debugging options
#
# set to True if you want to track imap protocol execution
# in per-maildir ".in/.out" files.
# Note that you need to manually cleanup these files
# so use this option with caution on production servers.
imap_rawlog = false
# #
# Privacy Policy # Privacy Policy
# #

View File

@@ -9,20 +9,19 @@ class LastLoginDictProxy(DictProxy):
super().__init__() super().__init__()
self.config = config self.config = config
def handle_set(self, transaction_id, parts): def handle_set(self, addr, parts):
keyname = parts[1].split("/") keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else "" value = parts[2] if len(parts) > 2 else ""
addr = self.transactions[transaction_id]["addr"]
if keyname[0] == "shared" and keyname[1] == "last-login": if keyname[0] == "shared" and keyname[1] == "last-login":
if addr.startswith("echo@"): if addr.startswith("echo@"):
return return True
addr = keyname[2] addr = keyname[2]
timestamp = int(value) timestamp = int(value)
user = self.config.get_user(addr) user = self.config.get_user(addr)
user.set_last_login_timestamp(timestamp) user.set_last_login_timestamp(timestamp)
else: return True
# Transaction failed.
self.transactions[transaction_id]["res"] = "F\n" return False
def main(): def main():

View File

@@ -62,24 +62,19 @@ class MetadataDictProxy(DictProxy):
logging.warning(f"lookup ignored: {parts!r}") logging.warning(f"lookup ignored: {parts!r}")
return "N\n" return "N\n"
def handle_set(self, transaction_id, parts): def handle_set(self, addr, parts):
# For documentation on key structure see # For documentation on key structure see
# https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h # https://github.com/dovecot/core/blob/main/src/lib-storage/mailbox-attribute.h
keyname = parts[1].split("/") keyname = parts[1].split("/")
value = parts[2] if len(parts) > 2 else "" value = parts[2] if len(parts) > 2 else ""
addr = self.transactions[transaction_id]["addr"]
if keyname[0] == "priv" and keyname[2] == self.metadata.DEVICETOKEN_KEY: if keyname[0] == "priv" and keyname[2] == self.metadata.DEVICETOKEN_KEY:
self.metadata.add_token_to_addr(addr, value) self.metadata.add_token_to_addr(addr, value)
return True
elif keyname[0] == "priv" and keyname[2] == "messagenew": elif keyname[0] == "priv" and keyname[2] == "messagenew":
self.notifier.new_message_for_addr(addr, self.metadata) self.notifier.new_message_for_addr(addr, self.metadata)
else: return True
# Transaction failed.
try: return False
self.transactions[transaction_id]["res"] = "F\n"
except KeyError:
logging.error(
f"could not mark tx as failed: {transaction_id} {self.transactions}"
)
def main(): def main():

View File

@@ -72,12 +72,13 @@ def test_nocreate_file(monkeypatch, tmpdir, dictproxy):
def test_handle_dovecot_request(dictproxy): def test_handle_dovecot_request(dictproxy):
transactions = {}
# Test that password can contain ", ', \ and / # Test that password can contain ", ', \ and /
msg = ( msg = (
'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"' 'Lshared/passdb/laksjdlaksjdlak\\\\sjdlk\\"12j\\\'3l1/k2j3123"'
"some42123@chat.example.org\tsome42123@chat.example.org" "some42123@chat.example.org\tsome42123@chat.example.org"
) )
res = dictproxy.handle_dovecot_request(msg) res = dictproxy.handle_dovecot_request(msg, transactions)
assert res assert res
assert res[0] == "O" and res.endswith("\n") assert res[0] == "O" and res.endswith("\n")
userdata = json.loads(res[1:].strip()) userdata = json.loads(res[1:].strip())

View File

@@ -12,28 +12,30 @@ def test_handle_dovecot_request_last_login(testaddr, example_config):
authproxy = AuthDictProxy(config=example_config) authproxy = AuthDictProxy(config=example_config)
authproxy.lookup_passdb(testaddr, "1l2k3j1l2k3jl123") authproxy.lookup_passdb(testaddr, "1l2k3j1l2k3jl123")
dictproxy_transactions = {}
# Begin transaction # Begin transaction
tx = "1111" tx = "1111"
msg = f"B{tx}\t{testaddr}" msg = f"B{tx}\t{testaddr}"
res = dictproxy.handle_dovecot_request(msg) res = dictproxy.handle_dovecot_request(msg, dictproxy_transactions)
assert not res assert not res
assert dictproxy.transactions == {tx: dict(addr=testaddr, res="O\n")} assert dictproxy_transactions == {tx: dict(addr=testaddr, res="O\n")}
# set last-login info for user # set last-login info for user
user = dictproxy.config.get_user(testaddr) user = dictproxy.config.get_user(testaddr)
timestamp = int(time.time()) timestamp = int(time.time())
msg = f"S{tx}\tshared/last-login/{testaddr}\t{timestamp}" msg = f"S{tx}\tshared/last-login/{testaddr}\t{timestamp}"
res = dictproxy.handle_dovecot_request(msg) res = dictproxy.handle_dovecot_request(msg, dictproxy_transactions)
assert not res assert not res
assert len(dictproxy.transactions) == 1 assert len(dictproxy_transactions) == 1
read_timestamp = user.get_last_login_timestamp() read_timestamp = user.get_last_login_timestamp()
assert read_timestamp == timestamp // 86400 * 86400 assert read_timestamp == timestamp // 86400 * 86400
# finish transaction # finish transaction
msg = f"C{tx}" msg = f"C{tx}"
res = dictproxy.handle_dovecot_request(msg) res = dictproxy.handle_dovecot_request(msg, dictproxy_transactions)
assert res == "O\n" assert res == "O\n"
assert len(dictproxy.transactions) == 0 assert len(dictproxy_transactions) == 0
def test_handle_dovecot_request_last_login_echobot(example_config): def test_handle_dovecot_request_last_login_echobot(example_config):
@@ -44,17 +46,19 @@ def test_handle_dovecot_request_last_login_echobot(example_config):
authproxy.lookup_passdb(testaddr, "ignore") authproxy.lookup_passdb(testaddr, "ignore")
user = dictproxy.config.get_user(testaddr) user = dictproxy.config.get_user(testaddr)
transactions = {}
# set last-login info for user # set last-login info for user
tx = "1111" tx = "1111"
msg = f"B{tx}\t{testaddr}" msg = f"B{tx}\t{testaddr}"
res = dictproxy.handle_dovecot_request(msg) res = dictproxy.handle_dovecot_request(msg, transactions)
assert not res assert not res
assert dictproxy.transactions == {tx: dict(addr=testaddr, res="O\n")} assert transactions == {tx: dict(addr=testaddr, res="O\n")}
timestamp = int(time.time()) timestamp = int(time.time())
msg = f"S{tx}\tshared/last-login/{testaddr}\t{timestamp}" msg = f"S{tx}\tshared/last-login/{testaddr}\t{timestamp}"
res = dictproxy.handle_dovecot_request(msg) res = dictproxy.handle_dovecot_request(msg, transactions)
assert not res assert not res
assert len(dictproxy.transactions) == 1 assert len(transactions) == 1
read_timestamp = user.get_last_login_timestamp() read_timestamp = user.get_last_login_timestamp()
assert read_timestamp is None assert read_timestamp is None

View File

@@ -88,42 +88,45 @@ def test_notifier_remove_without_set(metadata, testaddr):
def test_handle_dovecot_request_lookup_fails(dictproxy, testaddr): def test_handle_dovecot_request_lookup_fails(dictproxy, testaddr):
res = dictproxy.handle_dovecot_request(f"Lpriv/123/chatmail\t{testaddr}") transactions = {}
res = dictproxy.handle_dovecot_request(
f"Lpriv/123/chatmail\t{testaddr}", transactions
)
assert res == "N\n" assert res == "N\n"
def test_handle_dovecot_request_happy_path(dictproxy, testaddr, token): def test_handle_dovecot_request_happy_path(dictproxy, testaddr, token):
metadata = dictproxy.metadata metadata = dictproxy.metadata
transactions = dictproxy.transactions transactions = {}
notifier = dictproxy.notifier notifier = dictproxy.notifier
# set device token in a transaction # set device token in a transaction
tx = "1111" tx = "1111"
msg = f"B{tx}\t{testaddr}" msg = f"B{tx}\t{testaddr}"
res = dictproxy.handle_dovecot_request(msg) res = dictproxy.handle_dovecot_request(msg, transactions)
assert not res and not metadata.get_tokens_for_addr(testaddr) assert not res and not metadata.get_tokens_for_addr(testaddr)
assert transactions == {tx: dict(addr=testaddr, res="O\n")} assert transactions == {tx: dict(addr=testaddr, res="O\n")}
msg = f"S{tx}\tpriv/guid00/devicetoken\t{token}" msg = f"S{tx}\tpriv/guid00/devicetoken\t{token}"
res = dictproxy.handle_dovecot_request(msg) res = dictproxy.handle_dovecot_request(msg, transactions)
assert not res assert not res
assert len(transactions) == 1 assert len(transactions) == 1
assert metadata.get_tokens_for_addr(testaddr) == [token] assert metadata.get_tokens_for_addr(testaddr) == [token]
msg = f"C{tx}" msg = f"C{tx}"
res = dictproxy.handle_dovecot_request(msg) res = dictproxy.handle_dovecot_request(msg, transactions)
assert res == "O\n" assert res == "O\n"
assert len(transactions) == 0 assert len(transactions) == 0
assert metadata.get_tokens_for_addr(testaddr) == [token] assert metadata.get_tokens_for_addr(testaddr) == [token]
# trigger notification for incoming message # trigger notification for incoming message
tx2 = "2222" tx2 = "2222"
assert dictproxy.handle_dovecot_request(f"B{tx2}\t{testaddr}") is None assert dictproxy.handle_dovecot_request(f"B{tx2}\t{testaddr}", transactions) is None
msg = f"S{tx2}\tpriv/guid00/messagenew" msg = f"S{tx2}\tpriv/guid00/messagenew"
assert dictproxy.handle_dovecot_request(msg) is None assert dictproxy.handle_dovecot_request(msg, transactions) is None
queue_item = notifier.retry_queues[0].get()[1] queue_item = notifier.retry_queues[0].get()[1]
assert queue_item.token == token assert queue_item.token == token
assert dictproxy.handle_dovecot_request(f"C{tx2}") == "O\n" assert dictproxy.handle_dovecot_request(f"C{tx2}", transactions) == "O\n"
assert not transactions assert not transactions
assert queue_item.path.exists() assert queue_item.path.exists()

View File

@@ -12,11 +12,11 @@ def test_get_user_dict_not_set(testaddr, example_config, caplog):
user = example_config.get_user(testaddr) user = example_config.get_user(testaddr)
assert not caplog.records assert not caplog.records
assert user.get_userdb_dict() == {} assert user.get_userdb_dict() == {}
assert len(caplog.records) == 1 assert len(caplog.records) == 0
user.set_password("") user.set_password("")
assert user.get_userdb_dict() == {} assert user.get_userdb_dict() == {}
assert len(caplog.records) == 2 assert len(caplog.records) == 1
def test_get_user_dict(make_config, tmp_path): def test_get_user_dict(make_config, tmp_path):

View File

@@ -26,7 +26,6 @@ class User:
try: try:
pw = self.password_path.read_text() pw = self.password_path.read_text()
except FileNotFoundError: except FileNotFoundError:
logging.error(f"password not set for: {self.addr}")
return {} return {}
if not pw: if not pw:

View File

@@ -12,11 +12,10 @@ import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from packaging import version
import pyinfra import pyinfra
from chatmaild.config import read_config, write_initial_config from chatmaild.config import read_config, write_initial_config
from packaging import version
from termcolor import colored from termcolor import colored
from . import dns, remote_funcs from . import dns, remote_funcs

View File

@@ -203,3 +203,24 @@ ssl_key = </var/lib/acme/live/{{ config.mail_domain }}/privkey
ssl_dh = </usr/share/dovecot/dh.pem ssl_dh = </usr/share/dovecot/dh.pem
ssl_min_protocol = TLSv1.2 ssl_min_protocol = TLSv1.2
ssl_prefer_server_ciphers = yes ssl_prefer_server_ciphers = yes
{% if config.imap_rawlog %}
service postlogin {
executable = script-login -d rawlog
unix_listener postlogin {
}
}
service imap {
executable = imap postlogin
}
protocol imap {
#rawlog_dir = /tmp/rawlog/%u
# Put .in and .out imap protocol logging files into per-user homedir
# You can use a command like this to combine into one protocol stream:
# sort -sn <(sed 's/ / C: /' *.in) <(sed 's/ / S: /' cat *.out)
rawlog_dir = %h
}
{% endif %}

View File

@@ -11,7 +11,11 @@ for Delta Chat users. For details how it avoids storing personal information
please see our [privacy policy](privacy.html). please see our [privacy policy](privacy.html).
{% endif %} {% endif %}
👉 **Tap** or scan this QR code to get a `@{{config.mail_domain}}` chat profile <a class="cta-button" href="DCACCOUNT:https://{{ config.mail_domain }}/new">Get a {{config.mail_domain}} chat profile</a>
If you are viewing this page on a different device
without a Delta Chat app,
you can also **scan this QR code** with Delta Chat:
<a href="DCACCOUNT:https://{{ config.mail_domain }}/new"> <a href="DCACCOUNT:https://{{ config.mail_domain }}/new">
<img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a> <img width=300 style="float: none;" src="qr-chatmail-invite-{{config.mail_domain}}.png" /></a>

View File

@@ -8,11 +8,9 @@ for the usage in chats, especially DeltaChat.
### Choosing a chatmail address instead of using a random one ### Choosing a chatmail address instead of using a random one
In the Delta Chat account setup 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:
you may tap `I already have a profile`
and fill the two fields like this:
- `Address`: invent a word with - `E-Mail Address`: invent a word with
{% if username_min_length == username_max_length %} {% if username_min_length == username_max_length %}
*exactly* {{ username_min_length }} *exactly* {{ username_min_length }}
{% else %} {% else %}
@@ -26,7 +24,7 @@ and fill the two fields like this:
characters characters
and append `@{{config.mail_domain}}` to it. and append `@{{config.mail_domain}}` to it.
- `Password`: invent at least {{ password_min_length }} characters. - `Existing Password`: invent at least {{ password_min_length }} characters.
If the e-mail address is not yet taken, you'll get that account. If the e-mail address is not yet taken, you'll get that account.
The first login sets your password. The first login sets your password.

View File

@@ -72,3 +72,15 @@ code {
color: red; color: red;
font-weight: bold; font-weight: bold;
} }
.cta-button, .cta-button:hover, .cta-button:visited {
border: 1.5px solid #a4c2d0;
border-radius: 5px;
padding: 10px;
display: inline-block;
margin: 10px 0;
background: linear-gradient(120deg, #77888f, #364e59);
color: white !important;
font-weight: bold;
}