Skip to content

Commit 993d9e1

Browse files
feat(email): implement an outbox for ticketing mails with multiple receivers (#1190)
* implement an outbox for ticketing mails with multiple receivers * improved on sourcery suggestion * improved on copilot suggestion * improve models fields * fix: template render issue * fix send_to field model field and datefield as suggested by copilot * use urlencode * make attachments field as non null * use query_replace tag for sorting links to avoid duplicate ordering params * copilot suggestion -2 --------- Co-authored-by: Mario Behling <[email protected]>
1 parent 2770941 commit 993d9e1

File tree

24 files changed

+2057
-400
lines changed

24 files changed

+2057
-400
lines changed

app/eventyay/base/email.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -307,15 +307,15 @@ def render(self, plain_body: str, plain_signature: str, subject: str, order, pos
307307
class ClassicMailRenderer(TemplateBasedMailRenderer):
308308
verbose_name = _('Default')
309309
identifier = 'classic'
310-
thumbnail_filename = 'eventyay/email/thumb.png'
311-
template_name = 'eventyay/email/plainwrapper.jinja'
310+
thumbnail_filename = 'pretixbase/email/thumb.png'
311+
template_name = 'pretixbase/email/plainwrapper.html'
312312

313313

314314
class UnembellishedMailRenderer(TemplateBasedMailRenderer):
315315
verbose_name = _('Simple with logo')
316316
identifier = 'simple_logo'
317-
thumbnail_filename = 'eventyay/email/thumb_simple_logo.png'
318-
template_name = 'eventyay/email/simple_logo.jinja'
317+
thumbnail_filename = 'pretixbase/email/thumb_simple_logo.png'
318+
template_name = 'pretixbase/email/simple_logo.html'
319319

320320

321321
@receiver(register_html_mail_renderers, dispatch_uid='eventyay_email_renderers')
@@ -418,8 +418,11 @@ def get_email_context(**kwargs):
418418
if not isinstance(val, (list, tuple)):
419419
val = [val]
420420
for v in val:
421-
if all(rp in kwargs for rp in v.required_context):
422-
ctx[v.identifier] = v.render(kwargs)
421+
try:
422+
if all(rp in kwargs for rp in v.required_context):
423+
ctx[v.identifier] = v.render(kwargs)
424+
except (KeyError, AttributeError, TypeError, ValueError) as e:
425+
logger.warning("Skipping placeholder %s due to error: %s", v.identifier, e)
423426
logger.info('Email context: %s', ctx)
424427
return ctx
425428

app/eventyay/base/services/mail.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ def mail(
7676
*,
7777
headers: dict = None,
7878
sender: str = None,
79+
event_bcc: str = None,
80+
event_reply_to: str = None,
7981
invoices: Sequence = None,
8082
attach_tickets=False,
8183
auto_email=True,
@@ -172,11 +174,21 @@ def mail(
172174
if event:
173175
timezone = event.timezone
174176
renderer = event.get_html_mail_renderer()
175-
if event.settings.mail_bcc:
177+
if not auto_email:
178+
if event_bcc: # Use custom BCC if specified
179+
for bcc_mail in event_bcc.split(','):
180+
bcc.append(bcc_mail.strip())
181+
elif event.settings.mail_bcc:
176182
for bcc_mail in event.settings.mail_bcc.split(','):
177183
bcc.append(bcc_mail.strip())
178184

179-
if (
185+
if not auto_email:
186+
if (
187+
event_reply_to
188+
and not headers.get('Reply-To')
189+
):
190+
headers['Reply-To'] = event_reply_to
191+
elif (
180192
event.settings.mail_from == settings.DEFAULT_FROM_EMAIL
181193
and event.settings.contact_mail
182194
and not headers.get('Reply-To')

app/eventyay/plugins/sendmail/forms.py

Lines changed: 195 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,16 @@
1010
from eventyay.base.email import get_available_placeholders
1111
from eventyay.base.forms import PlaceholderValidator, SettingsForm
1212
from eventyay.base.forms.widgets import SplitDateTimePickerWidget
13-
from eventyay.base.models import CheckinList, Product, Order, SubEvent
13+
from eventyay.base.models.base import CachedFile
14+
from eventyay.base.models.checkin import CheckinList
15+
from eventyay.base.models.event import SubEvent
16+
from eventyay.base.models.product import Product
17+
from eventyay.base.models.organizer import Team
18+
from eventyay.base.models.orders import Order
1419
from eventyay.control.forms import CachedFileField
1520
from eventyay.control.forms.widgets import Select2, Select2Multiple
21+
from eventyay.plugins.sendmail.models import ComposingFor, EmailQueue, EmailQueueToUser
22+
1623

1724
MAIL_SEND_ORDER_PLACED_ATTENDEE_HELP = _( 'If the order contains attendees with email addresses different from the person who orders the ' 'tickets, the following email will be sent out to the attendees.' )
1825

@@ -22,7 +29,7 @@ def contains_web_channel_validate(value):
2229

2330
class MailForm(forms.Form):
2431
recipients = forms.ChoiceField(label=_('Send email to'), widget=forms.RadioSelect, initial='orders', choices=[])
25-
sendto = forms.MultipleChoiceField() # overridden later
32+
order_status = forms.MultipleChoiceField() # overridden later
2633
subject = forms.CharField(label=_('Subject'))
2734
message = forms.CharField(label=_('Message'))
2835
attachment = CachedFileField(
@@ -63,7 +70,7 @@ class MailForm(forms.Form):
6370
required=True,
6471
queryset=Product.objects.none(),
6572
)
66-
filter_checkins = forms.BooleanField(label=_('Filter check-in status'), required=False)
73+
has_filter_checkins = forms.BooleanField(label=_('Filter check-in status'), required=False)
6774
checkin_lists = SafeModelMultipleChoiceField(
6875
queryset=CheckinList.objects.none(), required=False
6976
) # overridden later
@@ -84,12 +91,12 @@ class MailForm(forms.Form):
8491
label=pgettext_lazy('subevent', 'Only send to customers of dates starting before'),
8592
required=False,
8693
)
87-
created_from = forms.SplitDateTimeField(
94+
order_created_from = forms.SplitDateTimeField(
8895
widget=SplitDateTimePickerWidget(),
8996
label=pgettext_lazy('subevent', 'Only send to customers with orders created after'),
9097
required=False,
9198
)
92-
created_to = forms.SplitDateTimeField(
99+
order_created_to = forms.SplitDateTimeField(
93100
widget=SplitDateTimePickerWidget(),
94101
label=pgettext_lazy('subevent', 'Only send to customers with orders created before'),
95102
required=False,
@@ -159,16 +166,16 @@ def __init__(self, *args, **kwargs):
159166
choices.insert(0, ('pa', _('approval pending')))
160167
if not event.settings.get('payment_term_expire_automatically', as_type=bool):
161168
choices.append(('overdue', _('pending with payment overdue')))
162-
self.fields['sendto'] = forms.MultipleChoiceField(
169+
self.fields['order_status'] = forms.MultipleChoiceField(
163170
label=_('Send to customers with order status'),
164171
widget=forms.CheckboxSelectMultiple(attrs={'class': 'scrolling-multiple-choice'}),
165172
choices=choices,
166173
)
167-
if not self.initial.get('sendto'):
168-
self.initial['sendto'] = ['p', 'na']
169-
elif 'n' in self.initial['sendto']:
170-
self.initial['sendto'].append('pa')
171-
self.initial['sendto'].append('na')
174+
if not self.initial.get('order_status'):
175+
self.initial['order_status'] = ['p', 'na']
176+
elif 'n' in self.initial['order_status']:
177+
self.initial['order_status'].append('pa')
178+
self.initial['order_status'].append('na')
172179

173180
self.fields['products'].queryset = event.products.all()
174181
if not self.initial.get('products'):
@@ -212,6 +219,7 @@ def __init__(self, *args, **kwargs):
212219
del self.fields['subevents_from']
213220
del self.fields['subevents_to']
214221

222+
215223
class MailContentSettingsForm(SettingsForm):
216224
mail_text_order_placed = I18nFormField(
217225
label=_('Text sent to order contact address'),
@@ -411,3 +419,179 @@ def __init__(self, *args, **kwargs):
411419
for k, v in self.base_context.items():
412420
if k in self.fields:
413421
self._set_field_placeholders(k, v)
422+
423+
424+
class EmailQueueEditForm(forms.ModelForm):
425+
new_attachment = forms.FileField(
426+
required=False,
427+
label=_("New attachment"),
428+
help_text=_("Upload a new file to replace the existing one.")
429+
)
430+
431+
emails = forms.CharField(
432+
label=_("Recipients"),
433+
help_text=_("Edit the list of recipient email addresses separated by commas."),
434+
required=True,
435+
widget=forms.Textarea(attrs={'rows': 2, 'class': 'form-control'})
436+
)
437+
438+
class Meta:
439+
model = EmailQueue
440+
fields = [
441+
'reply_to',
442+
'bcc',
443+
]
444+
labels = {
445+
'reply_to': _('Reply-To'),
446+
'bcc': _('BCC'),
447+
}
448+
help_texts = {
449+
'reply_to': _("Any changes to the Reply-To field will apply only to this queued email."),
450+
'bcc': _("Any changes to the BCC field will apply only to this queued email."),
451+
}
452+
widgets = {
453+
'reply_to': forms.TextInput(attrs={'class': 'form-control'}),
454+
'bcc': forms.Textarea(attrs={'class': 'form-control', 'rows': 1}),
455+
}
456+
457+
def __init__(self, *args, **kwargs):
458+
self.event = kwargs.pop('event', None)
459+
self.read_only = kwargs.pop('read_only', False)
460+
super().__init__(*args, **kwargs)
461+
462+
if self.instance.composing_for == ComposingFor.TEAMS:
463+
base_placeholders = ['event', 'team']
464+
else:
465+
base_placeholders = ['event', 'order', 'position_or_address']
466+
467+
existing_recipients = EmailQueueToUser.objects.filter(mail=self.instance).order_by('id')
468+
self.recipient_objects = list(existing_recipients)
469+
self.fields['emails'].initial = ", ".join([u.email for u in self.recipient_objects])
470+
471+
saved_locales = set()
472+
if self.instance.subject and hasattr(self.instance.subject, '_data'):
473+
saved_locales |= set(self.instance.subject._data.keys())
474+
if self.instance.message and hasattr(self.instance.message, '_data'):
475+
saved_locales |= set(self.instance.message._data.keys())
476+
477+
configured_locales = set(self.event.settings.get('locales', [])) if self.event else set()
478+
allowed_locales = saved_locales | configured_locales
479+
480+
self.fields['subject'] = I18nFormField(
481+
label=_('Subject'),
482+
widget=I18nTextInput,
483+
required=False,
484+
locales=list(allowed_locales),
485+
initial=self.instance.subject
486+
)
487+
self.fields['message'] = I18nFormField(
488+
label=_('Message'),
489+
widget=I18nTextarea,
490+
required=False,
491+
locales=list(allowed_locales),
492+
initial=self.instance.message
493+
)
494+
495+
if not self.read_only:
496+
self._set_field_placeholders('subject', base_placeholders)
497+
self._set_field_placeholders('message', base_placeholders)
498+
499+
def _set_field_placeholders(self, fn, base_parameters):
500+
phs = ['{%s}' % p for p in sorted(get_available_placeholders(self.event, base_parameters).keys())]
501+
ht = _('Available placeholders: {list}').format(list=', '.join(phs))
502+
if self.fields[fn].help_text:
503+
self.fields[fn].help_text += ' ' + str(ht)
504+
else:
505+
self.fields[fn].help_text = ht
506+
self.fields[fn].validators.append(PlaceholderValidator(phs))
507+
508+
def clean_emails(self):
509+
updated_emails = [
510+
email.strip()
511+
for email in self.cleaned_data['emails'].split(',')
512+
if email.strip()
513+
]
514+
515+
if len(updated_emails) == 0:
516+
raise ValidationError(
517+
_("At least one recipient must remain. You cannot remove all recipients.")
518+
)
519+
520+
if len(updated_emails) != len(self.recipient_objects):
521+
raise ValidationError(
522+
_("You cannot add new recipients or remove recipients. Only editing existing email addresses is allowed.")
523+
)
524+
525+
return updated_emails
526+
527+
def save(self, commit=True):
528+
instance = super().save(commit=False)
529+
530+
updated_emails = self.cleaned_data['emails']
531+
532+
for i, email in enumerate(updated_emails):
533+
self.recipient_objects[i].email = email
534+
if commit:
535+
self.recipient_objects[i].save()
536+
537+
# Handle new attachment
538+
if self.cleaned_data.get('new_attachment'):
539+
uploaded_file = self.cleaned_data['new_attachment']
540+
cf = CachedFile.objects.create(file=uploaded_file, filename=uploaded_file.name)
541+
instance.attachments = [cf.id]
542+
543+
instance.subject = self.cleaned_data['subject']
544+
instance.message = self.cleaned_data['message']
545+
546+
if commit:
547+
instance.save()
548+
549+
return instance
550+
551+
552+
class TeamMailForm(forms.Form):
553+
attachment = CachedFileField(
554+
label=_('Attachment'),
555+
required=False,
556+
ext_whitelist=(
557+
'.png', '.jpg', '.gif', '.jpeg', '.pdf', '.txt', '.docx', '.svg', '.pptx',
558+
'.ppt', '.doc', '.xlsx', '.xls', '.jfif', '.heic', '.heif', '.pages', '.bmp',
559+
'.tif', '.tiff',
560+
),
561+
help_text=_(
562+
'Sending an attachment increases the chance of your email not arriving or being sorted into spam folders. '
563+
'We recommend only using PDFs of no more than 2 MB in size.'
564+
),
565+
max_size=10 * 1024 * 1024,
566+
)
567+
568+
def __init__(self, *args, **kwargs):
569+
self.event = kwargs.pop('event')
570+
super().__init__(*args, **kwargs)
571+
572+
locales = self.event.settings.get('locales') or [self.event.locale or 'en']
573+
if isinstance(locales, str):
574+
locales = [locales]
575+
576+
placeholder_keys = get_available_placeholders(self.event, ['event', 'team']).keys()
577+
placeholder_text = _("Available placeholders: ") + ', '.join(f"{{{key}}}" for key in sorted(placeholder_keys))
578+
579+
self.fields['subject'] = I18nFormField(
580+
label=_('Subject'),
581+
widget=I18nTextInput,
582+
required=True,
583+
locales=locales,
584+
help_text=placeholder_text
585+
)
586+
self.fields['message'] = I18nFormField(
587+
label=_('Message'),
588+
widget=I18nTextarea,
589+
required=True,
590+
locales=locales,
591+
help_text=placeholder_text
592+
)
593+
self.fields['teams'] = forms.ModelMultipleChoiceField(
594+
queryset=Team.objects.filter(organizer=self.event.organizer),
595+
widget=forms.CheckboxSelectMultiple(attrs={'class': 'scrolling-multiple-choice'}),
596+
label=_("Send to members of these teams")
597+
)

0 commit comments

Comments
 (0)