inital commit
This commit is contained in:
0
server/app/mailer/services/__init__.py
Normal file
0
server/app/mailer/services/__init__.py
Normal file
13
server/app/mailer/services/attachment_matching.py
Normal file
13
server/app/mailer/services/attachment_matching.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.mailer.domain.campaign import MailAttachmentConfig
|
||||
|
||||
|
||||
def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]:
|
||||
directory = base_path / config.base_dir
|
||||
if not directory.exists():
|
||||
return []
|
||||
iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter)
|
||||
return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0)
|
||||
135
server/app/mailer/services/campaign_executor.py
Normal file
135
server/app/mailer/services/campaign_executor.py
Normal 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
|
||||
25
server/app/mailer/services/zip_service.py
Normal file
25
server/app/mailer/services/zip_service.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
import pyzipper
|
||||
except ImportError: # pragma: no cover
|
||||
pyzipper = None
|
||||
|
||||
|
||||
def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if pyzipper is None:
|
||||
raise RuntimeError("pyzipper is required for writing encrypted ZIP files")
|
||||
with pyzipper.AESZipFile(
|
||||
output_path,
|
||||
"w",
|
||||
compression=pyzipper.ZIP_DEFLATED,
|
||||
encryption=pyzipper.WZ_AES,
|
||||
) as zip_file:
|
||||
if password:
|
||||
zip_file.setpassword(password.encode("utf-8"))
|
||||
for file_path in files:
|
||||
zip_file.write(file_path, arcname=file_path.name)
|
||||
return output_path
|
||||
Reference in New Issue
Block a user