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