Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 56 additions & 5 deletions cmsend.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import argparse
import sys
import threading
import time

from deltachat_rpc_client import DeltaChat, EventType, Rpc
Expand Down Expand Up @@ -33,9 +34,27 @@ 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"
)
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,
Expand Down Expand Up @@ -66,11 +85,21 @@ def perform_main(args):
profile = Profile(dc, verbosity=args.verbose)

if args.relay:
profile.perform_init(domain=args.relay)
elif args.invitelink:
profile.perform_init(domain=args.relay, shared=args.shared)
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()
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")
Expand Down Expand Up @@ -107,17 +136,27 @@ 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")

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)
Expand Down Expand Up @@ -167,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()
Expand Down
8 changes: 5 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]

Expand Down
38 changes: 38 additions & 0 deletions test_cmsend.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,3 +54,41 @@ 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"


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")
Loading