From d6e5ee4dbaac9b6caf31885fb4183a1d7dc8453f Mon Sep 17 00:00:00 2001 From: j4n Date: Tue, 18 Aug 2026 17:50:21 +0200 Subject: [PATCH 1/3] feat: add -n/--name to set the account display name --- cmsend.py | 22 +++++++++++++++++++++- test_cmsend.py | 16 ++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/cmsend.py b/cmsend.py index 976d4c8..7c612f4 100644 --- a/cmsend.py +++ b/cmsend.py @@ -33,6 +33,14 @@ def main(argv=None): default="GENESIS", help="use the specified tag for joining a chat or sending a message (default: GENESIS)", ) + parser.add_argument( + "-n", + "--name", + type=str, + dest="name", + default=None, + help="set the account display name", + ) parser.add_argument( "-l", dest="listtags", action="store_true", help="list existing tagged chats" ) @@ -67,7 +75,12 @@ def perform_main(args): if args.relay: profile.perform_init(domain=args.relay) - elif args.invitelink: + if args.name: + profile.perform_setname(args.name) + if args.relay or args.name: + return + + if args.invitelink: profile.perform_join(tag=args.tag, invitelink=args.invitelink) elif args.listtags: profile.perform_listtags() @@ -118,6 +131,13 @@ def perform_init(self, domain): account.wait_for_event(EventType.IMAP_INBOX_IDLE) self.verbose1(f"profile {self!r} is configured and active now") + def perform_setname(self, name): + if self._account is None: + print("you must first call --init to setup a profile", file=sys.stderr) + raise SystemExit(4) + self._account.set_config("displayname", name) + self.verbose1(f"set display name to {name!r}") + def perform_join(self, tag, invitelink): if self._account is None: print("you must first call --init to setup a profile", file=sys.stderr) diff --git a/test_cmsend.py b/test_cmsend.py index 863ec17..1acdeaf 100644 --- a/test_cmsend.py +++ b/test_cmsend.py @@ -54,3 +54,19 @@ def test_init_join_and_send(acfactory, run_cmsend, invite): event = ac.wait_for_incoming_msg_event() snapshot = ac.get_message_by_id(event.msg_id).get_snapshot() assert snapshot.text == "hello from cmsend" + + +def test_name_reaches_recipient(acfactory, run_cmsend): + (ac,) = acfactory.get_online_accounts(1) + + run_cmsend("--init", ci_chatmail_domain, timeout=120) + run_cmsend("--name", "CI Bot") + + run_cmsend("-t", "LOG", "--join", ac.get_qr_code()) + run_cmsend("-t", "LOG", "-m", "named hello") + + event = ac.wait_for_incoming_msg_event() + snapshot = ac.get_message_by_id(event.msg_id).get_snapshot() + assert snapshot.text == "named hello" + sender = ac.get_contact_by_id(snapshot.from_id).get_snapshot() + assert sender.display_name == "CI Bot" From 5844bbc9787a485d4b27e987763ab287a5304adb Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 19 Aug 2026 08:45:52 +0200 Subject: [PATCH 2/3] feat: add --fetch-only to keep profile in sync and --shared to set bcc_self=1 This adds the ability for cloned profiles which run periodically and store state in between to stay in touch with group updates. --- README.md | 5 +++++ cmsend.py | 39 +++++++++++++++++++++++++++++++++++---- test_cmsend.py | 22 ++++++++++++++++++++++ 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 041a852..75167a3 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,11 @@ To list all chats with tags: cmsend -l +To fetch (group memberships and keys) without sending anything, e.g., for +cloned profiles, and to keep account alive on the relay: + + cmsend --fetch-only + To send a message to a tagged chat with an attachment: cmsend -t LOG -m "here is the file" -a README.md diff --git a/cmsend.py b/cmsend.py index 7c612f4..876e82e 100644 --- a/cmsend.py +++ b/cmsend.py @@ -4,6 +4,7 @@ import argparse import sys +import threading import time from deltachat_rpc_client import DeltaChat, EventType, Rpc @@ -44,6 +45,16 @@ def main(argv=None): parser.add_argument( "-l", dest="listtags", action="store_true", help="list existing tagged chats" ) + parser.add_argument( + "--fetch-only", + action="store_true", + help="only fetch pending messages, e.g., updated group info, and exit", + ) + parser.add_argument( + "--shared", + action="store_true", + help="use with --init for cloned profiles to keep messages on relay and --fetch-only before send", + ) parser.add_argument( "-m", type=str, @@ -74,7 +85,7 @@ def perform_main(args): profile = Profile(dc, verbosity=args.verbose) if args.relay: - profile.perform_init(domain=args.relay) + profile.perform_init(domain=args.relay, shared=args.shared) if args.name: profile.perform_setname(args.name) if args.relay or args.name: @@ -84,6 +95,11 @@ def perform_main(args): profile.perform_join(tag=args.tag, invitelink=args.invitelink) elif args.listtags: profile.perform_listtags() + elif args.fetch_only: + if not profile._account: + print("profile is not configured, run --init") + raise SystemExit(2) + profile.perform_fetch() else: if not profile._account: print("profile is not configured, run --init") @@ -120,13 +136,16 @@ def verbose2(self, msg): if self.verbosity >= 2: print(msg) - def perform_init(self, domain): + def perform_init(self, domain, shared=False): if self._account: print(f"profile {self!r} already exists", file=sys.stderr) raise SystemExit(3) print(f"# creating profile on {domain}") self._account = account = self.dc.add_account() account.set_config_from_qr(f"dcaccount:{domain}") + if shared: + account.set_config("bcc_self", "1") + account.set_config("delete_device_after", str(30 * 86400)) account.start_io() account.wait_for_event(EventType.IMAP_INBOX_IDLE) self.verbose1(f"profile {self!r} is configured and active now") @@ -187,8 +206,20 @@ def perform_listtags(self): for contact in chat.get_contacts(): print(f" - {contact.get_snapshot().name_and_addr}") - def perform_send(self, tag, text, filename=None): - self._account.start_io() + def perform_fetch(self): + self._account.bring_online() + self.verbose1("inbox is up to date") + + def perform_send(self, tag, text, filename=None, sync_timeout=60): + if self._account.get_config("bcc_self") == "1": + # For shared accounts, try the blocking fetch in a thread to not risk delivery + fetcher = threading.Thread(target=self.perform_fetch, daemon=True) + fetcher.start() + fetcher.join(sync_timeout) + if fetcher.is_alive(): + print(f"# inbox not synced after {sync_timeout}s, sending anyway") + else: + self._account.start_io() chat = self.get_tagged_chat(tag) snap = chat.get_full_snapshot() diff --git a/test_cmsend.py b/test_cmsend.py index 1acdeaf..c761159 100644 --- a/test_cmsend.py +++ b/test_cmsend.py @@ -70,3 +70,25 @@ def test_name_reaches_recipient(acfactory, run_cmsend): assert snapshot.text == "named hello" sender = ac.get_contact_by_id(snapshot.from_id).get_snapshot() assert sender.display_name == "CI Bot" + + +def test_send_reaches_member_added_while_offline(acfactory, run_cmsend): + ac1, ac2 = acfactory.get_online_accounts(2) + + run_cmsend("--init", ci_chatmail_domain, "--shared", timeout=120) + group = ac1.create_group("cmsend log") + invitelink = group.get_qr_code() + run_cmsend("-t", "LOG", "--join", invitelink) + + ac2.secure_join(invitelink) + ac1.wait_for_securejoin_inviter_success() + ac2.wait_for_securejoin_joiner_success() + + run_cmsend("-t", "LOG", "-m", "hello newcomer", timeout=180) + + for _ in range(10): # skip the "member added" info message + event = ac2.wait_for_incoming_msg_event() + snapshot = ac2.get_message_by_id(event.msg_id).get_snapshot() + if snapshot.text == "hello newcomer": + return + pytest.fail("the added member never received the message") From 4202463878e132a4afaac458ac278334b68d67ef Mon Sep 17 00:00:00 2001 From: j4n Date: Wed, 26 Aug 2026 14:55:20 +0200 Subject: [PATCH 3/3] fix: TEMPORARILY pin core to 2.58.0 core >=2.59 sends secure-join request as plain text, which filtermail rejects as unencrypted, and --join hangs on group invites. Fixed in chatmail/core#8610 but not yet released. --- pyproject.toml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 862cbb3..3c2e2c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,9 +8,11 @@ readme = "README.md" description = "send end-to-end encrypted messages to chats on chatmail relays" requires-python = ">=3.11" dependencies = [ - # core 2.38.0 removed "contacts" from FullChat, stay safely above it - "deltachat-rpc-server>=2.57.0", - "deltachat-rpc-client>=2.57.0", + # core 2.38.0 removed "contacts" from FullChat, stay safely above it. + # TEMPORARY: core >=2.59 sends a secure-join MIME filtermail relays reject, + # hanging --join (chatmail/core#8608, fixed on main but not yet released). + "deltachat-rpc-server==2.58.0", + "deltachat-rpc-client==2.58.0", "xdg-base-dirs>=6.0.2", ]