Copy directly-sent e-mails to an IMAP Sent folder, with a live folder picker

A raw SMTP send has no server-side "Sent" copy on its own (unlike IMAP
drafts, which are inherently server-side) -- add an opt-in checkbox plus a
configurable target folder so directly sent Mahnungen/SEPA-info-mails still
show up in the account's Gesendet/Sent folder like a normal mail client
would leave them. Applies only to "send" delivery; drafts already live on
the server by definition.

Both the Entwürfe- and Gesendet-folder fields are now editable comboboxes:
a new "Ordnerliste laden" button fetches the real folder list from the IMAP
server (needs working credentials first) via LIST, decoding folder names
from modified UTF-7 (RFC 3501) so names like "Entwürfe" render correctly
instead of as "Entw&APw-rfe". Free text still works -- ensure_imap_folder()
creates the folder on first use if it doesn't exist yet, checked once per
batch rather than before every single message.

mail_delivery.append_to_imap_drafts() became the more general
append_message(client, content, folder=, flags=), reused for both the
\Draft and \Seen cases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-15 04:34:34 +02:00
co-authored by Claude Sonnet 5
parent 6b0da82b45
commit cc4aaef895
9 changed files with 505 additions and 28 deletions
+98
View File
@@ -290,3 +290,101 @@ def test_generated_mail_send_mode_reuses_one_smtp_connection_for_batch(tmp_path,
assert generated[0].archive_path.is_file()
assert len(connections_opened) == 1, "one connection should be reused for the whole batch"
assert len(sent) == len(debits)
def test_generated_mail_send_mode_copies_to_sent_folder_reusing_one_imap_connection(
tmp_path, monkeypatch
):
import ccma.services.sepa_mail as sepa_mail_module
repository, ada = _repository(tmp_path)
second = repository.create_member(first_name="Grace", last_name="Hopper", member_number="C3-43")
second.status = "active"
second.email = "grace@example.org"
second.account_holder = "Grace Hopper"
second.iban = "DE89370400440532013000"
second.bic = "COBADEFFXXX"
second.mandate_reference = "MANDAT-43"
second.mandate_signed_at = "2025-01-10"
second.mandate_active = True
repository.save_member(second)
repository.save_contributions(
second.member_id,
ContributionData(
claims=[
{
"claim_id": "due",
"title": "Mitgliedsbeitrag 2026",
"amount": "150.00",
"due_date": "2026-01-31",
"status": "open",
}
]
),
)
organization = repository.get_configuration()["organization"]
organization.update(
{
"name": "Chaos Computer Club Mannheim e.V.",
"email": "verwaltung@example.org",
"iban": "DE89370400440532013000",
"bic": "COBADEFFXXX",
"creditor_id": "DE98ZZZ09999999999",
}
)
repository.save_organization(organization)
repository.save_email_settings(
delivery_mode="send",
smtp_host="mail.example.org",
smtp_port=587,
smtp_security="none",
smtp_username="",
smtp_password="",
imap_host="mail.example.org",
imap_port=993,
imap_security="ssl",
imap_username="",
imap_password="",
imap_drafts_folder="",
imap_sent_enabled=True,
imap_sent_folder="INBOX.Gesendet",
)
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
assert len(debits) == 2
monkeypatch.setattr(
sepa_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
)
monkeypatch.setattr(sepa_mail_module, "send_via_smtp", lambda client, content: None)
imap_connections = []
@contextmanager
def fake_imap_session(settings):
imap_connections.append(settings)
yield "imap-client"
monkeypatch.setattr(sepa_mail_module, "imap_session", fake_imap_session)
ensured = []
appended = []
monkeypatch.setattr(
sepa_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append(folder)
)
monkeypatch.setattr(
sepa_mail_module,
"append_message",
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
)
generate_debit_mails(
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="send",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert len(imap_connections) == 1, "one IMAP connection should be reused for the whole batch"
assert ensured == ["INBOX.Gesendet"]
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")] * 2