inital commit
This commit is contained in:
1
server/app/mailer/sending/__init__.py
Normal file
1
server/app/mailer/sending/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Sending helpers for MultiMailer."""
|
||||
195
server/app/mailer/sending/imap.py
Normal file
195
server/app/mailer/sending/imap.py
Normal 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
|
||||
606
server/app/mailer/sending/jobs.py
Normal file
606
server/app/mailer/sending/jobs.py
Normal 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])
|
||||
57
server/app/mailer/sending/rate_limit.py
Normal file
57
server/app/mailer/sending/rate_limit.py
Normal 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)
|
||||
157
server/app/mailer/sending/smtp.py
Normal file
157
server/app/mailer/sending/smtp.py
Normal 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),
|
||||
)
|
||||
Reference in New Issue
Block a user