inital commit

This commit is contained in:
2026-06-08 15:57:11 +02:00
parent aaf8729663
commit d9ca48addc
114 changed files with 12172 additions and 1 deletions

View File

@@ -0,0 +1 @@
"""Sending helpers for MultiMailer."""

View File

@@ -0,0 +1,195 @@
from __future__ import annotations
import imaplib
import re
import socket
import ssl
import time
from dataclasses import dataclass
from app.mailer.campaign.models import ImapConfig, TransportSecurity
class ImapConfigurationError(ValueError):
"""Raised when IMAP settings are incomplete or inconsistent."""
class ImapAppendError(RuntimeError):
"""Raised when APPENDing to Sent fails.
temporary=True means retrying later may help. temporary=False means the
configuration or mailbox choice probably needs user/admin attention.
"""
def __init__(self, message: str, *, temporary: bool | None = None):
super().__init__(message)
self.temporary = temporary
@dataclass(frozen=True, slots=True)
class ImapAppendResult:
host: str
port: int
security: str
folder: str
bytes_appended: int
response: str | None = None
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:
raise ImapConfigurationError("IMAP port is required")
if bool(config.username) != bool(config.password):
raise ImapConfigurationError("IMAP username and password must be provided together, or both omitted")
return config.host, config.port
def _open_imap(config: ImapConfig) -> imaplib.IMAP4:
host, port = _require_imap_config(config)
context = ssl.create_default_context()
try:
if config.security == TransportSecurity.TLS:
client: imaplib.IMAP4 = imaplib.IMAP4_SSL(host=host, port=port, timeout=config.timeout_seconds, ssl_context=context)
else:
client = imaplib.IMAP4(host=host, port=port, timeout=config.timeout_seconds)
if config.security == TransportSecurity.STARTTLS:
typ, data = client.starttls(ssl_context=context)
if typ != "OK":
raise ImapAppendError(f"IMAP STARTTLS failed: {data!r}", temporary=True)
if config.username and config.password:
typ, data = client.login(config.username, config.password)
if typ != "OK":
raise ImapAppendError(f"IMAP login failed: {data!r}", temporary=False)
return client
except Exception:
try:
client.logout() # type: ignore[possibly-undefined]
except Exception:
pass
raise
def _decode_item(item: bytes | str | None) -> str:
if item is None:
return ""
if isinstance(item, bytes):
return item.decode("utf-8", errors="replace")
return item
def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None:
"""Best-effort parser for IMAP LIST response lines.
Example lines:
(\\HasNoChildren \\Sent) "/" "Sent"
(\\HasNoChildren) "/" "Sent Items"
"""
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()}
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
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)
for name, flags in parsed:
if "\\sent" in flags or "\\sentmail" in flags:
return name
common_names = [
"Sent",
"Sent Items",
"Sent Messages",
"Gesendet",
"Gesendete Elemente",
"INBOX.Sent",
"INBOX/Sent",
]
names = {name.lower(): name for name, _ in parsed}
for candidate in common_names:
if candidate.lower() in names:
return names[candidate.lower()]
return None
def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str:
if requested_folder and requested_folder != "auto":
return requested_folder
if config.sent_folder and config.sent_folder != "auto":
return config.sent_folder
discovered = discover_sent_folder(client)
if discovered:
return discovered
raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder")
def append_message_to_sent(
message_bytes: bytes,
*,
imap_config: ImapConfig,
folder: str | None = None,
) -> ImapAppendResult:
"""Append a sent MIME message to the configured IMAP Sent folder.
The SMTP send remains authoritative. APPEND is a separate best-effort step
and should not be used to decide whether an email was sent.
"""
host, port = _require_imap_config(imap_config)
client: imaplib.IMAP4 | None = None
try:
client = _open_imap(imap_config)
target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client)
internal_date = imaplib.Time2Internaldate(time.time())
typ, data = client.append(target_folder, "\\Seen", internal_date, message_bytes)
if typ != "OK":
raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False)
response = "; ".join(_decode_item(item) for item in (data or [])) or None
return ImapAppendResult(
host=host,
port=port,
security=imap_config.security.value,
folder=target_folder,
bytes_appended=len(message_bytes),
response=response,
)
except ImapAppendError:
raise
except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc:
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=True) from exc
except imaplib.IMAP4.error as exc:
raise ImapAppendError(f"IMAP append failed: {exc}", temporary=False) from exc
finally:
if client is not None:
try:
client.logout()
except Exception:
pass

