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,135 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from pathlib import Path
from typing import Iterable
from app.mailer.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity
from app.mailer.domain.queue import MailQueue
from app.mailer.domain.recipients import Recipient, RecipientType
from app.mailer.services.attachment_matching import match_files
from app.mailer.services.zip_service import create_encrypted_zip
def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str:
return ", ".join(r.formatted() for r in recipients if r.type == recipient_type)
def _recipient_values(recipients: list[Recipient]) -> dict[str, str]:
def rows(recipient_type: RecipientType) -> list[Recipient]:
return [r for r in recipients if r.type == recipient_type]
def joined(recipient_type: RecipientType, mode: str) -> str:
selected = rows(recipient_type)
if mode == "address":
return ", ".join(r.address for r in selected)
if mode == "name":
return ", ".join(r.name or r.address for r in selected)
return ", ".join(r.formatted() for r in selected)
return {
"mm_recipients": joined(RecipientType.TO, "formatted"),
"mm_recipients_address": joined(RecipientType.TO, "address"),
"mm_recipients_name": joined(RecipientType.TO, "name"),
"mm_cc": joined(RecipientType.CC, "formatted"),
"mm_cc_address": joined(RecipientType.CC, "address"),
"mm_cc_name": joined(RecipientType.CC, "name"),
"mm_bcc": joined(RecipientType.BCC, "formatted"),
"mm_bcc_address": joined(RecipientType.BCC, "address"),
"mm_bcc_name": joined(RecipientType.BCC, "name"),
}
def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]:
paths: list[Path] = []
if entry.combine_attachments:
for config in campaign.global_attachment_configs:
paths.extend(match_files(campaign.base_attachment_path, config))
if campaign.individual_attachments:
for config in entry.attachment_configs:
paths.extend(match_files(campaign.base_attachment_path, config))
return paths
def get_not_sent_files(campaign: MailCampaign) -> set[Path]:
"""Return files matching campaign attachment configs that are not referenced by any built entry."""
all_files: set[Path] = set()
used_files: set[Path] = set()
for config in campaign.global_attachment_configs:
all_files.update(match_files(campaign.base_attachment_path, config))
for entry in campaign.mail_entries:
files = set(_message_attachment_paths(campaign, entry))
used_files.update(files)
all_files.update(files)
return all_files - used_files
def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None:
recipients = campaign.all_recipients_for(entry)
attachments = _message_attachment_paths(campaign, entry)
if not attachments and not campaign.send_without_attachments:
return None
values = {}
values.update(_recipient_values(recipients))
values.update(campaign.field_contents.as_value_map("global"))
values.update(entry.field_contents.as_value_map("local"))
message = EmailMessage()
sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from
if sender:
message["From"] = sender.formatted()
to_header = _recipient_header(recipients, RecipientType.TO)
cc_header = _recipient_header(recipients, RecipientType.CC)
if to_header:
message["To"] = to_header
if cc_header:
message["Cc"] = cc_header
message["Subject"] = campaign.subject_template.apply_values(values)
message.set_content(campaign.mail_template.apply_values(values))
attachment_paths = attachments
if attachments and zip_attachments:
number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments"
password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else ""
zip_path = campaign.base_attachment_path / f"{number}.zip"
attachment_paths = [create_encrypted_zip(zip_path, attachments, password)]
for attachment_path in attachment_paths:
data = attachment_path.read_bytes()
message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name)
return message
def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue:
queue = MailQueue()
for entry in campaign.mail_entries:
if not entry.is_active:
continue
message = build_message(campaign, entry, zip_attachments=zip_attachments)
if message is not None:
queue.add_mail(message)
return queue
def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue:
retry_queue = MailQueue()
if settings.transport_security == TransportSecurity.TLS:
smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60)
else:
smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60)
try:
if settings.transport_security == TransportSecurity.STARTTLS:
smtp.starttls()
if settings.username:
smtp.login(settings.username, settings.password)
for message in queue:
try:
smtp.send_message(message)
except Exception:
retry_queue.add_mail(message)
finally:
smtp.quit()
return retry_queue