mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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>
225 lines
7.9 KiB
Python
225 lines
7.9 KiB
Python
from __future__ import annotations
|
|
|
|
import base64
|
|
import binascii
|
|
import imaplib
|
|
import re
|
|
import smtplib
|
|
import ssl
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager, suppress
|
|
from email.parser import BytesParser
|
|
from email.policy import default as email_default_policy
|
|
|
|
from ccma.storage.repository import RepositoryError
|
|
|
|
|
|
def _ssl_context() -> ssl.SSLContext:
|
|
return ssl.create_default_context()
|
|
|
|
|
|
def _smtp_client(settings: dict) -> smtplib.SMTP:
|
|
host = str(settings.get("smtp_host", "")).strip()
|
|
if not host:
|
|
raise RepositoryError("Kein SMTP-Server konfiguriert.")
|
|
port = int(settings.get("smtp_port", 587))
|
|
security = str(settings.get("smtp_security", "starttls"))
|
|
client = (
|
|
smtplib.SMTP_SSL(host, port, context=_ssl_context(), timeout=30)
|
|
if security == "ssl"
|
|
else smtplib.SMTP(host, port, timeout=30)
|
|
)
|
|
if security == "starttls":
|
|
client.starttls(context=_ssl_context())
|
|
username = str(settings.get("smtp_username", "")).strip()
|
|
if username:
|
|
client.login(username, str(settings.get("smtp_password", "")))
|
|
return client
|
|
|
|
|
|
def _imap_client(settings: dict) -> imaplib.IMAP4:
|
|
host = str(settings.get("imap_host", "")).strip()
|
|
if not host:
|
|
raise RepositoryError("Kein IMAP-Server konfiguriert.")
|
|
port = int(settings.get("imap_port", 993))
|
|
security = str(settings.get("imap_security", "ssl"))
|
|
client = (
|
|
imaplib.IMAP4_SSL(host, port, timeout=30)
|
|
if security == "ssl"
|
|
else imaplib.IMAP4(host, port, timeout=30)
|
|
)
|
|
if security == "starttls":
|
|
client.starttls(_ssl_context())
|
|
username = str(settings.get("imap_username", "")).strip()
|
|
if username:
|
|
client.login(username, str(settings.get("imap_password", "")))
|
|
return client
|
|
|
|
|
|
@contextmanager
|
|
def smtp_session(settings: dict) -> Iterator[smtplib.SMTP]:
|
|
"""Opens one authenticated SMTP connection to reuse across several sends (a SEPA
|
|
info-mail batch may cover dozens of members -- reconnecting/re-authenticating per
|
|
message would be slow and can trigger provider rate limits)."""
|
|
try:
|
|
client = _smtp_client(settings)
|
|
except (OSError, smtplib.SMTPException) as exc:
|
|
raise RepositoryError(f"SMTP-Verbindung fehlgeschlagen: {exc}") from exc
|
|
try:
|
|
yield client
|
|
finally:
|
|
with suppress(OSError, smtplib.SMTPException):
|
|
client.quit()
|
|
|
|
|
|
@contextmanager
|
|
def imap_session(settings: dict) -> Iterator[imaplib.IMAP4]:
|
|
try:
|
|
client = _imap_client(settings)
|
|
except (OSError, imaplib.IMAP4.error) as exc:
|
|
raise RepositoryError(f"IMAP-Verbindung fehlgeschlagen: {exc}") from exc
|
|
try:
|
|
yield client
|
|
finally:
|
|
with suppress(OSError, imaplib.IMAP4.error):
|
|
client.logout()
|
|
|
|
|
|
def send_via_smtp(client: smtplib.SMTP, message_bytes: bytes) -> None:
|
|
message = BytesParser(policy=email_default_policy).parsebytes(message_bytes)
|
|
try:
|
|
client.send_message(message)
|
|
except (OSError, smtplib.SMTPException) as exc:
|
|
raise RepositoryError(f"E-Mail konnte nicht versandt werden: {exc}") from exc
|
|
|
|
|
|
def append_message(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str, flags: str) -> None:
|
|
try:
|
|
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 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
|
|
|
|
|
|
def test_imap_connection(settings: dict) -> None:
|
|
with imap_session(settings):
|
|
pass
|