Release v0.1.2
This commit is contained in:
47
src/govoplan_mail/backend/capabilities.py
Normal file
47
src/govoplan_mail/backend/capabilities.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_mail.backend.runtime import configure_runtime
|
||||
from govoplan_mail.backend.mail_profiles import (
|
||||
MailProfileError,
|
||||
apply_campaign_credentials,
|
||||
assert_campaign_mail_policy_allows_json,
|
||||
assert_mail_policy_allows_send,
|
||||
effective_profile_credentials_inherited,
|
||||
ensure_mail_profile_allowed_for_campaign,
|
||||
imap_config_from_profile,
|
||||
mail_profile_id_from_campaign_json,
|
||||
materialize_campaign_mail_profile_config,
|
||||
smtp_config_from_profile,
|
||||
)
|
||||
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
|
||||
from govoplan_mail.backend.sending.rate_limit import wait_for_rate_limit
|
||||
from govoplan_mail.backend.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_bytes, send_email_message
|
||||
|
||||
|
||||
class MailCampaignCapability:
|
||||
MailProfileError = MailProfileError
|
||||
SmtpConfigurationError = SmtpConfigurationError
|
||||
SmtpSendError = SmtpSendError
|
||||
ImapConfigurationError = ImapConfigurationError
|
||||
ImapAppendError = ImapAppendError
|
||||
materialize_campaign_mail_profile_config = staticmethod(materialize_campaign_mail_profile_config)
|
||||
assert_campaign_mail_policy_allows_json = staticmethod(assert_campaign_mail_policy_allows_json)
|
||||
assert_mail_policy_allows_send = staticmethod(assert_mail_policy_allows_send)
|
||||
mail_profile_id_from_campaign_json = staticmethod(mail_profile_id_from_campaign_json)
|
||||
ensure_mail_profile_allowed_for_campaign = staticmethod(ensure_mail_profile_allowed_for_campaign)
|
||||
smtp_config_from_profile = staticmethod(smtp_config_from_profile)
|
||||
imap_config_from_profile = staticmethod(imap_config_from_profile)
|
||||
effective_profile_credentials_inherited = staticmethod(effective_profile_credentials_inherited)
|
||||
apply_campaign_credentials = staticmethod(apply_campaign_credentials)
|
||||
wait_for_rate_limit = staticmethod(wait_for_rate_limit)
|
||||
send_email_bytes = staticmethod(send_email_bytes)
|
||||
append_message_to_sent = staticmethod(append_message_to_sent)
|
||||
send_email_message = staticmethod(send_email_message)
|
||||
|
||||
|
||||
def campaign_capability(context: ModuleContext) -> MailCampaignCapability:
|
||||
configure_runtime(settings=context.settings)
|
||||
return MailCampaignCapability()
|
||||
@@ -1,35 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from govoplan_core.mail.config import (
|
||||
ImapConfig,
|
||||
ImapServerConfig,
|
||||
SmtpConfig,
|
||||
SmtpServerConfig,
|
||||
StrictModel,
|
||||
TransportCredentials,
|
||||
TransportSecurity,
|
||||
)
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class StrictModel(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", populate_by_name=True)
|
||||
|
||||
|
||||
class TransportSecurity(StrEnum):
|
||||
PLAIN = "plain"
|
||||
TLS = "tls"
|
||||
STARTTLS = "starttls"
|
||||
|
||||
|
||||
class SmtpConfig(StrictModel):
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
security: TransportSecurity = TransportSecurity.STARTTLS
|
||||
timeout_seconds: int = Field(default=30, ge=1)
|
||||
|
||||
|
||||
class ImapConfig(StrictModel):
|
||||
enabled: bool = False
|
||||
host: str | None = None
|
||||
port: int | None = Field(default=None, ge=1, le=65535)
|
||||
username: str | None = None
|
||||
password: str | None = None
|
||||
security: TransportSecurity = TransportSecurity.TLS
|
||||
sent_folder: str = "auto"
|
||||
timeout_seconds: int = Field(default=30, ge=1)
|
||||
__all__ = [
|
||||
"ImapConfig",
|
||||
"ImapServerConfig",
|
||||
"SmtpConfig",
|
||||
"SmtpServerConfig",
|
||||
"StrictModel",
|
||||
"TransportCredentials",
|
||||
"TransportSecurity",
|
||||
]
|
||||
|
||||
@@ -29,8 +29,10 @@ class MailServerProfile(Base, TimestampMixin):
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False, index=True)
|
||||
smtp_config: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
||||
smtp_username: Mapped[str | None] = mapped_column(String(320))
|
||||
smtp_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
imap_config: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
imap_username: Mapped[str | None] = mapped_column(String(320))
|
||||
imap_password_encrypted: Mapped[str | None] = mapped_column(Text)
|
||||
created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
updated_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||
|
||||
@@ -10,8 +10,8 @@ from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.admin.governance import get_system_settings
|
||||
from govoplan_core.core.optional import reraise_unless_missing_package
|
||||
from govoplan_core.db.models import Group, Tenant, User, UserGroupMembership
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
from govoplan_mail.backend.db.models import MailServerProfile
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||
from govoplan_core.security.secrets import decrypt_secret, encrypt_secret
|
||||
@@ -25,20 +25,47 @@ MAIL_PROFILE_POLICY_SETTINGS_KEY = "mail_profile_policy"
|
||||
PROFILE_SCOPE_TYPES = {"system", "tenant", "user", "group", "campaign"}
|
||||
PROFILE_PATTERN_KEYS = ("smtp_hosts", "imap_hosts", "envelope_senders", "from_headers", "recipient_domains")
|
||||
PROFILE_SCOPE_ORDER = {"system": 0, "tenant": 1, "user": 2, "group": 2, "campaign": 3}
|
||||
MAIL_POLICY_LIMIT_KEYS = (
|
||||
"allowed_profile_ids",
|
||||
"allow_user_profiles",
|
||||
"allow_group_profiles",
|
||||
"allow_campaign_profiles",
|
||||
"smtp_credentials.inherit",
|
||||
"imap_credentials.inherit",
|
||||
"whitelist.smtp_hosts",
|
||||
"whitelist.imap_hosts",
|
||||
"whitelist.envelope_senders",
|
||||
"whitelist.from_headers",
|
||||
"whitelist.recipient_domains",
|
||||
"blacklist.smtp_hosts",
|
||||
"blacklist.imap_hosts",
|
||||
"blacklist.envelope_senders",
|
||||
"blacklist.from_headers",
|
||||
"blacklist.recipient_domains",
|
||||
)
|
||||
|
||||
|
||||
def default_mail_policy_lower_level_limits() -> dict[str, bool]:
|
||||
return {key: True for key in MAIL_POLICY_LIMIT_KEYS}
|
||||
|
||||
|
||||
def _campaign_model():
|
||||
try:
|
||||
from govoplan_campaign.backend.db.models import Campaign
|
||||
except ModuleNotFoundError as exc:
|
||||
reraise_unless_missing_package(exc, "govoplan_campaign")
|
||||
raise MailProfileError("Campaign module is not installed") from exc
|
||||
return Campaign
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class EffectiveCredentialPolicy:
|
||||
inherit: bool = True
|
||||
allow_override: bool = True
|
||||
explicit_inherit: bool = False
|
||||
inherit_source: str | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"inherit": self.inherit,
|
||||
"allow_override": self.allow_override,
|
||||
}
|
||||
return {"inherit": self.inherit}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -51,6 +78,7 @@ class EffectiveMailProfilePolicy:
|
||||
imap_credentials: EffectiveCredentialPolicy = field(default_factory=EffectiveCredentialPolicy)
|
||||
whitelist_groups: dict[str, list[list[str]]] = field(default_factory=lambda: {key: [] for key in PROFILE_PATTERN_KEYS})
|
||||
blacklist_patterns: dict[str, list[str]] = field(default_factory=lambda: {key: [] for key in PROFILE_PATTERN_KEYS})
|
||||
allow_lower_level_limits: dict[str, bool] = field(default_factory=default_mail_policy_lower_level_limits)
|
||||
source_policies: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def profile_id_allowed(self, profile_id: str) -> bool:
|
||||
@@ -87,6 +115,7 @@ class EffectiveMailProfilePolicy:
|
||||
"imap_credentials": self.imap_credentials.as_dict(),
|
||||
"whitelist": whitelist,
|
||||
"blacklist": blacklist,
|
||||
"allow_lower_level_limits": dict(self.allow_lower_level_limits),
|
||||
}
|
||||
|
||||
|
||||
@@ -95,11 +124,14 @@ def slugify_profile_name(value: str) -> str:
|
||||
return slug or "mail-profile"
|
||||
|
||||
|
||||
def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, bool]:
|
||||
def _transport_payload(config: SmtpConfig | ImapConfig) -> tuple[dict[str, Any], str | None, str | None, bool, bool]:
|
||||
payload = config.model_dump(mode="json")
|
||||
username_was_supplied = "username" in config.model_fields_set
|
||||
password_was_supplied = "password" in config.model_fields_set
|
||||
username = payload.pop("username", None)
|
||||
password = payload.pop("password", None)
|
||||
return payload, password, password_was_supplied
|
||||
payload.pop("enabled", None)
|
||||
return payload, username, password, username_was_supplied, password_was_supplied
|
||||
|
||||
|
||||
def _normalize_scope_type(scope_type: str | None) -> str:
|
||||
@@ -162,11 +194,20 @@ def _normalize_credential_policy(value: Any) -> dict[str, bool | None]:
|
||||
inherit = data.get("inherit")
|
||||
if inherit is None:
|
||||
inherit = data.get("inherit_credentials")
|
||||
allow_override = data.get("allow_override")
|
||||
return {
|
||||
"inherit": inherit if isinstance(inherit, bool) else None,
|
||||
"allow_override": allow_override if isinstance(allow_override, bool) else None,
|
||||
}
|
||||
return {"inherit": inherit if isinstance(inherit, bool) else None}
|
||||
|
||||
|
||||
def _normalize_allow_lower_level_limits(value: Any, *, fill_defaults: bool = False) -> dict[str, bool]:
|
||||
if value is None:
|
||||
return default_mail_policy_lower_level_limits() if fill_defaults else {}
|
||||
if not isinstance(value, dict):
|
||||
raise MailProfileError("Mail profile lower-level limit policy must be an object")
|
||||
normalized = default_mail_policy_lower_level_limits() if fill_defaults else {}
|
||||
for key in MAIL_POLICY_LIMIT_KEYS:
|
||||
raw_value = value.get(key)
|
||||
if isinstance(raw_value, bool):
|
||||
normalized[key] = raw_value
|
||||
return normalized
|
||||
|
||||
|
||||
def normalize_mail_profile_policy(payload: dict[str, Any] | None) -> dict[str, Any]:
|
||||
@@ -180,6 +221,7 @@ def normalize_mail_profile_policy(payload: dict[str, Any] | None) -> dict[str, A
|
||||
"imap_credentials": _normalize_credential_policy(data.get("imap_credentials")),
|
||||
"whitelist": whitelist,
|
||||
"blacklist": blacklist,
|
||||
"allow_lower_level_limits": _normalize_allow_lower_level_limits(data.get("allow_lower_level_limits")),
|
||||
}
|
||||
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
|
||||
value = data.get(key)
|
||||
@@ -211,68 +253,117 @@ def _mail_policy_source_fields(policy: dict[str, Any], *, baseline: bool = False
|
||||
if isinstance(credential, dict):
|
||||
if isinstance(credential.get("inherit"), bool):
|
||||
fields.append(f"{key}.inherit")
|
||||
if isinstance(credential.get("allow_override"), bool):
|
||||
fields.append(f"{key}.allow_override")
|
||||
for kind in ("whitelist", "blacklist"):
|
||||
rules = policy.get(kind) or {}
|
||||
if isinstance(rules, dict):
|
||||
for key in PROFILE_PATTERN_KEYS:
|
||||
if _clean_string_list(rules.get(key, [])):
|
||||
fields.append(f"{kind}.{key}")
|
||||
allow_lower = policy.get("allow_lower_level_limits") or {}
|
||||
if isinstance(allow_lower, dict):
|
||||
for key in MAIL_POLICY_LIMIT_KEYS:
|
||||
if isinstance(allow_lower.get(key), bool):
|
||||
fields.append(f"allow_lower_level_limits.{key}")
|
||||
if baseline and not fields:
|
||||
fields.append("defaults")
|
||||
return fields
|
||||
|
||||
|
||||
def _mail_policy_source_policy(policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
|
||||
normalized = normalize_mail_profile_policy(policy)
|
||||
if not baseline:
|
||||
return normalized
|
||||
source_policy = EffectiveMailProfilePolicy().as_dict()
|
||||
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
|
||||
if allowed_ids:
|
||||
source_policy["allowed_profile_ids"] = allowed_ids
|
||||
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
|
||||
value = normalized.get(key)
|
||||
if isinstance(value, bool):
|
||||
source_policy[key] = value
|
||||
for key in ("smtp_credentials", "imap_credentials"):
|
||||
credential = normalized.get(key) or {}
|
||||
source_credential = dict(source_policy.get(key) or {})
|
||||
if isinstance(credential, dict):
|
||||
if isinstance(credential.get("inherit"), bool):
|
||||
source_credential["inherit"] = credential["inherit"]
|
||||
source_policy[key] = source_credential
|
||||
source_policy["whitelist"] = dict(normalized.get("whitelist") or {})
|
||||
source_policy["blacklist"] = dict(normalized.get("blacklist") or {})
|
||||
source_policy["allow_lower_level_limits"] = {
|
||||
**default_mail_policy_lower_level_limits(),
|
||||
**(normalized.get("allow_lower_level_limits") or {}),
|
||||
}
|
||||
return source_policy
|
||||
|
||||
|
||||
def _mail_policy_source_step(source: str, source_id: str | None, label: str, policy: dict[str, Any], *, baseline: bool = False) -> dict[str, Any]:
|
||||
return {
|
||||
"scope_type": source,
|
||||
"scope_id": source_id,
|
||||
"label": label,
|
||||
"applied_fields": _mail_policy_source_fields(policy, baseline=baseline),
|
||||
"policy": _mail_policy_source_policy(policy, baseline=baseline),
|
||||
}
|
||||
|
||||
def _merge_credential_policy(current: EffectiveCredentialPolicy, data: dict[str, Any], *, source: str) -> None:
|
||||
inherit = data.get("inherit")
|
||||
if isinstance(inherit, bool):
|
||||
if current.allow_override:
|
||||
current.inherit = inherit
|
||||
current.explicit_inherit = True
|
||||
current.inherit_source = source
|
||||
elif inherit == current.inherit:
|
||||
current.explicit_inherit = True
|
||||
|
||||
allow_override = data.get("allow_override")
|
||||
if isinstance(allow_override, bool):
|
||||
if current.allow_override:
|
||||
current.allow_override = allow_override
|
||||
elif allow_override is False:
|
||||
current.allow_override = False
|
||||
def _merge_credential_policy(
|
||||
current: EffectiveCredentialPolicy,
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
source: str,
|
||||
lower_limits: dict[str, bool],
|
||||
inherit_limit_key: str,
|
||||
) -> None:
|
||||
inherit = data.get("inherit")
|
||||
if isinstance(inherit, bool) and lower_limits.get(inherit_limit_key, True):
|
||||
current.inherit = inherit
|
||||
current.explicit_inherit = True
|
||||
current.inherit_source = source
|
||||
|
||||
|
||||
def _merge_policy(policy: EffectiveMailProfilePolicy, data: dict[str, Any], *, source: str, source_id: str | None = None, label: str | None = None, baseline: bool = False) -> None:
|
||||
normalized = normalize_mail_profile_policy(data)
|
||||
policy.source_policies.append(_mail_policy_source_step(source, source_id, label or source.capitalize(), normalized, baseline=baseline))
|
||||
lower_limits = dict(policy.allow_lower_level_limits)
|
||||
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
|
||||
if allowed_ids:
|
||||
if allowed_ids and lower_limits.get("allowed_profile_ids", True):
|
||||
policy.allowed_profile_id_sets.append(set(allowed_ids))
|
||||
if normalized.get("allow_user_profiles") is False:
|
||||
if normalized.get("allow_user_profiles") is False and lower_limits.get("allow_user_profiles", True):
|
||||
policy.allow_user_profiles = False
|
||||
if normalized.get("allow_group_profiles") is False:
|
||||
if normalized.get("allow_group_profiles") is False and lower_limits.get("allow_group_profiles", True):
|
||||
policy.allow_group_profiles = False
|
||||
if normalized.get("allow_campaign_profiles") is False:
|
||||
if normalized.get("allow_campaign_profiles") is False and lower_limits.get("allow_campaign_profiles", True):
|
||||
policy.allow_campaign_profiles = False
|
||||
_merge_credential_policy(policy.smtp_credentials, normalized.get("smtp_credentials") or {}, source=source)
|
||||
_merge_credential_policy(policy.imap_credentials, normalized.get("imap_credentials") or {}, source=source)
|
||||
_merge_credential_policy(
|
||||
policy.smtp_credentials,
|
||||
normalized.get("smtp_credentials") or {},
|
||||
source=source,
|
||||
lower_limits=lower_limits,
|
||||
inherit_limit_key="smtp_credentials.inherit",
|
||||
)
|
||||
_merge_credential_policy(
|
||||
policy.imap_credentials,
|
||||
normalized.get("imap_credentials") or {},
|
||||
source=source,
|
||||
lower_limits=lower_limits,
|
||||
inherit_limit_key="imap_credentials.inherit",
|
||||
)
|
||||
whitelist = normalized.get("whitelist") or {}
|
||||
blacklist = normalized.get("blacklist") or {}
|
||||
for key in PROFILE_PATTERN_KEYS:
|
||||
allow_patterns = _meaningful_allow_patterns(whitelist.get(key, []))
|
||||
if allow_patterns:
|
||||
if allow_patterns and lower_limits.get(f"whitelist.{key}", True):
|
||||
policy.whitelist_groups.setdefault(key, []).append(allow_patterns)
|
||||
deny_patterns = _clean_string_list(blacklist.get(key, []))
|
||||
if deny_patterns:
|
||||
if deny_patterns and lower_limits.get(f"blacklist.{key}", True):
|
||||
policy.blacklist_patterns.setdefault(key, []).extend(deny_patterns)
|
||||
local_lower_limits = normalized.get("allow_lower_level_limits") or {}
|
||||
if local_lower_limits:
|
||||
policy.allow_lower_level_limits = {
|
||||
key: lower_limits.get(key, True) and local_lower_limits.get(key, lower_limits.get(key, True))
|
||||
for key in MAIL_POLICY_LIMIT_KEYS
|
||||
}
|
||||
|
||||
|
||||
def _owner_context(
|
||||
@@ -282,10 +373,10 @@ def _owner_context(
|
||||
campaign_id: str | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
owner_group_id: str | None = None,
|
||||
) -> tuple[Campaign | None, str | None, str | None]:
|
||||
) -> tuple[Any | None, str | None, str | None]:
|
||||
campaign = None
|
||||
if campaign_id:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
campaign = session.get(_campaign_model(), campaign_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise MailProfileError("Campaign not found for mail-server profile policy")
|
||||
owner_user_id = campaign.owner_user_id
|
||||
@@ -329,6 +420,38 @@ def effective_mail_profile_policy(
|
||||
return policy
|
||||
|
||||
|
||||
|
||||
def effective_mail_profile_policy_for_scope(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
scope_type: str,
|
||||
scope_id: str | None = None,
|
||||
campaign_id: str | None = None,
|
||||
) -> EffectiveMailProfilePolicy:
|
||||
clean_scope = _normalize_scope_type(scope_type)
|
||||
if campaign_id:
|
||||
return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
if clean_scope == "system":
|
||||
policy = EffectiveMailProfilePolicy()
|
||||
system_settings = get_system_settings(session)
|
||||
_merge_policy(policy, _policy_from_settings(system_settings.settings or {}), source="system", label="System", baseline=True)
|
||||
return policy
|
||||
if clean_scope == "tenant":
|
||||
return effective_mail_profile_policy(session, tenant_id=tenant_id)
|
||||
if clean_scope == "user":
|
||||
if not scope_id:
|
||||
raise MailProfileError("User mail profile policy requires scope_id")
|
||||
return effective_mail_profile_policy(session, tenant_id=tenant_id, owner_user_id=scope_id)
|
||||
if clean_scope == "group":
|
||||
if not scope_id:
|
||||
raise MailProfileError("Group mail profile policy requires scope_id")
|
||||
return effective_mail_profile_policy(session, tenant_id=tenant_id, owner_group_id=scope_id)
|
||||
if not scope_id:
|
||||
raise MailProfileError("Campaign mail profile policy requires scope_id")
|
||||
return effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=scope_id)
|
||||
|
||||
|
||||
def parent_mail_profile_policy(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -353,7 +476,7 @@ def parent_mail_profile_policy(
|
||||
|
||||
if clean_scope != "campaign" or not scope_id:
|
||||
return policy
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
campaign = session.get(_campaign_model(), scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise MailProfileError("Campaign policy not found")
|
||||
if campaign.owner_user_id:
|
||||
@@ -470,7 +593,7 @@ def _scope_tuple_for_create(
|
||||
return tenant_id, clean_scope, clean_scope_id
|
||||
if not clean_scope_id:
|
||||
raise MailProfileError("Campaign-scoped mail-server profiles require a campaign scope_id")
|
||||
campaign = session.get(Campaign, clean_scope_id)
|
||||
campaign = session.get(_campaign_model(), clean_scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise MailProfileError("Campaign scope is not part of the active tenant")
|
||||
return tenant_id, clean_scope, clean_scope_id
|
||||
@@ -524,7 +647,7 @@ def _profile_visible_filter(tenant_id: str):
|
||||
return or_(MailServerProfile.scope_type == "system", MailServerProfile.tenant_id == tenant_id)
|
||||
|
||||
|
||||
def _context_profile_clauses(campaign: Campaign, policy: EffectiveMailProfilePolicy) -> list[Any]:
|
||||
def _context_profile_clauses(campaign: Any, policy: EffectiveMailProfilePolicy) -> list[Any]:
|
||||
clauses: list[Any] = [
|
||||
MailServerProfile.scope_type == "system",
|
||||
and_(MailServerProfile.tenant_id == campaign.tenant_id, MailServerProfile.scope_type == "tenant"),
|
||||
@@ -551,7 +674,7 @@ def _profile_allowed_for_context(
|
||||
*,
|
||||
tenant_id: str,
|
||||
policy: EffectiveMailProfilePolicy,
|
||||
campaign: Campaign | None = None,
|
||||
campaign: Any | None = None,
|
||||
owner_user_id: str | None = None,
|
||||
owner_group_id: str | None = None,
|
||||
) -> bool:
|
||||
@@ -588,7 +711,7 @@ def list_mail_server_profiles(
|
||||
campaign_id: str | None = None,
|
||||
) -> list[MailServerProfile]:
|
||||
if campaign_id:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
campaign = session.get(_campaign_model(), campaign_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise MailProfileError("Campaign not found for mail-server profiles")
|
||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign.id)
|
||||
@@ -639,7 +762,7 @@ def ensure_mail_profile_allowed_for_campaign(
|
||||
profile_id: str,
|
||||
require_active: bool = True,
|
||||
) -> MailServerProfile:
|
||||
campaign = session.get(Campaign, campaign_id)
|
||||
campaign = session.get(_campaign_model(), campaign_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise MailProfileError("Campaign not found for mail-server profile policy")
|
||||
profile = get_mail_server_profile(session, tenant_id=tenant_id, profile_id=profile_id, require_active=require_active)
|
||||
@@ -668,10 +791,18 @@ def _legacy_profile_credentials_inherited(server: dict[str, Any], protocol: str)
|
||||
return server.get(f"inherit_{protocol}_credentials") is not False
|
||||
|
||||
|
||||
def _local_credential_inheritance_override_allowed(policy: EffectiveMailProfilePolicy, protocol: str) -> bool:
|
||||
credential_policy = _credential_policy_for_protocol(policy, protocol)
|
||||
return (
|
||||
credential_policy.inherit_source != "campaign"
|
||||
and policy.allow_lower_level_limits.get(f"{protocol}_credentials.inherit", True)
|
||||
)
|
||||
|
||||
|
||||
def profile_credentials_inherited_for_server(policy: EffectiveMailProfilePolicy, server: dict[str, Any], protocol: str) -> bool:
|
||||
credential_policy = _credential_policy_for_protocol(policy, protocol)
|
||||
legacy_key = f"inherit_{protocol}_credentials"
|
||||
if legacy_key in server and credential_policy.allow_override and credential_policy.inherit_source != "campaign":
|
||||
if legacy_key in server and _local_credential_inheritance_override_allowed(policy, protocol):
|
||||
return _legacy_profile_credentials_inherited(server, protocol)
|
||||
return credential_policy.inherit
|
||||
|
||||
@@ -718,7 +849,7 @@ def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, p
|
||||
legacy_key = f"inherit_{protocol}_credentials"
|
||||
if legacy_key in server:
|
||||
legacy_inherit = _legacy_profile_credentials_inherited(server, protocol)
|
||||
if (not credential_policy.allow_override or credential_policy.inherit_source == "campaign") and legacy_inherit != credential_policy.inherit:
|
||||
if not _local_credential_inheritance_override_allowed(policy, protocol) and legacy_inherit != credential_policy.inherit:
|
||||
raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by the effective mail profile policy")
|
||||
inherited = profile_credentials_inherited_for_server(policy, server, protocol)
|
||||
username, password = _transport_credentials_from_server(server, protocol)
|
||||
@@ -728,14 +859,16 @@ def _assert_profile_credentials_allowed_for_server(profile: MailServerProfile, p
|
||||
raise MailProfileError(f"{protocol.upper()} credentials must be supplied by this campaign or scope policy")
|
||||
|
||||
|
||||
def _inline_transport_configured(value: Any) -> bool:
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
if value.get("enabled") is True:
|
||||
return True
|
||||
for key in ("host", "username", "password"):
|
||||
def _inline_transport_configured(server: dict[str, Any], protocol: str) -> bool:
|
||||
value = server.get(protocol) if isinstance(server.get(protocol), dict) else {}
|
||||
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
|
||||
credential_value = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
|
||||
for key in ("host",):
|
||||
if str(value.get(key) or "").strip():
|
||||
return True
|
||||
for key in ("username", "password"):
|
||||
if str(credential_value.get(key) or value.get(key) or "").strip():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@@ -784,7 +917,7 @@ def assert_campaign_mail_policy_allows_json(
|
||||
imap = server.get("imap") if isinstance(server, dict) and isinstance(server.get("imap"), dict) else None
|
||||
if campaign_id:
|
||||
policy = effective_mail_profile_policy(session, tenant_id=tenant_id, campaign_id=campaign_id)
|
||||
if not policy.allow_campaign_profiles and (_inline_transport_configured(smtp) or _inline_transport_configured(imap)):
|
||||
if not policy.allow_campaign_profiles and (_inline_transport_configured(server, "smtp") or _inline_transport_configured(server, "imap")):
|
||||
raise MailProfileError("Campaign-local inline mail settings are disabled by policy")
|
||||
_assert_transport_values_allowed(policy, smtp, imap)
|
||||
return
|
||||
@@ -834,11 +967,12 @@ def create_mail_server_profile(
|
||||
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=smtp, imap=imap)
|
||||
clean_slug = slugify_profile_name(slug or clean_name)
|
||||
_ensure_unique_slug(session, scope_type=clean_scope_type, scope_id=clean_scope_id, slug=clean_slug)
|
||||
smtp_payload, smtp_password, _ = _transport_payload(smtp)
|
||||
smtp_payload, smtp_username, smtp_password, _, _ = _transport_payload(smtp)
|
||||
imap_payload = None
|
||||
imap_username = None
|
||||
imap_password = None
|
||||
if imap is not None:
|
||||
imap_payload, imap_password, _ = _transport_payload(imap)
|
||||
imap_payload, imap_username, imap_password, _, _ = _transport_payload(imap)
|
||||
profile = MailServerProfile(
|
||||
tenant_id=profile_tenant_id,
|
||||
scope_type=clean_scope_type,
|
||||
@@ -848,8 +982,10 @@ def create_mail_server_profile(
|
||||
description=description,
|
||||
is_active=is_active,
|
||||
smtp_config=smtp_payload,
|
||||
smtp_username=smtp_username,
|
||||
smtp_password_encrypted=encrypt_secret(smtp_password),
|
||||
imap_config=imap_payload,
|
||||
imap_username=imap_username,
|
||||
imap_password_encrypted=encrypt_secret(imap_password),
|
||||
created_by_user_id=user_id,
|
||||
updated_by_user_id=user_id,
|
||||
@@ -893,13 +1029,13 @@ def update_mail_server_profile(
|
||||
if is_active is not None:
|
||||
profile.is_active = is_active
|
||||
|
||||
next_smtp = smtp or SmtpConfig.model_validate({**(profile.smtp_config or {}), "password": decrypt_secret(profile.smtp_password_encrypted)})
|
||||
next_smtp = smtp or SmtpConfig.model_validate({**(profile.smtp_config or {}), "username": _profile_username(profile, "smtp"), "password": decrypt_secret(profile.smtp_password_encrypted)})
|
||||
if clear_imap:
|
||||
next_imap = None
|
||||
elif imap is not None:
|
||||
next_imap = imap
|
||||
elif profile.imap_config:
|
||||
next_imap = ImapConfig.model_validate({**(profile.imap_config or {}), "password": decrypt_secret(profile.imap_password_encrypted)})
|
||||
next_imap = ImapConfig.model_validate({**(profile.imap_config or {}), "username": _profile_username(profile, "imap"), "password": decrypt_secret(profile.imap_password_encrypted)})
|
||||
else:
|
||||
next_imap = None
|
||||
|
||||
@@ -915,17 +1051,22 @@ def update_mail_server_profile(
|
||||
assert_mail_policy_allows_transport(session, tenant_id=tenant_id, smtp=next_smtp, imap=next_imap)
|
||||
|
||||
if smtp is not None:
|
||||
smtp_payload, smtp_password, supplied = _transport_payload(smtp)
|
||||
smtp_payload, smtp_username, smtp_password, username_supplied, password_supplied = _transport_payload(smtp)
|
||||
profile.smtp_config = smtp_payload
|
||||
if supplied:
|
||||
if username_supplied:
|
||||
profile.smtp_username = smtp_username
|
||||
if password_supplied:
|
||||
profile.smtp_password_encrypted = encrypt_secret(smtp_password)
|
||||
if clear_imap:
|
||||
profile.imap_config = None
|
||||
profile.imap_username = None
|
||||
profile.imap_password_encrypted = None
|
||||
elif imap is not None:
|
||||
imap_payload, imap_password, supplied = _transport_payload(imap)
|
||||
imap_payload, imap_username, imap_password, username_supplied, password_supplied = _transport_payload(imap)
|
||||
profile.imap_config = imap_payload
|
||||
if supplied:
|
||||
if username_supplied:
|
||||
profile.imap_username = imap_username
|
||||
if password_supplied:
|
||||
profile.imap_password_encrypted = encrypt_secret(imap_password)
|
||||
profile.updated_by_user_id = user_id
|
||||
session.add(profile)
|
||||
@@ -933,8 +1074,18 @@ def update_mail_server_profile(
|
||||
return profile
|
||||
|
||||
|
||||
def _profile_username(profile: MailServerProfile, protocol: str) -> str | None:
|
||||
if protocol == "smtp":
|
||||
return profile.smtp_username or (profile.smtp_config or {}).get("username")
|
||||
if protocol == "imap":
|
||||
return profile.imap_username or (profile.imap_config or {}).get("username")
|
||||
return None
|
||||
|
||||
|
||||
def smtp_config_from_profile(profile: MailServerProfile) -> SmtpConfig:
|
||||
payload = dict(profile.smtp_config or {})
|
||||
payload.pop("username", None)
|
||||
payload["username"] = _profile_username(profile, "smtp")
|
||||
payload["password"] = decrypt_secret(profile.smtp_password_encrypted)
|
||||
return SmtpConfig.model_validate(payload)
|
||||
|
||||
@@ -943,6 +1094,9 @@ def imap_config_from_profile(profile: MailServerProfile) -> ImapConfig | None:
|
||||
if not profile.imap_config:
|
||||
return None
|
||||
payload = dict(profile.imap_config or {})
|
||||
payload.pop("username", None)
|
||||
payload.pop("enabled", None)
|
||||
payload["username"] = _profile_username(profile, "imap")
|
||||
payload["password"] = decrypt_secret(profile.imap_password_encrypted)
|
||||
return ImapConfig.model_validate(payload)
|
||||
|
||||
@@ -952,11 +1106,15 @@ def inherit_profile_credentials(server: dict[str, Any], protocol: str) -> bool:
|
||||
|
||||
|
||||
def _transport_credentials_from_server(server: dict[str, Any], protocol: str) -> tuple[str | None, str | None]:
|
||||
config = server.get(protocol)
|
||||
if not isinstance(config, dict):
|
||||
return None, None
|
||||
username = config.get("username")
|
||||
password = config.get("password")
|
||||
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
|
||||
config = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else None
|
||||
legacy_config = server.get(protocol) if isinstance(server.get(protocol), dict) else None
|
||||
username = config.get("username") if config is not None else None
|
||||
password = config.get("password") if config is not None else None
|
||||
if username in (None, "") and legacy_config is not None:
|
||||
username = legacy_config.get("username")
|
||||
if password in (None, "") and legacy_config is not None:
|
||||
password = legacy_config.get("password")
|
||||
return (str(username) if username not in (None, "") else None, str(password) if password not in (None, "") else None)
|
||||
|
||||
|
||||
@@ -967,6 +1125,14 @@ def apply_campaign_credentials(payload: dict[str, Any], server: dict[str, Any],
|
||||
return payload
|
||||
|
||||
|
||||
def _server_config_payload(value: dict[str, Any] | None) -> dict[str, Any]:
|
||||
payload = dict(value or {})
|
||||
payload.pop("username", None)
|
||||
payload.pop("password", None)
|
||||
payload.pop("enabled", None)
|
||||
return payload
|
||||
|
||||
|
||||
def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]:
|
||||
return {
|
||||
"id": profile.id,
|
||||
@@ -977,8 +1143,12 @@ def profile_response_payload(profile: MailServerProfile) -> dict[str, Any]:
|
||||
"slug": profile.slug,
|
||||
"description": profile.description,
|
||||
"is_active": profile.is_active,
|
||||
"smtp": dict(profile.smtp_config or {}),
|
||||
"imap": dict(profile.imap_config or {}) if profile.imap_config else None,
|
||||
"smtp": _server_config_payload(profile.smtp_config),
|
||||
"imap": _server_config_payload(profile.imap_config) if profile.imap_config else None,
|
||||
"credentials": {
|
||||
"smtp": {"username": _profile_username(profile, "smtp")},
|
||||
"imap": {"username": _profile_username(profile, "imap")},
|
||||
},
|
||||
"smtp_password_configured": bool(profile.smtp_password_encrypted),
|
||||
"imap_password_configured": bool(profile.imap_password_encrypted),
|
||||
"created_at": profile.created_at,
|
||||
@@ -1013,22 +1183,36 @@ def get_mail_profile_policy(
|
||||
if group is None or group.tenant_id != tenant_id:
|
||||
raise MailProfileError("Group policy not found")
|
||||
return _policy_from_attr(group.mail_profile_policy)
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
campaign = session.get(_campaign_model(), scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise MailProfileError("Campaign policy not found")
|
||||
return _policy_from_attr(campaign.mail_profile_policy)
|
||||
|
||||
|
||||
def _validate_policy_against_parent(parent: EffectiveMailProfilePolicy, normalized: dict[str, Any]) -> None:
|
||||
parent_limits = parent.allow_lower_level_limits
|
||||
allowed_ids = _meaningful_allow_patterns(normalized.get("allowed_profile_ids") or [])
|
||||
if allowed_ids and not parent_limits.get("allowed_profile_ids", True):
|
||||
raise MailProfileError("Mail profile allow-list is locked by an ancestor policy")
|
||||
for key in ("allow_user_profiles", "allow_group_profiles", "allow_campaign_profiles"):
|
||||
if isinstance(normalized.get(key), bool) and not parent_limits.get(key, True):
|
||||
raise MailProfileError(f"{key} is locked by an ancestor policy")
|
||||
for kind in ("whitelist", "blacklist"):
|
||||
rules = normalized.get(kind) or {}
|
||||
if isinstance(rules, dict):
|
||||
for key in PROFILE_PATTERN_KEYS:
|
||||
if _clean_string_list(rules.get(key, [])) and not parent_limits.get(f"{kind}.{key}", True):
|
||||
raise MailProfileError(f"{kind}.{key} is locked by an ancestor policy")
|
||||
for protocol in ("smtp", "imap"):
|
||||
local = normalized.get(f"{protocol}_credentials") or {}
|
||||
parent_credentials = _credential_policy_for_protocol(parent, protocol)
|
||||
local_inherit = local.get("inherit")
|
||||
if isinstance(local_inherit, bool) and not parent_credentials.allow_override and local_inherit != parent_credentials.inherit:
|
||||
if isinstance(local_inherit, bool) and not parent_limits.get(f"{protocol}_credentials.inherit", True):
|
||||
raise MailProfileError(f"{protocol.upper()} credential inheritance is locked by an ancestor policy")
|
||||
local_allow_override = local.get("allow_override")
|
||||
if local_allow_override is True and not parent_credentials.allow_override:
|
||||
raise MailProfileError(f"{protocol.upper()} credential override cannot be re-enabled below a locked ancestor policy")
|
||||
local_lower_limits = normalized.get("allow_lower_level_limits") or {}
|
||||
if isinstance(local_lower_limits, dict):
|
||||
for key, allowed in local_lower_limits.items():
|
||||
if allowed is True and not parent_limits.get(key, True):
|
||||
raise MailProfileError(f"{key} lower-level limiting cannot be re-enabled below a locked ancestor policy")
|
||||
|
||||
|
||||
def set_mail_profile_policy(
|
||||
@@ -1078,7 +1262,7 @@ def set_mail_profile_policy(
|
||||
group.mail_profile_policy = normalized
|
||||
session.add(group)
|
||||
return normalized
|
||||
campaign = session.get(Campaign, scope_id)
|
||||
campaign = session.get(_campaign_model(), scope_id)
|
||||
if campaign is None or campaign.tenant_id != tenant_id:
|
||||
raise MailProfileError("Campaign policy not found")
|
||||
campaign.mail_profile_policy = normalized
|
||||
@@ -1086,6 +1270,23 @@ def set_mail_profile_policy(
|
||||
return normalized
|
||||
|
||||
|
||||
def _write_transport_to_server(server: dict[str, Any], protocol: str, payload: dict[str, Any]) -> None:
|
||||
server_payload = dict(payload)
|
||||
username = server_payload.pop("username", None)
|
||||
password = server_payload.pop("password", None)
|
||||
server_payload.pop("enabled", None)
|
||||
server[protocol] = server_payload
|
||||
if username not in (None, "") or password not in (None, ""):
|
||||
credentials = server.get("credentials") if isinstance(server.get("credentials"), dict) else {}
|
||||
protocol_credentials = credentials.get(protocol) if isinstance(credentials.get(protocol), dict) else {}
|
||||
if username not in (None, ""):
|
||||
protocol_credentials["username"] = username
|
||||
if password not in (None, ""):
|
||||
protocol_credentials["password"] = password
|
||||
credentials[protocol] = protocol_credentials
|
||||
server["credentials"] = credentials
|
||||
|
||||
|
||||
def materialize_campaign_mail_profile_config(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -1133,13 +1334,13 @@ def materialize_campaign_mail_profile_config(
|
||||
smtp_payload = smtp_config_from_profile(profile).model_dump(mode="json")
|
||||
if not profile_credentials_inherited_for_server(policy, server, "smtp"):
|
||||
smtp_payload = apply_campaign_credentials(smtp_payload, server, "smtp")
|
||||
resolved_server["smtp"] = smtp_payload
|
||||
_write_transport_to_server(resolved_server, "smtp", smtp_payload)
|
||||
imap = imap_config_from_profile(profile)
|
||||
if imap is not None:
|
||||
imap_payload = imap.model_dump(mode="json")
|
||||
if not profile_credentials_inherited_for_server(policy, server, "imap"):
|
||||
imap_payload = apply_campaign_credentials(imap_payload, server, "imap")
|
||||
resolved_server["imap"] = imap_payload
|
||||
_write_transport_to_server(resolved_server, "imap", imap_payload)
|
||||
else:
|
||||
resolved_server.pop("imap", None)
|
||||
data["server"] = resolved_server
|
||||
|
||||
@@ -82,6 +82,9 @@ manifest = ModuleManifest(
|
||||
metadata=Base.metadata,
|
||||
script_location=str(Path(__file__).with_name("migrations") / "versions"),
|
||||
),
|
||||
capability_factories={
|
||||
"mail.campaign_delivery": lambda context: __import__("govoplan_mail.backend.capabilities", fromlist=["campaign_capability"]).campaign_capability(context),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ from govoplan_core.db.session import get_session
|
||||
from govoplan_mail.backend.mail_profiles import (
|
||||
MailProfileError,
|
||||
create_mail_server_profile,
|
||||
effective_mail_profile_policy,
|
||||
effective_mail_profile_policy_for_scope,
|
||||
get_mail_profile_policy,
|
||||
get_mail_server_profile,
|
||||
imap_config_from_profile,
|
||||
@@ -36,6 +36,7 @@ from govoplan_mail.backend.mail_profiles import (
|
||||
smtp_config_from_profile,
|
||||
update_mail_server_profile,
|
||||
)
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||
from govoplan_mail.backend.sending.imap import ImapAppendError, ImapConfigurationError, get_imap_message, list_imap_folders, list_imap_messages, test_imap_login
|
||||
from govoplan_mail.backend.sending.smtp import test_smtp_login
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -172,7 +173,7 @@ def create_profile(
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_profile_write_scope(principal, payload.scope_type)
|
||||
if payload.smtp.password or (payload.imap and payload.imap.password):
|
||||
if payload.credentials_supplied():
|
||||
_require_profile_credentials_scope(principal, payload.scope_type)
|
||||
try:
|
||||
profile = create_mail_server_profile(
|
||||
@@ -182,8 +183,8 @@ def create_profile(
|
||||
name=payload.name,
|
||||
slug=payload.slug,
|
||||
description=payload.description,
|
||||
smtp=payload.smtp,
|
||||
imap=payload.imap,
|
||||
smtp=payload.smtp_config(),
|
||||
imap=payload.imap_config(),
|
||||
is_active=payload.is_active,
|
||||
scope_type=payload.scope_type,
|
||||
scope_id=payload.scope_id,
|
||||
@@ -219,8 +220,18 @@ def update_profile(
|
||||
try:
|
||||
profile = get_mail_server_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||
_require_profile_write_scope(principal, profile.scope_type or "tenant")
|
||||
if (payload.smtp and "password" in payload.smtp.model_fields_set) or (payload.imap and "password" in payload.imap.model_fields_set) or payload.clear_imap:
|
||||
if payload.credentials_supplied() or payload.clear_imap:
|
||||
_require_profile_credentials_scope(principal, profile.scope_type or "tenant")
|
||||
smtp_config = payload.smtp_config()
|
||||
if payload.smtp is None and "smtp" in payload.credentials.model_fields_set:
|
||||
smtp_data = dict(profile.smtp_config or {})
|
||||
smtp_data.update(payload.credentials.smtp.model_dump(mode="json", exclude_unset=True))
|
||||
smtp_config = SmtpConfig.model_validate(smtp_data)
|
||||
imap_config = payload.imap_config()
|
||||
if payload.imap is None and "imap" in payload.credentials.model_fields_set and profile.imap_config:
|
||||
imap_data = dict(profile.imap_config or {})
|
||||
imap_data.update(payload.credentials.imap.model_dump(mode="json", exclude_unset=True))
|
||||
imap_config = ImapConfig.model_validate(imap_data)
|
||||
update_mail_server_profile(
|
||||
session,
|
||||
profile,
|
||||
@@ -230,8 +241,8 @@ def update_profile(
|
||||
slug=payload.slug,
|
||||
description=payload.description if "description" in payload.model_fields_set else None,
|
||||
is_active=payload.is_active,
|
||||
smtp=payload.smtp,
|
||||
imap=payload.imap,
|
||||
smtp=smtp_config,
|
||||
imap=imap_config,
|
||||
clear_imap=payload.clear_imap,
|
||||
)
|
||||
session.commit()
|
||||
@@ -273,16 +284,15 @@ def read_mail_profile_policy(
|
||||
_require_policy_read_scope(principal, scope_type)
|
||||
try:
|
||||
policy = get_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id)
|
||||
effective = None
|
||||
effective_sources = []
|
||||
if campaign_id:
|
||||
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=campaign_id)
|
||||
effective = effective_policy.as_dict()
|
||||
effective_sources = effective_policy.source_policies
|
||||
elif scope_type == "campaign" and scope_id:
|
||||
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
|
||||
effective = effective_policy.as_dict()
|
||||
effective_sources = effective_policy.source_policies
|
||||
effective_policy = effective_mail_profile_policy_for_scope(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
campaign_id=campaign_id,
|
||||
)
|
||||
effective = effective_policy.as_dict()
|
||||
effective_sources = effective_policy.source_policies
|
||||
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
|
||||
parent = parent_policy.as_dict() if parent_policy else None
|
||||
parent_sources = parent_policy.source_policies if parent_policy else []
|
||||
@@ -310,12 +320,14 @@ def write_mail_profile_policy(
|
||||
policy=payload.policy.model_dump(mode="json"),
|
||||
)
|
||||
session.commit()
|
||||
effective = None
|
||||
effective_sources = []
|
||||
if scope_type == "campaign" and scope_id:
|
||||
effective_policy = effective_mail_profile_policy(session, tenant_id=principal.tenant_id, campaign_id=scope_id)
|
||||
effective = effective_policy.as_dict()
|
||||
effective_sources = effective_policy.source_policies
|
||||
effective_policy = effective_mail_profile_policy_for_scope(
|
||||
session,
|
||||
tenant_id=principal.tenant_id,
|
||||
scope_type=scope_type,
|
||||
scope_id=scope_id,
|
||||
)
|
||||
effective = effective_policy.as_dict()
|
||||
effective_sources = effective_policy.source_policies
|
||||
parent_policy = parent_mail_profile_policy(session, tenant_id=principal.tenant_id, scope_type=scope_type, scope_id=scope_id) if scope_type != "system" else None
|
||||
parent = parent_policy.as_dict() if parent_policy else None
|
||||
parent_sources = parent_policy.source_policies if parent_policy else []
|
||||
@@ -379,7 +391,7 @@ def list_profile_imap_folders(
|
||||
if imap is None:
|
||||
raise MailProfileError("Mail-server profile has no IMAP configuration")
|
||||
result = list_imap_folders(imap_config=imap)
|
||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
|
||||
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
|
||||
except MailProfileError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
@@ -397,7 +409,7 @@ def list_profile_mailbox_folders(
|
||||
try:
|
||||
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||
result = list_imap_folders(imap_config=imap)
|
||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
|
||||
return MailImapFolderListResponse(ok=True, host=result.host, port=result.port, security=result.security, message=f"Found {len(folders)} IMAP folder(s).", folders=folders, detected_sent_folder=result.detected_sent_folder)
|
||||
except MailProfileError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
|
||||
@@ -412,13 +424,14 @@ def list_profile_mailbox_messages(
|
||||
profile_id: str,
|
||||
folder: str = Query(default="INBOX", min_length=1, max_length=255),
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0, le=100000),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
_require_mailbox_read_scope(principal)
|
||||
try:
|
||||
imap = _imap_config_for_profile(session, tenant_id=principal.tenant_id, profile_id=profile_id)
|
||||
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit)
|
||||
result = list_imap_messages(imap_config=imap, folder=folder, limit=limit, offset=offset)
|
||||
return MailMailboxMessageListResponse(
|
||||
profile_id=profile_id,
|
||||
folder=result.folder,
|
||||
@@ -426,6 +439,8 @@ def list_profile_mailbox_messages(
|
||||
port=result.port,
|
||||
security=result.security,
|
||||
total_count=result.total_count,
|
||||
offset=result.offset,
|
||||
limit=result.limit,
|
||||
messages=[_mailbox_summary_response(message) for message in result.messages],
|
||||
)
|
||||
except MailProfileError as exc:
|
||||
@@ -551,7 +566,7 @@ def list_imap_folder_settings(
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Testing raw credentials requires mail_servers:manage_credentials")
|
||||
try:
|
||||
result = list_imap_folders(imap_config=payload)
|
||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags) for item in result.folders]
|
||||
folders = [MailImapFolderResponse(name=item.name, flags=item.flags, message_count=item.message_count, unseen_count=item.unseen_count) for item in result.folders]
|
||||
return MailImapFolderListResponse(
|
||||
ok=True,
|
||||
host=result.host,
|
||||
|
||||
@@ -3,9 +3,9 @@ from __future__ import annotations
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from govoplan_mail.backend.config import ImapConfig, SmtpConfig
|
||||
from govoplan_mail.backend.config import ImapConfig, ImapServerConfig, SmtpConfig, SmtpServerConfig, TransportCredentials
|
||||
|
||||
|
||||
class MailSmtpTestRequest(SmtpConfig):
|
||||
@@ -15,7 +15,50 @@ class MailSmtpTestRequest(SmtpConfig):
|
||||
class MailImapTestRequest(ImapConfig):
|
||||
"""IMAP settings supplied directly from the WebUI mail settings form."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
class MailTransportCredentialsPayload(TransportCredentials):
|
||||
"""Credentials supplied separately from saved server settings."""
|
||||
|
||||
|
||||
class MailServerProfileCredentialsPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
smtp: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload)
|
||||
imap: MailTransportCredentialsPayload = Field(default_factory=MailTransportCredentialsPayload)
|
||||
|
||||
|
||||
def _normalize_profile_transport_payload(value: object) -> object:
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
data = dict(value)
|
||||
credentials = data.get("credentials") if isinstance(data.get("credentials"), dict) else {}
|
||||
credentials = {key: dict(item) for key, item in credentials.items() if isinstance(item, dict)}
|
||||
for protocol in ("smtp", "imap"):
|
||||
transport = data.get(protocol)
|
||||
if not isinstance(transport, dict):
|
||||
continue
|
||||
next_transport = dict(transport)
|
||||
next_credentials = dict(credentials.get(protocol) or {})
|
||||
for field in ("username", "password"):
|
||||
if field in next_transport and field not in next_credentials:
|
||||
next_credentials[field] = next_transport[field]
|
||||
next_transport.pop(field, None)
|
||||
next_transport.pop("enabled", None)
|
||||
data[protocol] = next_transport
|
||||
if next_credentials:
|
||||
credentials[protocol] = next_credentials
|
||||
if credentials:
|
||||
data["credentials"] = credentials
|
||||
return data
|
||||
|
||||
|
||||
def _merge_transport_credentials(server: SmtpServerConfig | ImapServerConfig, credentials: MailTransportCredentialsPayload) -> dict[str, object]:
|
||||
payload = server.model_dump(mode="json")
|
||||
if "username" in credentials.model_fields_set:
|
||||
payload["username"] = credentials.username
|
||||
if "password" in credentials.model_fields_set:
|
||||
payload["password"] = credentials.password
|
||||
return payload
|
||||
|
||||
|
||||
MailProfileScope = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
@@ -25,7 +68,6 @@ class MailCredentialPolicyPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
inherit: bool | None = None
|
||||
allow_override: bool | None = None
|
||||
|
||||
|
||||
class MailProfilePolicyPayload(BaseModel):
|
||||
@@ -39,6 +81,7 @@ class MailProfilePolicyPayload(BaseModel):
|
||||
imap_credentials: MailCredentialPolicyPayload = Field(default_factory=MailCredentialPolicyPayload)
|
||||
whitelist: dict[str, list[str]] = Field(default_factory=dict)
|
||||
blacklist: dict[str, list[str]] = Field(default_factory=dict)
|
||||
allow_lower_level_limits: dict[str, bool] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailProfilePolicyUpdateRequest(BaseModel):
|
||||
@@ -52,6 +95,7 @@ class PolicySourceStepResponse(BaseModel):
|
||||
scope_id: str | None = None
|
||||
label: str
|
||||
applied_fields: list[str] = Field(default_factory=list)
|
||||
policy: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class MailProfilePolicyResponse(BaseModel):
|
||||
@@ -73,8 +117,29 @@ class MailServerProfileCreateRequest(BaseModel):
|
||||
is_active: bool = True
|
||||
scope_type: MailProfileScope = "tenant"
|
||||
scope_id: str | None = None
|
||||
smtp: SmtpConfig
|
||||
imap: ImapConfig | None = None
|
||||
smtp: SmtpServerConfig
|
||||
imap: ImapServerConfig | None = None
|
||||
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_legacy_transport_payload(cls, value: object) -> object:
|
||||
return _normalize_profile_transport_payload(value)
|
||||
|
||||
def smtp_config(self) -> SmtpConfig:
|
||||
return SmtpConfig.model_validate(_merge_transport_credentials(self.smtp, self.credentials.smtp))
|
||||
|
||||
def imap_config(self) -> ImapConfig | None:
|
||||
if self.imap is None:
|
||||
return None
|
||||
return ImapConfig.model_validate(_merge_transport_credentials(self.imap, self.credentials.imap))
|
||||
|
||||
def credentials_supplied(self) -> bool:
|
||||
return any(
|
||||
field in credentials.model_fields_set
|
||||
for credentials in (self.credentials.smtp, self.credentials.imap)
|
||||
for field in ("username", "password")
|
||||
)
|
||||
|
||||
|
||||
class MailServerProfileUpdateRequest(BaseModel):
|
||||
@@ -84,10 +149,35 @@ class MailServerProfileUpdateRequest(BaseModel):
|
||||
slug: str | None = Field(default=None, max_length=100)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
smtp: SmtpConfig | None = None
|
||||
imap: ImapConfig | None = None
|
||||
smtp: SmtpServerConfig | None = None
|
||||
imap: ImapServerConfig | None = None
|
||||
credentials: MailServerProfileCredentialsPayload = Field(default_factory=MailServerProfileCredentialsPayload)
|
||||
clear_imap: bool = False
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def normalize_legacy_transport_payload(cls, value: object) -> object:
|
||||
return _normalize_profile_transport_payload(value)
|
||||
|
||||
def smtp_config(self) -> SmtpConfig | None:
|
||||
if self.smtp is None and "smtp" not in self.credentials.model_fields_set:
|
||||
return None
|
||||
server = self.smtp or SmtpServerConfig()
|
||||
return SmtpConfig.model_validate(_merge_transport_credentials(server, self.credentials.smtp))
|
||||
|
||||
def imap_config(self) -> ImapConfig | None:
|
||||
if self.imap is None and "imap" not in self.credentials.model_fields_set:
|
||||
return None
|
||||
server = self.imap or ImapServerConfig()
|
||||
return ImapConfig.model_validate(_merge_transport_credentials(server, self.credentials.imap))
|
||||
|
||||
def credentials_supplied(self) -> bool:
|
||||
return any(
|
||||
field in credentials.model_fields_set
|
||||
for credentials in (self.credentials.smtp, self.credentials.imap)
|
||||
for field in ("username", "password")
|
||||
)
|
||||
|
||||
|
||||
class MailServerProfileResponse(BaseModel):
|
||||
id: str
|
||||
@@ -100,6 +190,7 @@ class MailServerProfileResponse(BaseModel):
|
||||
is_active: bool
|
||||
smtp: dict[str, Any]
|
||||
imap: dict[str, Any] | None = None
|
||||
credentials: dict[str, Any] = Field(default_factory=dict)
|
||||
smtp_password_configured: bool = False
|
||||
imap_password_configured: bool = False
|
||||
created_at: datetime
|
||||
@@ -123,6 +214,8 @@ class MailConnectionTestResponse(BaseModel):
|
||||
class MailImapFolderResponse(BaseModel):
|
||||
name: str
|
||||
flags: list[str] = Field(default_factory=list)
|
||||
message_count: int | None = None
|
||||
unseen_count: int | None = None
|
||||
|
||||
|
||||
class MailImapFolderListResponse(BaseModel):
|
||||
@@ -171,6 +264,8 @@ class MailMailboxMessageListResponse(BaseModel):
|
||||
port: int | None = None
|
||||
security: str | None = None
|
||||
total_count: int = 0
|
||||
offset: int = 0
|
||||
limit: int = 50
|
||||
messages: list[MailMailboxMessageSummaryResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ class ImapLoginTestResult:
|
||||
class ImapMailboxInfo:
|
||||
name: str
|
||||
flags: list[str]
|
||||
message_count: int | None = None
|
||||
unseen_count: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -111,6 +113,8 @@ class ImapMailboxMessageListResult:
|
||||
folder: str
|
||||
messages: list[ImapMailboxMessageSummary]
|
||||
total_count: int
|
||||
offset: int
|
||||
limit: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -133,8 +137,6 @@ class ImapAppendResult:
|
||||
|
||||
|
||||
def _require_imap_config(config: ImapConfig) -> tuple[str, int]:
|
||||
if not config.enabled:
|
||||
raise ImapConfigurationError("IMAP is disabled")
|
||||
if not config.host:
|
||||
raise ImapConfigurationError("IMAP host is required")
|
||||
if not config.port:
|
||||
@@ -311,7 +313,12 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
||||
|
||||
host, port = _require_imap_config(imap_config)
|
||||
if is_mock_imap_host(imap_config.host):
|
||||
folders = [ImapMailboxInfo(name=str(item["name"]), flags=list(item.get("flags") or [])) for item in MOCK_IMAP_FOLDERS]
|
||||
records = list_records(limit=500)
|
||||
folders = []
|
||||
for item in MOCK_IMAP_FOLDERS:
|
||||
name = str(item["name"])
|
||||
count = sum(1 for record in records if _mock_folder_matches(record, name))
|
||||
folders.append(ImapMailboxInfo(name=name, flags=list(item.get("flags") or []), message_count=count, unseen_count=None))
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
port=port,
|
||||
@@ -334,7 +341,8 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
||||
continue
|
||||
name, flags = extracted
|
||||
parsed.append((name, flags))
|
||||
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags)))
|
||||
message_count, unseen_count = (None, None) if _has_folder_flag(flags, "noselect") else _imap_folder_status(client, name)
|
||||
folders.append(ImapMailboxInfo(name=name, flags=sorted(flags), message_count=message_count, unseen_count=unseen_count))
|
||||
|
||||
return ImapFolderListResult(
|
||||
host=host,
|
||||
@@ -350,6 +358,31 @@ def list_imap_folders(*, imap_config: ImapConfig) -> ImapFolderListResult:
|
||||
pass
|
||||
|
||||
|
||||
def _has_folder_flag(flags: set[str], flag: str) -> bool:
|
||||
wanted = flag.casefold().lstrip("\\")
|
||||
return any(item.casefold().lstrip("\\") == wanted for item in flags)
|
||||
|
||||
|
||||
def _quote_mailbox_name(name: str) -> str:
|
||||
return "\"" + name.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
|
||||
|
||||
def _imap_folder_status(client: imaplib.IMAP4, folder: str) -> tuple[int | None, int | None]:
|
||||
try:
|
||||
typ, data = client.status(_quote_mailbox_name(folder), "(MESSAGES UNSEEN)")
|
||||
except Exception:
|
||||
return None, None
|
||||
if typ != "OK":
|
||||
return None, None
|
||||
text = " ".join(_decode_item(item) for item in data or [])
|
||||
messages_match = re.search(r"\bMESSAGES\s+(\d+)", text, re.IGNORECASE)
|
||||
unseen_match = re.search(r"\bUNSEEN\s+(\d+)", text, re.IGNORECASE)
|
||||
return (
|
||||
int(messages_match.group(1)) if messages_match else None,
|
||||
int(unseen_match.group(1)) if unseen_match else None,
|
||||
)
|
||||
|
||||
|
||||
def _parse_message(raw: bytes) -> EmailMessage:
|
||||
return BytesParser(policy=policy.default).parsebytes(raw)
|
||||
|
||||
@@ -393,6 +426,24 @@ def _attachment_infos(message: EmailMessage) -> list[ImapMailboxAttachmentInfo]:
|
||||
return attachments
|
||||
|
||||
|
||||
def _message_summary_from_headers(*, uid: str, folder: str, headers: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
|
||||
message = _parse_message(headers.rstrip() + b"\r\n\r\n")
|
||||
return ImapMailboxMessageSummary(
|
||||
uid=uid,
|
||||
folder=folder,
|
||||
subject=_header_text(message, "Subject"),
|
||||
from_header=_header_text(message, "From"),
|
||||
to_header=_header_text(message, "To"),
|
||||
cc_header=_header_text(message, "Cc"),
|
||||
date=_header_text(message, "Date"),
|
||||
message_id=_header_text(message, "Message-ID"),
|
||||
flags=flags,
|
||||
size_bytes=size_bytes,
|
||||
body_preview=None,
|
||||
attachment_count=0,
|
||||
)
|
||||
|
||||
|
||||
def _message_summary_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[str], size_bytes: int | None) -> ImapMailboxMessageSummary:
|
||||
message = _parse_message(raw)
|
||||
attachments = _attachment_infos(message)
|
||||
@@ -434,6 +485,14 @@ def _message_detail_from_raw(*, uid: str, folder: str, raw: bytes, flags: list[s
|
||||
)
|
||||
|
||||
|
||||
def _parse_fetch_metadata(metadata: str) -> tuple[str | None, list[str], int | None]:
|
||||
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
|
||||
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
|
||||
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
|
||||
flags = flags_match.group(1).split() if flags_match else []
|
||||
return uid_match.group(1) if uid_match else None, flags, int(size_match.group(1)) if size_match else None
|
||||
|
||||
|
||||
def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str | None, list[str], int | None, bytes]:
|
||||
metadata_parts: list[str] = []
|
||||
raw: bytes | None = None
|
||||
@@ -445,24 +504,38 @@ def _parse_fetch_response(data: list[Any] | tuple[Any, ...] | None) -> tuple[str
|
||||
else:
|
||||
metadata_parts.append(_decode_item(item))
|
||||
metadata = " ".join(part for part in metadata_parts if part)
|
||||
uid_match = re.search(r"\bUID\s+(\d+)", metadata, re.IGNORECASE)
|
||||
size_match = re.search(r"\bRFC822\.SIZE\s+(\d+)", metadata, re.IGNORECASE)
|
||||
flags_match = re.search(r"\bFLAGS\s+\(([^)]*)\)", metadata, re.IGNORECASE)
|
||||
flags = flags_match.group(1).split() if flags_match else []
|
||||
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
|
||||
if raw is None:
|
||||
raise ImapAppendError(f"IMAP message fetch returned no message body: {metadata!r}", temporary=True)
|
||||
return (
|
||||
uid_match.group(1) if uid_match else None,
|
||||
flags,
|
||||
int(size_match.group(1)) if size_match else None,
|
||||
raw,
|
||||
)
|
||||
return uid, flags, size_bytes, raw
|
||||
|
||||
|
||||
def _select_readonly(client: imaplib.IMAP4, folder: str) -> None:
|
||||
def _parse_fetch_sequence(metadata: str) -> str | None:
|
||||
match = re.match(r"\s*(\d+)\s+\(", metadata)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _parse_fetch_parts_with_sequence(data: list[Any] | tuple[Any, ...] | None) -> list[tuple[str | None, str | None, list[str], int | None, bytes]]:
|
||||
parts: list[tuple[str | None, str | None, list[str], int | None, bytes]] = []
|
||||
for item in data or []:
|
||||
if not isinstance(item, tuple) or not isinstance(item[1], bytes):
|
||||
continue
|
||||
metadata = _decode_item(item[0])
|
||||
sequence = _parse_fetch_sequence(metadata)
|
||||
uid, flags, size_bytes = _parse_fetch_metadata(metadata)
|
||||
parts.append((sequence, uid, flags, size_bytes, item[1]))
|
||||
return parts
|
||||
|
||||
|
||||
def _select_readonly(client: imaplib.IMAP4, folder: str) -> int:
|
||||
typ, data = client.select(folder, readonly=True)
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP folder {folder!r} could not be opened read-only: {data!r}", temporary=False)
|
||||
selected_count = _decode_item(data[0] if data else None).strip()
|
||||
try:
|
||||
return max(0, int(selected_count))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[str], int | None, bytes]:
|
||||
@@ -473,6 +546,50 @@ def _fetch_message_by_uid(client: imaplib.IMAP4, uid: str) -> tuple[str, list[st
|
||||
return fetched_uid or str(uid), flags, size_bytes, raw
|
||||
|
||||
|
||||
def _fetch_message_by_sequence(client: imaplib.IMAP4, sequence: str) -> tuple[str, list[str], int | None, bytes]:
|
||||
typ, data = client.fetch(str(sequence), "(UID FLAGS RFC822.SIZE BODY.PEEK[])")
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch failed: {data!r}", temporary=True)
|
||||
fetched_uid, flags, size_bytes, raw = _parse_fetch_response(data)
|
||||
if not fetched_uid:
|
||||
raise ImapAppendError(f"IMAP message sequence {sequence!r} fetch returned no UID", temporary=True)
|
||||
return fetched_uid, flags, size_bytes, raw
|
||||
|
||||
|
||||
def _sequence_set(sequences: list[str]) -> str:
|
||||
sequence_numbers = sorted({int(sequence) for sequence in sequences if str(sequence).isdigit()})
|
||||
if not sequence_numbers:
|
||||
return ",".join(str(sequence) for sequence in sequences)
|
||||
expected_range = list(range(sequence_numbers[0], sequence_numbers[-1] + 1))
|
||||
if sequence_numbers == expected_range:
|
||||
return f"{sequence_numbers[0]}:{sequence_numbers[-1]}"
|
||||
return ",".join(str(sequence) for sequence in sequence_numbers)
|
||||
|
||||
|
||||
def _fetch_message_summaries_by_sequence(client: imaplib.IMAP4, sequences: list[str], folder: str) -> list[ImapMailboxMessageSummary]:
|
||||
if not sequences:
|
||||
return []
|
||||
typ, data = client.fetch(_sequence_set(sequences), "(UID FLAGS RFC822.SIZE BODY.PEEK[HEADER.FIELDS (SUBJECT FROM TO CC DATE MESSAGE-ID)])")
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP message summary fetch failed: {data!r}", temporary=True)
|
||||
|
||||
by_sequence: dict[str, ImapMailboxMessageSummary] = {}
|
||||
for sequence, fetched_uid, flags, size_bytes, headers in _parse_fetch_parts_with_sequence(data):
|
||||
if not sequence or not fetched_uid:
|
||||
continue
|
||||
by_sequence[sequence] = _message_summary_from_headers(uid=fetched_uid, folder=folder, headers=headers, flags=flags, size_bytes=size_bytes)
|
||||
|
||||
summaries: list[ImapMailboxMessageSummary] = []
|
||||
for sequence in sequences:
|
||||
summary = by_sequence.get(str(sequence))
|
||||
if summary is not None:
|
||||
summaries.append(summary)
|
||||
continue
|
||||
fetched_uid, flags, size_bytes, raw = _fetch_message_by_sequence(client, sequence)
|
||||
summaries.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
|
||||
return summaries
|
||||
|
||||
|
||||
def _mock_record_folder(record: dict[str, Any]) -> str:
|
||||
folder = str(record.get("folder") or "").strip()
|
||||
if folder:
|
||||
@@ -495,14 +612,16 @@ def _mock_raw_bytes(record: dict[str, Any]) -> bytes:
|
||||
return "\n".join(lines).encode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50) -> ImapMailboxMessageListResult:
|
||||
def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit: int = 50, offset: int = 0) -> ImapMailboxMessageListResult:
|
||||
"""List mailbox messages without mutating read/unread state."""
|
||||
|
||||
host, port = _require_imap_config(imap_config)
|
||||
folder = (folder or "INBOX").strip() or "INBOX"
|
||||
limit = max(1, min(limit, 100))
|
||||
offset = max(0, offset)
|
||||
if is_mock_imap_host(imap_config.host):
|
||||
records = [record for record in list_records(limit=500) if _mock_folder_matches(record, folder)]
|
||||
page_records = records[offset:offset + limit]
|
||||
messages = [
|
||||
_message_summary_from_raw(
|
||||
uid=str(record.get("id") or ""),
|
||||
@@ -511,23 +630,16 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
|
||||
flags=["\\Seen"] if record.get("kind") == "imap_append" else [],
|
||||
size_bytes=int(record.get("size_bytes") or 0) or None,
|
||||
)
|
||||
for record in records[:limit]
|
||||
for record in page_records
|
||||
]
|
||||
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records))
|
||||
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(records), offset=offset, limit=limit)
|
||||
|
||||
client = _open_imap(imap_config)
|
||||
try:
|
||||
_select_readonly(client, folder)
|
||||
typ, data = client.uid("search", None, "ALL")
|
||||
if typ != "OK":
|
||||
raise ImapAppendError(f"IMAP search failed for folder {folder!r}: {data!r}", temporary=True)
|
||||
uid_bytes = data[0] if data else b""
|
||||
uids = _decode_item(uid_bytes).split()
|
||||
messages: list[ImapMailboxMessageSummary] = []
|
||||
for uid in reversed(uids[-limit:]):
|
||||
fetched_uid, flags, size_bytes, raw = _fetch_message_by_uid(client, uid)
|
||||
messages.append(_message_summary_from_raw(uid=fetched_uid, folder=folder, raw=raw, flags=flags, size_bytes=size_bytes))
|
||||
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=len(uids))
|
||||
total_count = _select_readonly(client, folder)
|
||||
page_sequences = _paged_descending_sequences(total_count, offset=offset, limit=limit)
|
||||
messages = _fetch_message_summaries_by_sequence(client, page_sequences, folder)
|
||||
return ImapMailboxMessageListResult(host=host, port=port, security=imap_config.security.value, folder=folder, messages=messages, total_count=total_count, offset=offset, limit=limit)
|
||||
finally:
|
||||
try:
|
||||
client.logout()
|
||||
@@ -535,6 +647,14 @@ def list_imap_messages(*, imap_config: ImapConfig, folder: str = "INBOX", limit:
|
||||
pass
|
||||
|
||||
|
||||
def _paged_descending_sequences(total_count: int, *, offset: int, limit: int) -> list[str]:
|
||||
if total_count <= 0 or offset >= total_count:
|
||||
return []
|
||||
end = total_count - offset
|
||||
start = max(1, end - limit + 1)
|
||||
return [str(sequence) for sequence in range(end, start - 1, -1)]
|
||||
|
||||
|
||||
def get_imap_message(*, imap_config: ImapConfig, folder: str, uid: str) -> ImapMailboxMessageResult:
|
||||
"""Fetch one message body using BODY.PEEK in a read-only selected mailbox."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user