View File

@@ -0,0 +1,606 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from datetime import datetime, timezone
from email import policy
from email.parser import BytesParser
from pathlib import Path
from typing import Any
from sqlalchemy.orm import Session
from app.db.models import (
Campaign,
CampaignJob,
CampaignStatus,
CampaignVersion,
JobBuildStatus,
JobImapStatus,
JobQueueStatus,
JobSendStatus,
JobValidationStatus,
ImapAppendAttempt,
SendAttempt,
)
from app.mailer.campaign.loader import load_campaign_config
from app.mailer.campaign.models import CampaignConfig
from app.mailer.persistence.campaigns import _write_campaign_snapshot
from app.mailer.sending.rate_limit import wait_for_rate_limit
from app.mailer.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_message
from app.mailer.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent
class QueueingError(RuntimeError):
pass
class SendJobError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class QueueCampaignResult:
campaign_id: str
version_id: str
queued_count: int
skipped_count: int
blocked_count: int
enqueued_count: int
dry_run: bool = False
def as_dict(self) -> dict[str, Any]:
return {
"campaign_id": self.campaign_id,
"version_id": self.version_id,
"queued_count": self.queued_count,
"skipped_count": self.skipped_count,
"blocked_count": self.blocked_count,
"enqueued_count": self.enqueued_count,
"dry_run": self.dry_run,
}
@dataclass(frozen=True, slots=True)
class SendJobResult:
job_id: str
status: str
attempt_number: int
dry_run: bool = False
message: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"job_id": self.job_id,
"status": self.status,
"attempt_number": self.attempt_number,
"dry_run": self.dry_run,
"message": self.message,
}
@dataclass(frozen=True, slots=True)
class AppendSentResult:
job_id: str
status: str
attempt_number: int
dry_run: bool = False
folder: str | None = None
message: str | None = None
def as_dict(self) -> dict[str, Any]:
return {
"job_id": self.job_id,
"status": self.status,
"attempt_number": self.attempt_number,
"dry_run": self.dry_run,
"folder": self.folder,
"message": self.message,
}
QUEUEABLE_VALIDATION_STATUSES = {
JobValidationStatus.READY.value,
JobValidationStatus.WARNING.value,
}
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def _get_campaign_for_tenant(session: Session, *, campaign_id: str, tenant_id: str) -> Campaign:
campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none()
if not campaign:
raise QueueingError(f"Campaign not found or not accessible: {campaign_id}")
return campaign
def _get_current_version(session: Session, campaign: Campaign, version_id: str | None = None) -> CampaignVersion:
wanted = version_id or campaign.current_version_id
if not wanted:
raise QueueingError("Campaign has no current version")
version = session.get(CampaignVersion, wanted)
if not version or version.campaign_id != campaign.id:
raise QueueingError(f"Campaign version not found or not part of campaign: {wanted}")
return version
def _load_version_campaign_config(version: CampaignVersion) -> tuple[Path, CampaignConfig]:
snapshot_path = _write_campaign_snapshot(version)
return snapshot_path, load_campaign_config(snapshot_path)
def _celery_enqueue_send_job(job_id: str) -> None:
from app.celery_app import celery
celery.send_task("multimailer.send_email", args=[job_id], queue="send_email")
def _celery_enqueue_append_sent_job(job_id: str) -> None:
from app.celery_app import celery
celery.send_task("multimailer.append_sent", args=[job_id], queue="append_sent")
def queue_campaign_jobs(
session: Session,
*,
tenant_id: str,
campaign_id: str,
version_id: str | None = None,
enqueue_celery: bool = True,
include_warnings: bool = True,
dry_run: bool = False,
) -> QueueCampaignResult:
"""Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks."""
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
version = _get_current_version(session, campaign, version_id=version_id)
allowed_validation = {JobValidationStatus.READY.value}
if include_warnings:
allowed_validation.add(JobValidationStatus.WARNING.value)
jobs = (
session.query(CampaignJob)
.filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_version_id == version.id)
.order_by(CampaignJob.entry_index.asc())
.all()
)
if not jobs:
raise QueueingError("Campaign version has no jobs. Build messages before queueing.")
queued: list[CampaignJob] = []
skipped_count = 0
blocked_count = 0
for job in jobs:
if job.queue_status in {JobQueueStatus.CANCELLED.value, JobQueueStatus.SENDING.value} or job.send_status == JobSendStatus.SENT.value:
skipped_count += 1
continue
if job.build_status != JobBuildStatus.BUILT.value or job.validation_status not in allowed_validation:
blocked_count += 1
continue
if not job.eml_local_path and not job.eml_storage_key:
job.last_error = "Job has no generated EML path/storage key. Rebuild with write_eml enabled before queueing."
blocked_count += 1
continue
queued.append(job)
if not dry_run:
job.queue_status = JobQueueStatus.QUEUED.value
job.send_status = JobSendStatus.QUEUED.value
job.queued_at = _utcnow()
job.last_error = None
session.add(job)
if not dry_run:
if queued:
campaign.status = CampaignStatus.QUEUED.value
session.add(campaign)
session.commit()
enqueued_count = 0
if enqueue_celery and not dry_run:
for job in queued:
_celery_enqueue_send_job(job.id)
enqueued_count += 1
return QueueCampaignResult(
campaign_id=campaign.id,
version_id=version.id,
queued_count=len(queued),
skipped_count=skipped_count,
blocked_count=blocked_count,
enqueued_count=enqueued_count,
dry_run=dry_run,
)
def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int:
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]),
)
.order_by(CampaignJob.entry_index.asc())
.all()
)
for job in jobs:
_celery_enqueue_send_job(job.id)
return len(jobs)
def pause_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]:
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
changed = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
CampaignJob.queue_status == JobQueueStatus.QUEUED.value,
)
.update({CampaignJob.queue_status: JobQueueStatus.PAUSED.value}, synchronize_session=False)
)
if changed:
campaign.status = CampaignStatus.READY_TO_QUEUE.value
session.add(campaign)
session.commit()
return {"campaign_id": campaign.id, "paused_count": int(changed)}
def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True) -> dict[str, Any]:
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
CampaignJob.queue_status == JobQueueStatus.PAUSED.value,
)
.order_by(CampaignJob.entry_index.asc())
.all()
)
for job in jobs:
job.queue_status = JobQueueStatus.QUEUED.value
job.send_status = JobSendStatus.QUEUED.value
session.add(job)
if jobs:
campaign.status = CampaignStatus.QUEUED.value
session.add(campaign)
session.commit()
enqueued_count = 0
if enqueue_celery:
for job in jobs:
_celery_enqueue_send_job(job.id)
enqueued_count += 1
return {"campaign_id": campaign.id, "resumed_count": len(jobs), "enqueued_count": enqueued_count}
def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]:
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
CampaignJob.send_status.notin_([JobSendStatus.SENT.value]),
)
.all()
)
for job in jobs:
if job.queue_status != JobQueueStatus.SENDING.value:
job.queue_status = JobQueueStatus.CANCELLED.value
job.send_status = JobSendStatus.CANCELLED.value
session.add(job)
campaign.status = CampaignStatus.CANCELLED.value
session.add(campaign)
session.commit()
return {"campaign_id": campaign.id, "cancelled_count": len(jobs)}
def _load_eml_bytes_for_job(job: CampaignJob) -> bytes:
if job.eml_local_path:
path = Path(job.eml_local_path)
if not path.exists():
raise SendJobError(f"Generated EML file does not exist: {path}")
return path.read_bytes()
raise SendJobError("Only local EML paths are supported for sending in this implementation step")
def _load_eml_for_job(job: CampaignJob):
return BytesParser(policy=policy.default).parsebytes(_load_eml_bytes_for_job(job))
def _addresses_from_job(job: CampaignJob, field: str) -> list[str]:
data = job.resolved_recipients or {}
values = data.get(field) or []
return [item.get("email") for item in values if isinstance(item, dict) and item.get("email")]
def _sender_from_job(job: CampaignJob, config: CampaignConfig) -> str:
data = job.resolved_recipients or {}
bounce_to = _addresses_from_job(job, "bounce_to")
if bounce_to:
return bounce_to[0]
from_data = data.get("from") if isinstance(data, dict) else None
if isinstance(from_data, dict) and from_data.get("email"):
return from_data["email"]
if config.server.smtp and config.server.smtp.username:
return config.server.smtp.username
raise SmtpConfigurationError("No envelope sender could be determined")
def _recipients_from_job(job: CampaignJob) -> list[str]:
recipients: list[str] = []
for field in ["to", "cc", "bcc"]:
recipients.extend(_addresses_from_job(job, field))
# Preserve order while de-duplicating.
return list(dict.fromkeys(recipients))
def _record_attempt_start(session: Session, job: CampaignJob) -> SendAttempt:
attempt = SendAttempt(
job_id=job.id,
attempt_number=job.attempt_count + 1,
started_at=_utcnow(),
)
job.attempt_count += 1
job.queue_status = JobQueueStatus.SENDING.value
job.send_status = JobSendStatus.SENDING.value
job.last_error = None
session.add(attempt)
session.add(job)
session.commit()
return attempt
def _update_campaign_after_job(session: Session, campaign_id: str) -> None:
session.flush()
campaign = session.get(Campaign, campaign_id)
if not campaign:
return
remaining = (
session.query(CampaignJob)
.filter(
CampaignJob.campaign_id == campaign_id,
CampaignJob.queue_status.in_([JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value]),
)
.count()
)
failed = (
session.query(CampaignJob)
.filter(
CampaignJob.campaign_id == campaign_id,
CampaignJob.send_status.in_([JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value]),
)
.count()
)
sent = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id, CampaignJob.send_status == JobSendStatus.SENT.value).count()
if remaining:
campaign.status = CampaignStatus.QUEUED.value
elif failed:
campaign.status = CampaignStatus.FAILED.value if not sent else CampaignStatus.NEEDS_REVIEW.value
elif sent:
campaign.status = CampaignStatus.SENT.value
session.add(campaign)
def send_campaign_job(session: Session, *, job_id: str, dry_run: bool = False, use_rate_limit: bool = True, enqueue_imap_task: bool = False) -> SendJobResult:
job = session.get(CampaignJob, job_id)
if not job:
raise SendJobError(f"Job not found: {job_id}")
if job.queue_status == JobQueueStatus.CANCELLED.value or job.send_status == JobSendStatus.CANCELLED.value:
return SendJobResult(job_id=job_id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run)
if job.queue_status == JobQueueStatus.PAUSED.value:
return SendJobResult(job_id=job_id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run)
if job.send_status == JobSendStatus.SENT.value:
return SendJobResult(job_id=job_id, status="already_sent", attempt_number=job.attempt_count, dry_run=dry_run)
if job.queue_status not in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value}:
raise SendJobError(f"Job is not queued: {job.queue_status}")
version = session.get(CampaignVersion, job.campaign_version_id)
if not version:
raise SendJobError("Campaign version not found")
_, config = _load_version_campaign_config(version)
if not config.server.smtp:
raise SmtpConfigurationError("Campaign has no SMTP configuration")
message = _load_eml_for_job(job)
envelope_from = _sender_from_job(job, config)
envelope_recipients = _recipients_from_job(job)
if not envelope_recipients:
raise SmtpConfigurationError("No envelope recipients could be determined")
if dry_run:
return SendJobResult(
job_id=job.id,
status="dry_run",
attempt_number=job.attempt_count,
dry_run=True,
message=f"Would send to {len(envelope_recipients)} recipient(s) from {envelope_from}",
)
attempt = _record_attempt_start(session, job)
try:
wait_for_rate_limit(
key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}",
messages_per_minute=config.delivery.rate_limit.messages_per_minute,
enabled=use_rate_limit,
)
result = send_email_message(
message,
smtp_config=config.server.smtp,
envelope_from=envelope_from,
envelope_recipients=envelope_recipients,
)
attempt.finished_at = _utcnow()
attempt.smtp_response = json.dumps(asdict(result), default=str)
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.SENT.value
job.sent_at = _utcnow()
if config.delivery.imap_append_sent.enabled:
job.imap_status = JobImapStatus.PENDING.value
else:
job.imap_status = JobImapStatus.NOT_REQUESTED.value
job.last_error = None
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id)
session.commit()
if enqueue_imap_task and job.imap_status == JobImapStatus.PENDING.value:
_celery_enqueue_append_sent_job(job.id)
return SendJobResult(job_id=job.id, status="sent", attempt_number=attempt.attempt_number)
except (SmtpConfigurationError, SmtpSendError, SendJobError, OSError) as exc:
attempt.finished_at = _utcnow()
attempt.error_type = exc.__class__.__name__
attempt.error_message = str(exc)
job.last_error = str(exc)
retry = getattr(exc, "temporary", None) is True
max_attempts = config.delivery.retry.max_attempts
if retry and job.attempt_count < max_attempts:
job.queue_status = JobQueueStatus.QUEUED.value
job.send_status = JobSendStatus.FAILED_TEMPORARY.value
else:
job.queue_status = JobQueueStatus.DRAFT.value
job.send_status = JobSendStatus.FAILED_PERMANENT.value
session.add(attempt)
session.add(job)
_update_campaign_after_job(session, job.campaign_id)
session.commit()
raise
def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt:
existing_count = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
attempt = ImapAppendAttempt(
job_id=job.id,
attempt_number=existing_count + 1,
status="running",
)
job.imap_status = JobImapStatus.PENDING.value
job.last_error = None
session.add(attempt)
session.add(job)
session.commit()
return attempt
def _effective_imap_folder(config: CampaignConfig) -> str:
delivery_folder = config.delivery.imap_append_sent.folder
if delivery_folder and delivery_folder != "auto":
return delivery_folder
if config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto":
return config.server.imap.sent_folder
return "auto"
def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult:
"""Append one successfully sent job's exact EML to the configured IMAP Sent folder."""
job = session.get(CampaignJob, job_id)
if not job:
raise SendJobError(f"Job not found: {job_id}")
if job.send_status != JobSendStatus.SENT.value:
return AppendSentResult(job_id=job_id, status="not_sent", attempt_number=0, dry_run=dry_run, message="Job has not been sent")
if job.imap_status == JobImapStatus.NOT_REQUESTED.value:
return AppendSentResult(job_id=job_id, status="not_requested", attempt_number=0, dry_run=dry_run)
if job.imap_status == JobImapStatus.APPENDED.value:
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
return AppendSentResult(job_id=job_id, status="already_appended", attempt_number=attempts, dry_run=dry_run)
if job.imap_status == JobImapStatus.SKIPPED.value:
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
return AppendSentResult(job_id=job_id, status="skipped", attempt_number=attempts, dry_run=dry_run)
version = session.get(CampaignVersion, job.campaign_version_id)
if not version:
raise SendJobError("Campaign version not found")
_, config = _load_version_campaign_config(version)
if not config.delivery.imap_append_sent.enabled:
job.imap_status = JobImapStatus.NOT_REQUESTED.value
session.add(job)
session.commit()
return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run)
if not config.server.imap or not config.server.imap.enabled:
job.imap_status = JobImapStatus.SKIPPED.value
job.last_error = "IMAP append requested, but server.imap is missing or disabled"
session.add(job)
session.commit()
return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error)
message_bytes = _load_eml_bytes_for_job(job)
folder = _effective_imap_folder(config)
if dry_run:
attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count()
return AppendSentResult(
job_id=job.id,
status="dry_run",
attempt_number=attempts,
dry_run=True,
folder=folder,
message=f"Would append {len(message_bytes)} bytes to IMAP folder {folder!r}",
)
attempt = _record_imap_attempt_start(session, job)
try:
result = append_message_to_sent(
message_bytes,
imap_config=config.server.imap,
folder=None if folder == "auto" else folder,
)
attempt.status = "appended"
attempt.folder = result.folder
job.imap_status = JobImapStatus.APPENDED.value
job.last_error = None
session.add(attempt)
session.add(job)
session.commit()
return AppendSentResult(job_id=job.id, status="appended", attempt_number=attempt.attempt_number, folder=result.folder)
except (ImapConfigurationError, ImapAppendError, SendJobError, OSError) as exc:
attempt.status = "failed"
attempt.folder = None if folder == "auto" else folder
attempt.error_message = str(exc)
job.imap_status = JobImapStatus.FAILED.value
job.last_error = str(exc)
session.add(attempt)
session.add(job)
session.commit()
raise
def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True, dry_run: bool = False) -> dict[str, Any]:
campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id)
jobs = (
session.query(CampaignJob)
.filter(
CampaignJob.tenant_id == tenant_id,
CampaignJob.campaign_id == campaign.id,
CampaignJob.send_status == JobSendStatus.SENT.value,
CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]),
)
.order_by(CampaignJob.entry_index.asc())
.all()
)
if not dry_run and enqueue_celery:
for job in jobs:
_celery_enqueue_append_sent_job(job.id)
return {
"campaign_id": campaign.id,
"pending_count": len(jobs),
"enqueued_count": 0 if dry_run or not enqueue_celery else len(jobs),
"dry_run": dry_run,
}
def next_retry_delay(config: CampaignConfig, attempt_count: int) -> int:
delays = config.delivery.retry.backoff_seconds or [60]
index = max(0, min(attempt_count - 1, len(delays) - 1))
return int(delays[index])

