added backends, improved templating, rbac
This commit is contained in:
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -18,6 +18,14 @@ class SmtpSendError(RuntimeError):
|
||||
"""Raised when an SMTP send attempt fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmtpLoginTestResult:
|
||||
host: str
|
||||
port: int
|
||||
security: str
|
||||
authenticated: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SmtpSendResult:
|
||||
host: str
|
||||
@@ -80,6 +88,34 @@ def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[in
|
||||
return normalized
|
||||
|
||||
|
||||
def test_smtp_login(*, smtp_config: SmtpConfig) -> SmtpLoginTestResult:
|
||||
"""Open an SMTP connection and authenticate if credentials are configured.
|
||||
|
||||
This is intentionally side-effect free: it does not send a message and it
|
||||
never receives envelope or recipient data. It is used by the WebUI to check
|
||||
whether the configured transport can be reached before a campaign is built
|
||||
or queued.
|
||||
"""
|
||||
|
||||
host, port = _require_smtp_config(smtp_config)
|
||||
smtp = _open_smtp(smtp_config)
|
||||
try:
|
||||
return SmtpLoginTestResult(
|
||||
host=host,
|
||||
port=port,
|
||||
security=smtp_config.security.value,
|
||||
authenticated=bool(smtp_config.username and smtp_config.password),
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
smtp.quit()
|
||||
except Exception:
|
||||
try:
|
||||
smtp.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def prepare_test_message(
|
||||
message: EmailMessage,
|
||||
*,
|
||||
|
||||
Reference in New Issue
Block a user