mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
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:
co-authored by
Claude Sonnet 5
parent
6b0da82b45
commit
cc4aaef895
@@ -12,6 +12,8 @@ def test_email_settings_default_to_local_delivery_for_new_repositories(tmp_path)
|
||||
assert settings["delivery_mode"] == "local"
|
||||
assert settings["smtp_host"] == ""
|
||||
assert settings["imap_host"] == ""
|
||||
assert settings["imap_sent_enabled"] is False
|
||||
assert settings["imap_sent_folder"] == "INBOX.Sent"
|
||||
|
||||
|
||||
def test_email_settings_round_trip_through_save_and_get(tmp_path) -> None:
|
||||
@@ -31,6 +33,8 @@ def test_email_settings_round_trip_through_save_and_get(tmp_path) -> None:
|
||||
imap_username="board@example.org",
|
||||
imap_password="secret",
|
||||
imap_drafts_folder="INBOX.Entwürfe",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="INBOX.Gesendet",
|
||||
)
|
||||
|
||||
settings = repository.get_email_settings()
|
||||
@@ -39,6 +43,31 @@ def test_email_settings_round_trip_through_save_and_get(tmp_path) -> None:
|
||||
assert settings["smtp_port"] == 587
|
||||
assert settings["smtp_password"] == "secret"
|
||||
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
|
||||
assert settings["imap_sent_enabled"] is True
|
||||
assert settings["imap_sent_folder"] == "INBOX.Gesendet"
|
||||
|
||||
|
||||
def test_email_settings_sent_copy_requires_imap_host_even_in_send_only_mode(tmp_path) -> None:
|
||||
repository = MemberRepository(tmp_path)
|
||||
repository.initialize()
|
||||
|
||||
with pytest.raises(RepositoryError, match="Gesendet-Ordner ist ein IMAP-Server erforderlich"):
|
||||
repository.save_email_settings(
|
||||
delivery_mode="send",
|
||||
smtp_host="mail.example.org",
|
||||
smtp_port=587,
|
||||
smtp_security="starttls",
|
||||
smtp_username="",
|
||||
smtp_password="",
|
||||
imap_host="",
|
||||
imap_port=993,
|
||||
imap_security="ssl",
|
||||
imap_username="",
|
||||
imap_password="",
|
||||
imap_drafts_folder="",
|
||||
imap_sent_enabled=True,
|
||||
imap_sent_folder="",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -33,6 +33,7 @@ class _FakeSmtpClient:
|
||||
|
||||
class _FakeImapClient:
|
||||
instances: list["_FakeImapClient"] = []
|
||||
default_list_result: tuple = ("OK", [])
|
||||
error = Exception
|
||||
|
||||
def __init__(self, host, port, **kwargs):
|
||||
@@ -43,6 +44,11 @@ class _FakeImapClient:
|
||||
self.login_args: tuple | None = None
|
||||
self.appended: list[tuple] = []
|
||||
self.append_result = ("OK", [b"APPEND completed"])
|
||||
self.selected: list[str] = []
|
||||
self.select_result = ("OK", [b"1"])
|
||||
self.created: list[str] = []
|
||||
self.create_result = ("OK", [b"CREATE completed"])
|
||||
self.list_result = _FakeImapClient.default_list_result
|
||||
_FakeImapClient.instances.append(self)
|
||||
|
||||
def starttls(self, *args, **kwargs):
|
||||
@@ -57,6 +63,20 @@ class _FakeImapClient:
|
||||
self.calls.append("append")
|
||||
return self.append_result
|
||||
|
||||
def select(self, folder, readonly=False):
|
||||
self.selected.append(folder)
|
||||
self.calls.append("select")
|
||||
return self.select_result
|
||||
|
||||
def create(self, folder):
|
||||
self.created.append(folder)
|
||||
self.calls.append("create")
|
||||
return self.create_result
|
||||
|
||||
def list(self):
|
||||
self.calls.append("list")
|
||||
return self.list_result
|
||||
|
||||
def logout(self):
|
||||
self.calls.append("logout")
|
||||
|
||||
@@ -65,6 +85,7 @@ class _FakeImapClient:
|
||||
def _reset_fakes():
|
||||
_FakeSmtpClient.instances.clear()
|
||||
_FakeImapClient.instances.clear()
|
||||
_FakeImapClient.default_list_result = ("OK", [])
|
||||
yield
|
||||
|
||||
|
||||
@@ -118,23 +139,85 @@ def test_send_via_smtp_logs_in_and_sends_reusing_one_connection(monkeypatch) ->
|
||||
assert len(fake.sent_messages) == 2
|
||||
|
||||
|
||||
def test_append_to_imap_drafts_uses_configured_folder(monkeypatch) -> None:
|
||||
def test_append_message_uses_configured_folder_and_flags(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
mail_delivery.append_to_imap_drafts(client, MESSAGE, folder="INBOX.Entwürfe")
|
||||
mail_delivery.append_message(client, MESSAGE, folder="INBOX.Entwürfe", flags=r"(\Draft)")
|
||||
|
||||
fake = _FakeImapClient.instances[0]
|
||||
assert fake.login_args == ("board@example.org", "secret")
|
||||
assert fake.appended == [("INBOX.Entwürfe", r"(\Draft)", None, MESSAGE)]
|
||||
# The folder name travels the wire in modified UTF-7, not raw UTF-8.
|
||||
assert fake.appended == [("INBOX.Entw&APw-rfe", r"(\Draft)", None, MESSAGE)]
|
||||
assert fake.calls == ["login", "append", "logout"]
|
||||
|
||||
|
||||
def test_append_to_imap_drafts_raises_on_rejected_status(monkeypatch) -> None:
|
||||
def test_append_message_raises_on_rejected_status(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
client.append_result = ("NO", [b"Mailbox does not exist"])
|
||||
with pytest.raises(RepositoryError, match="Mailbox does not exist"):
|
||||
mail_delivery.append_to_imap_drafts(client, MESSAGE, folder="Missing")
|
||||
mail_delivery.append_message(client, MESSAGE, folder="Missing", flags=r"(\Seen)")
|
||||
|
||||
|
||||
def test_ensure_imap_folder_skips_creation_when_folder_already_exists(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
mail_delivery.ensure_imap_folder(client, "INBOX.Sent")
|
||||
|
||||
fake = _FakeImapClient.instances[0]
|
||||
assert fake.selected == ["INBOX.Sent"]
|
||||
assert fake.created == []
|
||||
|
||||
|
||||
def test_ensure_imap_folder_creates_missing_folder(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
client.select_result = ("NO", [b"Mailbox does not exist"])
|
||||
mail_delivery.ensure_imap_folder(client, "Archiv.Neu")
|
||||
|
||||
fake = _FakeImapClient.instances[0]
|
||||
assert fake.selected == ["Archiv.Neu"]
|
||||
assert fake.created == ["Archiv.Neu"]
|
||||
|
||||
|
||||
def test_ensure_imap_folder_raises_when_creation_fails(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||
client.select_result = ("NO", [b"no such mailbox"])
|
||||
client.create_result = ("NO", [b"Permission denied"])
|
||||
with pytest.raises(RepositoryError, match="Permission denied"):
|
||||
mail_delivery.ensure_imap_folder(client, "Verboten")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("plain", "wire"),
|
||||
[
|
||||
("INBOX", "INBOX"),
|
||||
("Entwürfe", "Entw&APw-rfe"),
|
||||
("INBOX.Entwürfe", "INBOX.Entw&APw-rfe"),
|
||||
("Test & Foo", "Test &- Foo"),
|
||||
],
|
||||
)
|
||||
def test_imap_utf7_round_trips(plain, wire) -> None:
|
||||
assert mail_delivery._encode_imap_utf7(plain) == wire
|
||||
assert mail_delivery._decode_imap_utf7(wire) == plain
|
||||
|
||||
|
||||
def test_list_imap_folders_parses_and_decodes_list_response(monkeypatch) -> None:
|
||||
_patch_imap(monkeypatch)
|
||||
_FakeImapClient.default_list_result = (
|
||||
"OK",
|
||||
[
|
||||
rb'(\HasNoChildren) "." INBOX',
|
||||
rb'(\HasNoChildren) "." INBOX.Entw&APw-rfe',
|
||||
rb'(\HasNoChildren) "." "INBOX.Gesendete Objekte"',
|
||||
],
|
||||
)
|
||||
|
||||
folders = mail_delivery.list_imap_folders(IMAP_SETTINGS)
|
||||
|
||||
assert folders == ["INBOX", "INBOX.Entwürfe", "INBOX.Gesendete Objekte"]
|
||||
assert _FakeImapClient.instances[0].calls == ["login", "list", "logout"]
|
||||
|
||||
|
||||
def test_smtp_session_wraps_connection_errors(monkeypatch) -> None:
|
||||
|
||||
@@ -137,3 +137,66 @@ def test_reminder_mail_send_mode_delivers_via_smtp_without_local_file(tmp_path,
|
||||
assert len(sent) == 1
|
||||
data = repository.get_contributions(member.member_id)
|
||||
assert data.reminders[0]["status"] == "sent"
|
||||
|
||||
|
||||
def test_reminder_mail_send_mode_also_copies_to_sent_folder_when_enabled(tmp_path, monkeypatch):
|
||||
import ccma.services.reminder_mail as reminder_mail_module
|
||||
|
||||
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||
member.email = "reminder@example.org"
|
||||
repository.save_member(member)
|
||||
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",
|
||||
)
|
||||
reminder = repository.create_reminder_draft(
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
level=1,
|
||||
name="Zahlungserinnerung",
|
||||
payment_deadline_days=14,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "smtp_session", contextmanager(lambda settings: iter(["smtp-client"]))
|
||||
)
|
||||
monkeypatch.setattr(reminder_mail_module, "send_via_smtp", lambda client, content: None)
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "imap_session", contextmanager(lambda settings: iter(["imap-client"]))
|
||||
)
|
||||
ensured = []
|
||||
appended = []
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module, "ensure_imap_folder", lambda client, folder: ensured.append((client, folder))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
reminder_mail_module,
|
||||
"append_message",
|
||||
lambda client, content, *, folder, flags: appended.append((client, folder, flags)),
|
||||
)
|
||||
|
||||
generate_and_send_reminder_mail(
|
||||
repository,
|
||||
member.member_id,
|
||||
"claim-1",
|
||||
reminder["reminder_id"],
|
||||
delivery_mode="send",
|
||||
sender_name="Verwaltung C3MA",
|
||||
sender_email="verwaltung@example.org",
|
||||
signature="Der Vorstand",
|
||||
)
|
||||
|
||||
assert ensured == [("imap-client", "INBOX.Gesendet")]
|
||||
assert appended == [("imap-client", "INBOX.Gesendet", r"(\Seen)")]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user