added backends, improved templating, rbac

This commit is contained in:
2026-06-10 14:40:22 +02:00
parent d9ca48addc
commit ce43f2658f
28 changed files with 1183 additions and 78 deletions

View File

@@ -26,6 +26,29 @@ class ImapAppendError(RuntimeError):
self.temporary = temporary
@dataclass(frozen=True, slots=True)
class ImapLoginTestResult:
host: str
port: int
security: str
authenticated: bool
@dataclass(frozen=True, slots=True)
class ImapMailboxInfo:
name: str
flags: list[str]
@dataclass(frozen=True, slots=True)
class ImapFolderListResult:
host: str
port: int
security: str
folders: list[ImapMailboxInfo]
detected_sent_folder: str | None = None
@dataclass(frozen=True, slots=True)
class ImapAppendResult:
host: str
@@ -83,43 +106,57 @@ def _decode_item(item: bytes | str | None) -> str:
return item
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
"""Best-effort parser for IMAP LIST response lines.
def _unquote_imap_token(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
value = value[1:-1]
value = value.replace('\\"', '"').replace('\\\\', '\\')
return value
Example lines:
(\\HasNoChildren \\Sent) "/" "Sent"
(\\HasNoChildren) "/" "Sent Items"
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
r"""Best-effort parser for IMAP LIST response lines.
RFC 3501 LIST responses contain attributes, hierarchy delimiter, then mailbox
name. Some servers quote both delimiter and mailbox::
(\HasNoChildren \Sent) "/" "Sent"
Others quote only the delimiter and leave the mailbox as an atom::
(\HasNoChildren \Sent) "/" Sent
The parser must therefore parse the delimiter token separately instead of
blindly taking the last quoted value.
"""
line = _decode_item(list_response_line).strip()
flags_match = re.match(r"^\((?P<flags>[^)]*)\)\s+", line)
flags = set()
if flags_match:
flags = {part.lower() for part in flags_match.group("flags").split()}
match = re.match(
r'^\((?P<flags>[^)]*)\)\s+'
r'(?P<delimiter>"(?:[^"\\]|\\.)*"|NIL|[^\s]+)\s+'
r'(?P<mailbox>.+?)\s*$',
line,
re.IGNORECASE,
)
if match:
flags = {part.lower() for part in match.group("flags").split()}
mailbox = _unquote_imap_token(match.group("mailbox"))
if mailbox:
return mailbox, flags
return None
quoted = re.findall(r'"((?:[^"\\]|\\.)*)"', line)
if quoted:
# Usually: delimiter, mailbox. Take the last quoted token.
return quoted[-1].replace(r'\"', '"'), flags
# Fallback for unquoted final atom.
parts = line.split()
if parts:
return parts[-1], flags
# Fallback for non-standard server lines: prefer the final token.
parts = line.split(maxsplit=2)
if len(parts) >= 3:
flags_text = parts[0].strip("()")
flags = {part.lower() for part in flags_text.split()}
mailbox = _unquote_imap_token(parts[2])
if mailbox:
return mailbox, flags
return None
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
typ, data = client.list()
if typ != "OK" or not data:
return None
parsed: list[tuple[str, set[str]]] = []
for item in data:
extracted = _extract_mailbox_name(item)
if extracted:
parsed.append(extracted)
def _detect_sent_folder(parsed: list[tuple[str, set[str]]]) -> str | None:
for name, flags in parsed:
if "\\sent" in flags or "\\sentmail" in flags:
return name
@@ -140,6 +177,20 @@ def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
return None
def discover_sent_folder(client: imaplib.IMAP4) -> str | None:
typ, data = client.list()
if typ != "OK" or not data:
return None
parsed: list[tuple[str, set[str]]] = []
for item in data:
extracted = _extract_mailbox_name(item)
if extracted:
parsed.append(extracted)
return _detect_sent_folder(parsed)
def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str:
if requested_folder and requested_folder != "auto":
return requested_folder
@@ -151,6 +202,63 @@ def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None,
raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder")
def test_imap_login(*, imap_config: ImapConfig) -> ImapLoginTestResult:
"""Open an IMAP connection and authenticate if credentials are configured.
This is a side-effect-free connection test for the WebUI. It does not select
a mailbox and does not append any message.
"""
host, port = _require_imap_config(imap_config)
client = _open_imap(imap_config)
try:
return ImapLoginTestResult(
host=host,
port=port,
security=imap_config.security.value,
authenticated=bool(imap_config.username and imap_config.password),
)
finally:
try:
client.logout()
except Exception:
pass
def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
"""Return folders visible through IMAP LIST and the best sent-folder guess."""
host, port = _require_imap_config(imap_config)
client = _open_imap(imap_config)
try:
typ, data = client.list()
if typ != "OK":
raise ImapAppendError(f"IMAP folder listing failed: {data!r}", temporary=True)
parsed: list[tuple[str, set[str]]] = []
folders: list[ImapMailboxInfo] = []
for item in data or []:
extracted = _extract_mailbox_name(item)
if not extracted:
continue
name, flags = extracted
parsed.append((name, flags))
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
return ImapFolderListResult(
host=host,
port=port,
security=imap_config.security.value,
folders=folders,
detected_sent_folder=_detect_sent_folder(parsed),
)
finally:
try:
client.logout()
except Exception:
pass
def append_message_to_sent(
message_bytes: bytes,
*,