View File

@@ -0,0 +1,57 @@
from __future__ import annotations
import time
from dataclasses import dataclass
from redis import Redis
from redis.exceptions import RedisError
from app.settings import settings
@dataclass(frozen=True, slots=True)
class RateLimitDecision:
key: str
messages_per_minute: int
gap_seconds: float
waited_seconds: float
def _redis_client() -> Redis:
return Redis.from_url(settings.redis_url, decode_responses=True)
def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = True) -> RateLimitDecision:
"""Throttle sends across worker processes using Redis when available.
The implementation stores the next allowed send timestamp per key. A Redis
lock keeps multiple Celery processes from reading/updating the timestamp at
the same time. If Redis is unavailable, it falls back to no distributed wait;
the per-container Celery concurrency still protects local development.
"""
messages_per_minute = max(1, int(messages_per_minute or 1))
gap = 60.0 / messages_per_minute
if not enabled:
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0)
redis_key = f"multimailer:ratelimit:{key}:next_allowed"
lock_key = f"multimailer:ratelimit:{key}:lock"
waited = 0.0
try:
client = _redis_client()
with client.lock(lock_key, timeout=30, blocking_timeout=30):
now = time.time()
raw_next = client.get(redis_key)
next_allowed = float(raw_next) if raw_next else now
if next_allowed > now:
waited = next_allowed - now
time.sleep(waited)
now = time.time()
client.set(redis_key, now + gap, ex=max(60, int(gap * 10)))
except (RedisError, TimeoutError, ValueError):
# Development fallback: do not fail sending because Redis is absent.
waited = 0.0
return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited)

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
import copy
import smtplib
import ssl
from dataclasses import dataclass
from email.message import EmailMessage
from email.utils import formataddr
from app.mailer.campaign.models import SmtpConfig, TransportSecurity
class SmtpConfigurationError(ValueError):
"""Raised when SMTP settings are incomplete or inconsistent."""
class SmtpSendError(RuntimeError):
"""Raised when an SMTP send attempt fails."""
@dataclass(frozen=True, slots=True)
class SmtpSendResult:
host: str
port: int
security: str
envelope_from: str
envelope_recipients: list[str]
refused_recipients: dict[str, tuple[int, bytes | str]]
@property
def accepted_count(self) -> int:
return len(self.envelope_recipients) - len(self.refused_recipients)
def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]:
if not config.host:
raise SmtpConfigurationError("SMTP host is required")
if not config.port:
raise SmtpConfigurationError("SMTP port is required")
if bool(config.username) != bool(config.password):
raise SmtpConfigurationError("SMTP username and password must be provided together, or both omitted")
return config.host, config.port
def _open_smtp(config: SmtpConfig) -> smtplib.SMTP:
host, port = _require_smtp_config(config)
context = ssl.create_default_context()
try:
if config.security == TransportSecurity.TLS:
smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context)
smtp.ehlo()
else:
smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds)
smtp.ehlo()
if config.security == TransportSecurity.STARTTLS:
smtp.starttls(context=context)
smtp.ehlo()
if config.username and config.password:
smtp.login(config.username, config.password)
return smtp
except Exception:
# If construction/login fails after a socket was created, smtplib usually closes
# on GC, but explicit cleanup is safer when the variable exists.
try:
smtp.quit() # type: ignore[possibly-undefined]
except Exception:
pass
raise
def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[int, bytes | str]]:
normalized: dict[str, tuple[int, bytes | str]] = {}
for recipient, (code, response) in refused.items():
try:
normalized[recipient] = (code, response.decode("utf-8", errors="replace"))
except AttributeError:
normalized[recipient] = (code, response)
return normalized
def prepare_test_message(
message: EmailMessage,
*,
test_recipient: str,
test_recipient_name: str | None = None,
) -> EmailMessage:
"""Return a safe copy of a generated campaign message for test delivery.
The original recipient headers are removed so a test send cannot accidentally
leak the real To/Cc list or deliver to the real recipients. The envelope
recipient must also be supplied separately to send_email_message().
"""
test_message = copy.deepcopy(message)
for header in ["To", "Cc", "Bcc"]:
if header in test_message:
del test_message[header]
# Replace potential previous marker headers if the user test-sends an EML twice.
for header in ["X-MultiMailer-Test-Send"]:
if header in test_message:
del test_message[header]
test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient))
test_message["X-MultiMailer-Test-Send"] = "true"
return test_message
def send_email_message(
message: EmailMessage,
*,
smtp_config: SmtpConfig,
envelope_from: str,
envelope_recipients: list[str],
) -> SmtpSendResult:
"""Send an EmailMessage through SMTP.
This low-level function deliberately receives explicit envelope sender and
recipients. Headers and SMTP envelope are related but not identical; Bcc and
future bounce-address handling depend on keeping them separate.
"""
host, port = _require_smtp_config(smtp_config)
if not envelope_from:
raise SmtpConfigurationError("SMTP envelope sender is required")
if not envelope_recipients:
raise SmtpConfigurationError("at least one SMTP envelope recipient is required")
try:
with _open_smtp(smtp_config) as smtp:
refused = smtp.send_message(
message,
from_addr=envelope_from,
to_addrs=envelope_recipients,
)
except smtplib.SMTPAuthenticationError as exc:
raise SmtpSendError(f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}") from exc
except smtplib.SMTPRecipientsRefused as exc:
raise SmtpSendError(f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}") from exc
except smtplib.SMTPSenderRefused as exc:
raise SmtpSendError(f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}") from exc
except smtplib.SMTPResponseException as exc:
raise SmtpSendError(f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}") from exc
except (OSError, smtplib.SMTPException) as exc:
raise SmtpSendError(f"SMTP send failed: {exc}") from exc
return SmtpSendResult(
host=host,
port=port,
security=smtp_config.security.value,
envelope_from=envelope_from,
envelope_recipients=list(envelope_recipients),
refused_recipients=_decode_refused(refused),
)