Source code for pretalx.mail.domain.queue

# SPDX-FileCopyrightText: 2026-present Tobias Kunze
# SPDX-License-Identifier: AGPL-3.0-only WITH LicenseRef-Pretalx-AGPL-3.0-Terms

import datetime as dt
import logging
from copy import deepcopy

from django.db import transaction
from django.utils.timezone import now
from django_scopes import scopes_disabled

from pretalx.common.exceptions import SendMailException
from pretalx.mail.domain.recipient import Recipient
from pretalx.mail.domain.render import render_template_to_mail
from pretalx.mail.domain.send import send_draft
from pretalx.mail.enums import QueuedMailStates
from pretalx.mail.models import QueuedMail
from pretalx.person.models import SpeakerProfile
from pretalx.schedule.models import TalkSlot
from pretalx.submission.models import Submission

logger = logging.getLogger(__name__)


[docs] def save_draft(mail, *, to=None, to_speakers=None, submissions=None, attachments=None): """Persist a rendered QueuedMail as a DRAFT row in the outbox. Speakers without an effective email are excluded here. If the email is meant to be sent immediately, use pretalx.mail.domain.send.send_draft. """ if to_speakers is not None: to_speakers = list(to_speakers) for speaker in to_speakers: if not speaker.effective_email: speaker.log_action( "pretalx.mail.skipped", orga=True, data={"subject": str(mail.subject)}, ) logger.warning( "Dropping mail recipient %s: no effective email", speaker.code ) to_speakers = [s for s in to_speakers if s.effective_email] if to is not None: mail.to = to if not mail.to and not to_speakers: return None if attachments is not None: mail.attachments = attachments mail.save() if to_speakers: mail.to_speakers.set(to_speakers) if submissions: mail.submissions.set(submissions) return mail
def bulk_create_drafts(template, recipients, *, progress=None): """Bulk-render the template over recipient data, collapsing identical (speaker, subject, text) tuples and saving unique emails as draft. Returns (saved_mails, render_failures). """ event = template.event speaker_ids = {r["speaker_id"] for r in recipients if "speaker_id" in r} speakers_by_id = { s.pk: s for s in SpeakerProfile.objects.filter( pk__in=speaker_ids, event=event ).select_related("user", "event") } user_ids = {r["user_id"] for r in recipients if "speaker_id" not in r} speakers_by_user_id = { s.user_id: s for s in SpeakerProfile.objects.filter( user_id__in=user_ids, event=event ).select_related("user", "event") } sub_ids = {r["submission_id"] for r in recipients if "submission_id" in r} subs_by_id = { s.pk: s for s in Submission.objects.filter(pk__in=sub_ids, event=event).select_related( "track", "submission_type", "event" ) } slot_ids = {r["slot_id"] for r in recipients if "slot_id" in r} slots_by_id = { s.pk: s for s in TalkSlot.objects.filter(pk__in=slot_ids, schedule__event=event) } total = len(recipients) render_failures = 0 dedup_groups = {} for i, entry in enumerate(recipients): if progress: progress(i + 1, total) if speaker_id := entry.get("speaker_id"): speaker = speakers_by_id.get(speaker_id) else: speaker = speakers_by_user_id.get(entry["user_id"]) if speaker is None: continue context = {"user": Recipient(speaker)} if submission_id := entry.get("submission_id"): context["submission"] = subs_by_id.get(submission_id) if slot_id := entry.get("slot_id"): context["slot"] = slots_by_id.get(slot_id) locale = speaker.effective_locale if submission := context.get("submission"): locale = submission.get_email_locale(locale) try: mail = render_template_to_mail( template, locale=locale, context_kwargs=context ) except SendMailException: render_failures += 1 continue key = (speaker, mail.subject, mail.text) _, submissions = dedup_groups.setdefault(key, (mail, [])) if submission := context.get("submission"): submissions.append(submission) saved_mails = [] with transaction.atomic(): for (speaker, _, _), (mail, submissions) in dedup_groups.items(): if save_draft(mail, to_speakers=[speaker], submissions=submissions): saved_mails.append(mail) return saved_mails, render_failures
[docs] def copy_to_draft(mail): """Duplicate a sent (or failed) QueuedMail as a fresh DRAFT so an organiser can edit and resend it. Recipient M2Ms (to_speakers, submissions) are copied; state / sent / error_* fields are reset. """ new_mail = deepcopy(mail) new_mail.pk = None new_mail._state.adding = True # force INSERT after deepcopy new_mail.sent = None new_mail.state = QueuedMailStates.DRAFT new_mail.error_data = None new_mail.error_timestamp = None return save_draft( new_mail, to_speakers=mail.to_speakers.all(), submissions=list(mail.submissions.all()), )
def send_outbox_mails(*, event, mail_pks, requestor=None, progress=None): """Send each DRAFT mail in ``mail_pks``. ``progress`` is an optional ``(current, total)`` callback. """ mails = list( event.queued_mails.filter( pk__in=mail_pks, state=QueuedMailStates.DRAFT ).select_related("event") ) total = len(mails) for i, mail in enumerate(mails): try: send_draft(mail, requestor=requestor) except Exception: logger.exception("Failed to send mail %d", mail.pk) if progress: progress(i + 1, total) return {"count": total} def expire_stale_queued_mails(): """Reset mails stuck in SENDING state for over an hour back to DRAFT, annotated with a timeout error. Returns the number of mails reset. """ frozen_now = now() with scopes_disabled(): return QueuedMail.objects.filter( state=QueuedMailStates.SENDING, updated__lt=frozen_now - dt.timedelta(hours=1), ).update( state=QueuedMailStates.DRAFT, error_data={ "error": "Timed out waiting for delivery confirmation", "type": "TimeoutError", }, error_timestamp=frozen_now, updated=frozen_now, )