added backends, improved templating, rbac
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
@@ -8,6 +9,7 @@ from typing import Any, Iterable
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.mailer.campaign.entries import load_campaign_entries
|
||||
from app.mailer.campaign.field_values import effective_entry_field_values
|
||||
from app.mailer.campaign.models import AttachmentConfig, Behavior, CampaignConfig, EntryConfig
|
||||
|
||||
|
||||
@@ -126,11 +128,39 @@ def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path:
|
||||
return (campaign_path.parent / path).resolve()
|
||||
|
||||
|
||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||
|
||||
|
||||
def _normalize_template_key(raw: str) -> str:
|
||||
key = raw.strip()
|
||||
if key.startswith("fields."):
|
||||
key = key.removeprefix("fields.")
|
||||
elif key.startswith("local."):
|
||||
key = "local::" + key.removeprefix("local.")
|
||||
elif key.startswith("global."):
|
||||
key = "global::" + key.removeprefix("global.")
|
||||
|
||||
if key.startswith("local::") or key.startswith("global::"):
|
||||
return key
|
||||
if key.startswith("local:"):
|
||||
return "local::" + key.removeprefix("local:")
|
||||
if key.startswith("global:"):
|
||||
return "global::" + key.removeprefix("global:")
|
||||
return key
|
||||
|
||||
|
||||
def _render_template(template: str, values: dict[str, Any]) -> str:
|
||||
rendered = template
|
||||
for key, value in values.items():
|
||||
rendered = rendered.replace("${" + key + "}", "" if value is None else str(value))
|
||||
return rendered
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = _normalize_template_key(match.group(1))
|
||||
if key in values:
|
||||
value = values[key]
|
||||
return "" if value is None else str(value)
|
||||
return match.group(0)
|
||||
|
||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
|
||||
|
||||
def _recipient_values(entry: EntryConfig) -> dict[str, str]:
|
||||
@@ -153,7 +183,7 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An
|
||||
values: dict[str, Any] = {}
|
||||
for key, value in config.global_values.items():
|
||||
values[f"global::{key}"] = value
|
||||
for key, value in entry.fields.items():
|
||||
for key, value in effective_entry_field_values(config, entry).items():
|
||||
values[f"local::{key}"] = value
|
||||
if entry.id:
|
||||
values["local::id"] = entry.id
|
||||
|
||||
62
server/app/mailer/campaign/field_values.py
Normal file
62
server/app/mailer/campaign/field_values.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .models import CampaignConfig, EntryConfig, FieldDefinition
|
||||
|
||||
|
||||
def field_definitions_by_name(config: CampaignConfig) -> dict[str, FieldDefinition]:
|
||||
"""Return campaign field definitions keyed by field id/name."""
|
||||
|
||||
return {field.name: field for field in config.fields}
|
||||
|
||||
|
||||
def field_can_override(config: CampaignConfig, field_name: str) -> bool:
|
||||
"""Return whether a recipient/entry value may override the global value.
|
||||
|
||||
Unknown fields remain overridable for backwards compatibility with older
|
||||
campaigns and ad-hoc external mappings. Semantic validation reports unknown
|
||||
field usage separately when a field list is configured.
|
||||
"""
|
||||
|
||||
field = field_definitions_by_name(config).get(field_name)
|
||||
if field is None:
|
||||
return True
|
||||
return field.can_override
|
||||
|
||||
|
||||
def ignored_entry_field_overrides(config: CampaignConfig, entry: EntryConfig) -> list[str]:
|
||||
"""Return recipient field keys that are ignored by the override policy."""
|
||||
|
||||
return sorted(name for name in entry.fields if not field_can_override(config, name))
|
||||
|
||||
|
||||
def effective_entry_field_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]:
|
||||
"""Return the local/effective field value map for one message entry.
|
||||
|
||||
Global values act as defaults for local template placeholders. Recipient
|
||||
values replace those defaults only when the corresponding field allows
|
||||
overrides. Fields that are unknown to the campaign definition keep the old
|
||||
permissive behavior and remain usable as local values.
|
||||
"""
|
||||
|
||||
values: dict[str, Any] = dict(config.global_values)
|
||||
for key, value in entry.fields.items():
|
||||
if field_can_override(config, key) and entry_field_has_override_value(value):
|
||||
values[key] = value
|
||||
return values
|
||||
|
||||
|
||||
def entry_field_has_override_value(value: Any) -> bool:
|
||||
"""Return whether an entry field should override a global default.
|
||||
|
||||
Empty recipient values are treated as "not set" so global_values remain the
|
||||
effective local defaults. Numeric zero and boolean false are valid explicit
|
||||
overrides.
|
||||
"""
|
||||
|
||||
if value is None:
|
||||
return False
|
||||
if isinstance(value, str):
|
||||
return value.strip() != ""
|
||||
return True
|
||||
@@ -91,6 +91,7 @@ class FieldDefinition(StrictModel):
|
||||
type: FieldType = FieldType.STRING
|
||||
label: str | None = None
|
||||
required: bool = False
|
||||
can_override: bool = True
|
||||
|
||||
|
||||
class SmtpConfig(StrictModel):
|
||||
|
||||
@@ -7,7 +7,8 @@ from typing import Iterable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .models import CampaignConfig, SourceType
|
||||
from .field_values import ignored_entry_field_overrides
|
||||
from .models import CampaignConfig, EntryConfig, SourceType
|
||||
|
||||
|
||||
class Severity(StrEnum):
|
||||
@@ -61,6 +62,12 @@ def _resolve(campaign_file: Path, raw_path: str) -> Path:
|
||||
return (campaign_file.parent / path).resolve()
|
||||
|
||||
|
||||
def _mapping_target_field_name(target: str) -> str | None:
|
||||
if target.startswith("fields."):
|
||||
return target.split(".", 1)[1]
|
||||
return None
|
||||
|
||||
|
||||
def _mapping_target_known(target: str, field_names: set[str]) -> bool:
|
||||
direct_targets = {
|
||||
"id",
|
||||
@@ -129,6 +136,18 @@ def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, s
|
||||
return paths
|
||||
|
||||
|
||||
def _ignored_override_issues(config: CampaignConfig, entry: EntryConfig, path_prefix: str) -> list[SemanticIssue]:
|
||||
return [
|
||||
_issue(
|
||||
Severity.WARNING,
|
||||
"field_override_not_allowed",
|
||||
f"recipient value for field {field_name!r} will be ignored because the field does not allow overrides",
|
||||
f"{path_prefix}/fields/{field_name}",
|
||||
)
|
||||
for field_name in ignored_entry_field_overrides(config, entry)
|
||||
]
|
||||
|
||||
|
||||
def validate_campaign_config(
|
||||
config: CampaignConfig,
|
||||
*,
|
||||
@@ -139,7 +158,8 @@ def validate_campaign_config(
|
||||
issues: list[SemanticIssue] = []
|
||||
|
||||
field_names = config.field_names
|
||||
declared_names = {field.name for field in config.fields}
|
||||
field_definitions = {field.name: field for field in config.fields}
|
||||
declared_names = set(field_definitions)
|
||||
|
||||
for key in config.global_values:
|
||||
if declared_names and key not in declared_names:
|
||||
@@ -187,10 +207,13 @@ def validate_campaign_config(
|
||||
))
|
||||
|
||||
if config.entries.is_inline:
|
||||
entries_count = len(config.entries.inline or [])
|
||||
inline_entries = config.entries.inline or []
|
||||
entries_count = len(inline_entries)
|
||||
entries_mode = "inline"
|
||||
if entries_count == 0:
|
||||
issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline"))
|
||||
for index, entry in enumerate(inline_entries):
|
||||
issues.extend(_ignored_override_issues(config, entry, f"/entries/inline/{index}"))
|
||||
else:
|
||||
entries_count = None
|
||||
entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}"
|
||||
@@ -205,6 +228,16 @@ def validate_campaign_config(
|
||||
f"mapping target {target!r} is not recognized by the current campaign model",
|
||||
f"/entries/mapping/{target}",
|
||||
))
|
||||
field_name = _mapping_target_field_name(target)
|
||||
if field_name and field_name in field_definitions and not field_definitions[field_name].can_override:
|
||||
issues.append(_issue(
|
||||
Severity.WARNING,
|
||||
"mapping_target_not_overridable",
|
||||
f"mapping target {target!r} points to a field that does not allow recipient overrides; mapped values will be ignored",
|
||||
f"/entries/mapping/{target}",
|
||||
))
|
||||
if config.entries.defaults:
|
||||
issues.extend(_ignored_override_issues(config, config.entries.defaults, "/entries/defaults"))
|
||||
if check_files and config.entries.source:
|
||||
source_path = _resolve(campaign_path, config.entries.source.path)
|
||||
if not source_path.exists():
|
||||
|
||||
@@ -18,6 +18,7 @@ class FieldType(StrEnum):
|
||||
class FieldDescription:
|
||||
name: str
|
||||
type: FieldType = FieldType.STRING
|
||||
can_override: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -17,6 +17,7 @@ from app.mailer.attachments.resolver import (
|
||||
resolve_entry_attachments,
|
||||
)
|
||||
from app.mailer.campaign.entries import load_campaign_entries
|
||||
from app.mailer.campaign.field_values import effective_entry_field_values, ignored_entry_field_overrides
|
||||
from app.mailer.campaign.models import (
|
||||
Behavior,
|
||||
BuildStatus,
|
||||
@@ -38,7 +39,26 @@ from .models import (
|
||||
MessageValidationStatus,
|
||||
)
|
||||
|
||||
_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
_DOLLAR_FIELD_PATTERN = re.compile(r"(?<!\\)\$\{(.*?)(?<!\\)\}")
|
||||
_BRACE_FIELD_PATTERN = re.compile(r"(?<!\\)\{\{\s*(.*?)\s*\}\}")
|
||||
|
||||
|
||||
def _normalize_template_key(raw: str) -> str:
|
||||
key = raw.strip()
|
||||
if key.startswith("fields."):
|
||||
key = key.removeprefix("fields.")
|
||||
elif key.startswith("local."):
|
||||
key = "local::" + key.removeprefix("local.")
|
||||
elif key.startswith("global."):
|
||||
key = "global::" + key.removeprefix("global.")
|
||||
|
||||
if key.startswith("local::") or key.startswith("global::"):
|
||||
return key
|
||||
if key.startswith("local:"):
|
||||
return "local::" + key.removeprefix("local:")
|
||||
if key.startswith("global:"):
|
||||
return "global::" + key.removeprefix("global:")
|
||||
return key
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -70,20 +90,25 @@ def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str =
|
||||
|
||||
def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str:
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
key = match.group(1)
|
||||
key = _normalize_template_key(match.group(1))
|
||||
if key in values:
|
||||
value = values[key]
|
||||
return "" if value is None else str(value)
|
||||
return match.group(0) if keep_missing else ""
|
||||
|
||||
rendered = _FIELD_PATTERN.sub(replace, template)
|
||||
rendered = _DOLLAR_FIELD_PATTERN.sub(replace, template)
|
||||
rendered = _BRACE_FIELD_PATTERN.sub(replace, rendered)
|
||||
return rendered.replace(r"\${", "${").replace(r"\}", "}")
|
||||
|
||||
|
||||
def _find_unresolved_placeholders(text: str | None) -> set[str]:
|
||||
if not text:
|
||||
return set()
|
||||
return set(_FIELD_PATTERN.findall(text))
|
||||
return {
|
||||
_normalize_template_key(match.group(1))
|
||||
for pattern in (_DOLLAR_FIELD_PATTERN, _BRACE_FIELD_PATTERN)
|
||||
for match in pattern.finditer(text)
|
||||
}
|
||||
|
||||
|
||||
def _recipient_values(entry: EntryConfig) -> dict[str, str]:
|
||||
@@ -106,7 +131,7 @@ def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, An
|
||||
values: dict[str, Any] = {}
|
||||
for key, value in config.global_values.items():
|
||||
values[f"global::{key}"] = value
|
||||
for key, value in entry.fields.items():
|
||||
for key, value in effective_entry_field_values(config, entry).items():
|
||||
values[f"local::{key}"] = value
|
||||
if entry.id:
|
||||
values["local::id"] = entry.id
|
||||
@@ -390,6 +415,20 @@ def build_entry_message(
|
||||
issues = _message_issues_from_attachment_resolution(resolution)
|
||||
validation_status = _validation_status_from_attachment_status(resolution.status)
|
||||
|
||||
ignored_field_overrides = ignored_entry_field_overrides(config, entry)
|
||||
if ignored_field_overrides:
|
||||
issues.append(
|
||||
MessageIssue(
|
||||
severity="warning",
|
||||
code="field_override_not_allowed",
|
||||
message="Recipient field value(s) ignored because the campaign field does not allow overrides: " + ", ".join(ignored_field_overrides),
|
||||
behavior="warn",
|
||||
source="fields",
|
||||
)
|
||||
)
|
||||
if validation_status == MessageValidationStatus.READY:
|
||||
validation_status = MessageValidationStatus.WARNING
|
||||
|
||||
if not entry.active:
|
||||
draft = MessageDraft(
|
||||
entry_index=entry_index,
|
||||
|
||||
@@ -70,6 +70,11 @@
|
||||
"required": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"can_override": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Whether recipient/entry field values may override the global value for this field."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
|
||||
@@ -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