mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +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
@@ -1,6 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import imaplib
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
from collections.abc import Iterator
|
||||
@@ -90,16 +93,127 @@ def send_via_smtp(client: smtplib.SMTP, message_bytes: bytes) -> None:
|
||||
raise RepositoryError(f"E-Mail konnte nicht versandt werden: {exc}") from exc
|
||||
|
||||
|
||||
def append_to_imap_drafts(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str) -> None:
|
||||
def append_message(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str, flags: str) -> None:
|
||||
try:
|
||||
status, response = client.append(folder, r"(\Draft)", None, message_bytes)
|
||||
status, response = client.append(_encode_imap_utf7(folder), flags, None, message_bytes)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"E-Mail konnte nicht als Entwurf abgelegt werden: {exc}") from exc
|
||||
raise RepositoryError(f"E-Mail konnte nicht in „{folder}“ abgelegt werden: {exc}") from exc
|
||||
if status != "OK":
|
||||
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
|
||||
raise RepositoryError(f"IMAP-Server hat die Ablage im Ordner „{folder}“ abgelehnt: {detail}")
|
||||
|
||||
|
||||
def ensure_imap_folder(client: imaplib.IMAP4, folder: str) -> None:
|
||||
"""Creates the folder on the server if it doesn't exist yet -- lets someone type
|
||||
a not-yet-existing Entwürfe/Gesendet folder name and have it just appear, instead
|
||||
of every append failing until it's created by hand in a separate mail client."""
|
||||
encoded = _encode_imap_utf7(folder)
|
||||
try:
|
||||
status, _response = client.select(encoded, readonly=True)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht geprüft werden: {exc}") from exc
|
||||
if status == "OK":
|
||||
return
|
||||
try:
|
||||
status, response = client.create(encoded)
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {exc}") from exc
|
||||
if status != "OK":
|
||||
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
|
||||
raise RepositoryError(f"IMAP-Ordner „{folder}“ konnte nicht angelegt werden: {detail}")
|
||||
|
||||
|
||||
_LIST_RESPONSE_RE = re.compile(
|
||||
rb'^\((?P<flags>[^)]*)\)\s+(?:"(?P<qdelim>[^"]*)"|(?P<delim>\S+))\s+(?P<name>.+)$'
|
||||
)
|
||||
|
||||
|
||||
def list_imap_folders(settings: dict) -> list[str]:
|
||||
"""Human-readable (decoded) folder names for the Options dialog's folder picker
|
||||
-- fetched live from the server, so it only works once credentials are entered."""
|
||||
with imap_session(settings) as client:
|
||||
try:
|
||||
status, response = client.list()
|
||||
except (OSError, imaplib.IMAP4.error) as exc:
|
||||
raise RepositoryError(f"Ordnerliste konnte nicht abgerufen werden: {exc}") from exc
|
||||
if status != "OK":
|
||||
raise RepositoryError("Ordnerliste konnte nicht abgerufen werden.")
|
||||
folders = []
|
||||
for raw in response or []:
|
||||
if not raw:
|
||||
continue
|
||||
name = _parse_list_response(raw)
|
||||
if name:
|
||||
folders.append(name)
|
||||
return folders
|
||||
|
||||
|
||||
def _parse_list_response(raw: bytes) -> str | None:
|
||||
match = _LIST_RESPONSE_RE.match(raw)
|
||||
if not match:
|
||||
return None
|
||||
name = match.group("name").decode("utf-8", "replace").strip()
|
||||
if name.startswith('"') and name.endswith('"') and len(name) >= 2:
|
||||
name = name[1:-1]
|
||||
return _decode_imap_utf7(name)
|
||||
|
||||
|
||||
def _decode_imap_utf7(value: str) -> str:
|
||||
# Modified UTF-7 (RFC 3501 5.1.3): "&" takes the role of "+", and "," takes the
|
||||
# role of "/" inside the base64 run; "&-" is a literal ampersand.
|
||||
if "&" not in value:
|
||||
return value
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
length = len(value)
|
||||
while index < length:
|
||||
char = value[index]
|
||||
if char != "&":
|
||||
result.append(char)
|
||||
index += 1
|
||||
continue
|
||||
end = value.find("-", index + 1)
|
||||
if end == -1:
|
||||
end = length
|
||||
chunk = value[index + 1 : end]
|
||||
if chunk == "":
|
||||
result.append("&")
|
||||
else:
|
||||
base64_chunk = chunk.replace(",", "/")
|
||||
base64_chunk += "=" * (-len(base64_chunk) % 4)
|
||||
try:
|
||||
result.append(base64.b64decode(base64_chunk).decode("utf-16-be"))
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
result.append("&" + chunk + "-")
|
||||
index = end + 1
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def _encode_imap_utf7(value: str) -> str:
|
||||
if "&" not in value and all(32 <= ord(char) <= 126 for char in value):
|
||||
return value
|
||||
result: list[str] = []
|
||||
index = 0
|
||||
length = len(value)
|
||||
while index < length:
|
||||
char = value[index]
|
||||
if char == "&":
|
||||
result.append("&-")
|
||||
index += 1
|
||||
continue
|
||||
if 32 <= ord(char) <= 126:
|
||||
result.append(char)
|
||||
index += 1
|
||||
continue
|
||||
start = index
|
||||
while index < length and not (32 <= ord(value[index]) <= 126):
|
||||
index += 1
|
||||
chunk = value[start:index]
|
||||
encoded = base64.b64encode(chunk.encode("utf-16-be")).decode("ascii").rstrip("=")
|
||||
result.append("&" + encoded.replace("/", ",") + "-")
|
||||
return "".join(result)
|
||||
|
||||
|
||||
def test_smtp_connection(settings: dict) -> None:
|
||||
with smtp_session(settings):
|
||||
pass
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
@@ -12,7 +13,13 @@ from pathlib import Path
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.services.mail_delivery import append_to_imap_drafts, imap_session, send_via_smtp, smtp_session
|
||||
from ccma.services.mail_delivery import (
|
||||
append_message,
|
||||
ensure_imap_folder,
|
||||
imap_session,
|
||||
send_via_smtp,
|
||||
smtp_session,
|
||||
)
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
@@ -178,12 +185,20 @@ def generate_and_send_reminder_mail(
|
||||
export_temp.unlink(missing_ok=True)
|
||||
export_path = target
|
||||
elif delivery_mode == "send":
|
||||
with smtp_session(repository.get_email_settings()) as client:
|
||||
send_via_smtp(client, content)
|
||||
settings = repository.get_email_settings()
|
||||
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
|
||||
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
|
||||
send_via_smtp(smtp_client, content)
|
||||
if imap_client is not None:
|
||||
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
||||
append_message(
|
||||
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
|
||||
)
|
||||
else:
|
||||
settings = repository.get_email_settings()
|
||||
with imap_session(settings) as client:
|
||||
append_to_imap_drafts(client, content, folder=settings["imap_drafts_folder"])
|
||||
ensure_imap_folder(client, settings["imap_drafts_folder"])
|
||||
append_message(client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)")
|
||||
os.replace(archive_temp, archive_path)
|
||||
finally:
|
||||
archive_temp.unlink(missing_ok=True)
|
||||
|
||||
@@ -10,7 +10,13 @@ from email.policy import SMTP
|
||||
from email.utils import format_datetime, make_msgid
|
||||
from pathlib import Path
|
||||
|
||||
from ccma.services.mail_delivery import append_to_imap_drafts, imap_session, send_via_smtp, smtp_session
|
||||
from ccma.services.mail_delivery import (
|
||||
append_message,
|
||||
ensure_imap_folder,
|
||||
imap_session,
|
||||
send_via_smtp,
|
||||
smtp_session,
|
||||
)
|
||||
from ccma.services.sepa import DirectDebit
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
@@ -105,18 +111,21 @@ def generate_debit_mails(
|
||||
) -> tuple[list[GeneratedDebitMail], list[str]]:
|
||||
output: Path | None = None
|
||||
email_settings: dict | None = None
|
||||
smtp_ctx = nullcontext(None)
|
||||
imap_ctx = nullcontext(None)
|
||||
if delivery_mode == "local":
|
||||
if not output_directory:
|
||||
raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
|
||||
output = Path(output_directory)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
session = nullcontext(None)
|
||||
elif delivery_mode == "send":
|
||||
email_settings = repository.get_email_settings()
|
||||
session = smtp_session(email_settings)
|
||||
smtp_ctx = smtp_session(email_settings)
|
||||
if email_settings["imap_sent_enabled"]:
|
||||
imap_ctx = imap_session(email_settings)
|
||||
elif delivery_mode == "drafts":
|
||||
email_settings = repository.get_email_settings()
|
||||
session = imap_session(email_settings)
|
||||
imap_ctx = imap_session(email_settings)
|
||||
else:
|
||||
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||
|
||||
@@ -126,9 +135,14 @@ def generate_debit_mails(
|
||||
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
|
||||
generated: list[GeneratedDebitMail] = []
|
||||
warnings: list[str] = []
|
||||
# One connection is opened for the whole batch (a run can cover dozens of
|
||||
# members) instead of reconnecting/re-authenticating per recipient.
|
||||
with session as client:
|
||||
# One connection (or two, for "send" with a Sent-folder copy) is opened for the
|
||||
# whole batch -- a run can cover dozens of members -- instead of reconnecting/
|
||||
# re-authenticating per recipient.
|
||||
with smtp_ctx as smtp_client, imap_ctx as imap_client:
|
||||
if delivery_mode == "drafts":
|
||||
ensure_imap_folder(imap_client, email_settings["imap_drafts_folder"])
|
||||
elif delivery_mode == "send" and imap_client is not None:
|
||||
ensure_imap_folder(imap_client, email_settings["imap_sent_folder"])
|
||||
for debit in debits:
|
||||
member = repository.get_member(debit.member_id)
|
||||
if not member.email.strip():
|
||||
@@ -156,9 +170,15 @@ def generate_debit_mails(
|
||||
export_path = _available_path(output, filename)
|
||||
export_path.write_bytes(content)
|
||||
elif delivery_mode == "send":
|
||||
send_via_smtp(client, content)
|
||||
send_via_smtp(smtp_client, content)
|
||||
if imap_client is not None:
|
||||
append_message(
|
||||
imap_client, content, folder=email_settings["imap_sent_folder"], flags=r"(\Seen)"
|
||||
)
|
||||
else:
|
||||
append_to_imap_drafts(client, content, folder=email_settings["imap_drafts_folder"])
|
||||
append_message(
|
||||
imap_client, content, folder=email_settings["imap_drafts_folder"], flags=r"(\Draft)"
|
||||
)
|
||||
try:
|
||||
archive_path.write_bytes(content)
|
||||
except OSError:
|
||||
|
||||
Reference in New Issue
Block a user