-
Notifications
You must be signed in to change notification settings - Fork 134
feat(email): implement an outbox for ticketing mails with multiple receivers #1190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 18 commits
e242a97
b2d6595
bb1b2dd
909d1ea
afd9cca
9554e30
b489951
0e29ce1
5429b62
6d507f7
c2a4bd7
ef82bd8
b58c2f7
7e3e164
d159caa
14b8e44
6bee8a9
9b94147
e8d74a2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -307,15 +307,15 @@ def render(self, plain_body: str, plain_signature: str, subject: str, order, pos | |
| class ClassicMailRenderer(TemplateBasedMailRenderer): | ||
| verbose_name = _('Default') | ||
| identifier = 'classic' | ||
| thumbnail_filename = 'eventyay/email/thumb.png' | ||
| template_name = 'eventyay/email/plainwrapper.jinja' | ||
| thumbnail_filename = 'pretixbase/email/thumb.png' | ||
| template_name = 'pretixbase/email/plainwrapper.html' | ||
|
|
||
|
|
||
| class UnembellishedMailRenderer(TemplateBasedMailRenderer): | ||
| verbose_name = _('Simple with logo') | ||
| identifier = 'simple_logo' | ||
| thumbnail_filename = 'eventyay/email/thumb_simple_logo.png' | ||
| template_name = 'eventyay/email/simple_logo.jinja' | ||
| thumbnail_filename = 'pretixbase/email/thumb_simple_logo.png' | ||
| template_name = 'pretixbase/email/simple_logo.html' | ||
|
Comment on lines
+310
to
+318
|
||
|
|
||
|
|
||
| @receiver(register_html_mail_renderers, dispatch_uid='eventyay_email_renderers') | ||
|
|
@@ -418,8 +418,11 @@ def get_email_context(**kwargs): | |
| if not isinstance(val, (list, tuple)): | ||
| val = [val] | ||
| for v in val: | ||
| if all(rp in kwargs for rp in v.required_context): | ||
| ctx[v.identifier] = v.render(kwargs) | ||
| try: | ||
| if all(rp in kwargs for rp in v.required_context): | ||
| ctx[v.identifier] = v.render(kwargs) | ||
| except (KeyError, AttributeError, TypeError, ValueError) as e: | ||
| logger.warning("Skipping placeholder %s due to error: %s", v.identifier, e) | ||
| logger.info('Email context: %s', ctx) | ||
| return ctx | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -76,6 +76,8 @@ def mail( | |||||
| *, | ||||||
| headers: dict = None, | ||||||
| sender: str = None, | ||||||
| event_bcc: str = None, | ||||||
| event_reply_to: str = None, | ||||||
| invoices: Sequence = None, | ||||||
| attach_tickets=False, | ||||||
| auto_email=True, | ||||||
|
|
@@ -172,11 +174,21 @@ def mail( | |||||
| if event: | ||||||
| timezone = event.timezone | ||||||
| renderer = event.get_html_mail_renderer() | ||||||
| if event.settings.mail_bcc: | ||||||
| if not auto_email: | ||||||
Gagan-Ram marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
| if event_bcc: # Use custom BCC if specified | ||||||
| for bcc_mail in event_bcc.split(','): | ||||||
| bcc.append(bcc_mail.strip()) | ||||||
| elif event.settings.mail_bcc: | ||||||
| for bcc_mail in event.settings.mail_bcc.split(','): | ||||||
| bcc.append(bcc_mail.strip()) | ||||||
|
|
||||||
| if ( | ||||||
| if not auto_email: | ||||||
| if ( | ||||||
| event_reply_to | ||||||
| and not headers.get('Reply-To') | ||||||
| ): | ||||||
| headers['Reply-To'] = event_reply_to | ||||||
|
||||||
| headers['Reply-To'] = event_reply_to | |
| headers['Reply-To'] = event_reply_to |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -10,9 +10,16 @@ | |||||||||||||||||||||||||
| from eventyay.base.email import get_available_placeholders | ||||||||||||||||||||||||||
| from eventyay.base.forms import PlaceholderValidator, SettingsForm | ||||||||||||||||||||||||||
| from eventyay.base.forms.widgets import SplitDateTimePickerWidget | ||||||||||||||||||||||||||
| from eventyay.base.models import CheckinList, Product, Order, SubEvent | ||||||||||||||||||||||||||
| from eventyay.base.models.base import CachedFile | ||||||||||||||||||||||||||
| from eventyay.base.models.checkin import CheckinList | ||||||||||||||||||||||||||
| from eventyay.base.models.event import SubEvent | ||||||||||||||||||||||||||
| from eventyay.base.models.product import Product | ||||||||||||||||||||||||||
| from eventyay.base.models.organizer import Team | ||||||||||||||||||||||||||
| from eventyay.base.models.orders import Order | ||||||||||||||||||||||||||
| from eventyay.control.forms import CachedFileField | ||||||||||||||||||||||||||
| from eventyay.control.forms.widgets import Select2, Select2Multiple | ||||||||||||||||||||||||||
| from eventyay.plugins.sendmail.models import ComposingFor, EmailQueue, EmailQueueToUser | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| 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.' ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
@@ -22,7 +29,7 @@ def contains_web_channel_validate(value): | |||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class MailForm(forms.Form): | ||||||||||||||||||||||||||
| recipients = forms.ChoiceField(label=_('Send email to'), widget=forms.RadioSelect, initial='orders', choices=[]) | ||||||||||||||||||||||||||
| sendto = forms.MultipleChoiceField() # overridden later | ||||||||||||||||||||||||||
| order_status = forms.MultipleChoiceField() # overridden later | ||||||||||||||||||||||||||
| subject = forms.CharField(label=_('Subject')) | ||||||||||||||||||||||||||
| message = forms.CharField(label=_('Message')) | ||||||||||||||||||||||||||
| attachment = CachedFileField( | ||||||||||||||||||||||||||
|
|
@@ -63,7 +70,7 @@ class MailForm(forms.Form): | |||||||||||||||||||||||||
| required=True, | ||||||||||||||||||||||||||
| queryset=Product.objects.none(), | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| filter_checkins = forms.BooleanField(label=_('Filter check-in status'), required=False) | ||||||||||||||||||||||||||
| has_filter_checkins = forms.BooleanField(label=_('Filter check-in status'), required=False) | ||||||||||||||||||||||||||
| checkin_lists = SafeModelMultipleChoiceField( | ||||||||||||||||||||||||||
| queryset=CheckinList.objects.none(), required=False | ||||||||||||||||||||||||||
| ) # overridden later | ||||||||||||||||||||||||||
|
|
@@ -84,12 +91,12 @@ class MailForm(forms.Form): | |||||||||||||||||||||||||
| label=pgettext_lazy('subevent', 'Only send to customers of dates starting before'), | ||||||||||||||||||||||||||
| required=False, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| created_from = forms.SplitDateTimeField( | ||||||||||||||||||||||||||
| order_created_from = forms.SplitDateTimeField( | ||||||||||||||||||||||||||
| widget=SplitDateTimePickerWidget(), | ||||||||||||||||||||||||||
| label=pgettext_lazy('subevent', 'Only send to customers with orders created after'), | ||||||||||||||||||||||||||
| required=False, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| created_to = forms.SplitDateTimeField( | ||||||||||||||||||||||||||
| order_created_to = forms.SplitDateTimeField( | ||||||||||||||||||||||||||
| widget=SplitDateTimePickerWidget(), | ||||||||||||||||||||||||||
| label=pgettext_lazy('subevent', 'Only send to customers with orders created before'), | ||||||||||||||||||||||||||
| required=False, | ||||||||||||||||||||||||||
|
|
@@ -159,16 +166,16 @@ def __init__(self, *args, **kwargs): | |||||||||||||||||||||||||
| choices.insert(0, ('pa', _('approval pending'))) | ||||||||||||||||||||||||||
| if not event.settings.get('payment_term_expire_automatically', as_type=bool): | ||||||||||||||||||||||||||
| choices.append(('overdue', _('pending with payment overdue'))) | ||||||||||||||||||||||||||
| self.fields['sendto'] = forms.MultipleChoiceField( | ||||||||||||||||||||||||||
| self.fields['order_status'] = forms.MultipleChoiceField( | ||||||||||||||||||||||||||
| label=_('Send to customers with order status'), | ||||||||||||||||||||||||||
| widget=forms.CheckboxSelectMultiple(attrs={'class': 'scrolling-multiple-choice'}), | ||||||||||||||||||||||||||
| choices=choices, | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| if not self.initial.get('sendto'): | ||||||||||||||||||||||||||
| self.initial['sendto'] = ['p', 'na'] | ||||||||||||||||||||||||||
| elif 'n' in self.initial['sendto']: | ||||||||||||||||||||||||||
| self.initial['sendto'].append('pa') | ||||||||||||||||||||||||||
| self.initial['sendto'].append('na') | ||||||||||||||||||||||||||
| if not self.initial.get('order_status'): | ||||||||||||||||||||||||||
| self.initial['order_status'] = ['p', 'na'] | ||||||||||||||||||||||||||
| elif 'n' in self.initial['order_status']: | ||||||||||||||||||||||||||
| self.initial['order_status'].append('pa') | ||||||||||||||||||||||||||
| self.initial['order_status'].append('na') | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| self.fields['products'].queryset = event.products.all() | ||||||||||||||||||||||||||
| if not self.initial.get('products'): | ||||||||||||||||||||||||||
|
|
@@ -212,6 +219,7 @@ def __init__(self, *args, **kwargs): | |||||||||||||||||||||||||
| del self.fields['subevents_from'] | ||||||||||||||||||||||||||
| del self.fields['subevents_to'] | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class MailContentSettingsForm(SettingsForm): | ||||||||||||||||||||||||||
| mail_text_order_placed = I18nFormField( | ||||||||||||||||||||||||||
| label=_('Text sent to order contact address'), | ||||||||||||||||||||||||||
|
|
@@ -411,3 +419,179 @@ def __init__(self, *args, **kwargs): | |||||||||||||||||||||||||
| for k, v in self.base_context.items(): | ||||||||||||||||||||||||||
| if k in self.fields: | ||||||||||||||||||||||||||
| self._set_field_placeholders(k, v) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class EmailQueueEditForm(forms.ModelForm): | ||||||||||||||||||||||||||
| new_attachment = forms.FileField( | ||||||||||||||||||||||||||
| required=False, | ||||||||||||||||||||||||||
| label=_("New attachment"), | ||||||||||||||||||||||||||
| help_text=_("Upload a new file to replace the existing one.") | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| emails = forms.CharField( | ||||||||||||||||||||||||||
| label=_("Recipients"), | ||||||||||||||||||||||||||
| help_text=_("Edit the list of recipient email addresses separated by commas."), | ||||||||||||||||||||||||||
| required=True, | ||||||||||||||||||||||||||
| widget=forms.Textarea(attrs={'rows': 2, 'class': 'form-control'}) | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| class Meta: | ||||||||||||||||||||||||||
| model = EmailQueue | ||||||||||||||||||||||||||
| fields = [ | ||||||||||||||||||||||||||
| 'reply_to', | ||||||||||||||||||||||||||
| 'bcc', | ||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||
| labels = { | ||||||||||||||||||||||||||
| 'reply_to': _('Reply-To'), | ||||||||||||||||||||||||||
| 'bcc': _('BCC'), | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| help_texts = { | ||||||||||||||||||||||||||
| 'reply_to': _("Any changes to the Reply-To field will apply only to this queued email."), | ||||||||||||||||||||||||||
| 'bcc': _("Any changes to the BCC field will apply only to this queued email."), | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| widgets = { | ||||||||||||||||||||||||||
| 'reply_to': forms.TextInput(attrs={'class': 'form-control'}), | ||||||||||||||||||||||||||
| 'bcc': forms.Textarea(attrs={'class': 'form-control', 'rows': 1}), | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def __init__(self, *args, **kwargs): | ||||||||||||||||||||||||||
| self.event = kwargs.pop('event', None) | ||||||||||||||||||||||||||
| self.read_only = kwargs.pop('read_only', False) | ||||||||||||||||||||||||||
| super().__init__(*args, **kwargs) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if self.instance.composing_for == ComposingFor.TEAMS: | ||||||||||||||||||||||||||
| base_placeholders = ['event', 'team'] | ||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||
| base_placeholders = ['event', 'order', 'position_or_address'] | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| existing_recipients = EmailQueueToUser.objects.filter(mail=self.instance).order_by('id') | ||||||||||||||||||||||||||
| self.recipient_objects = list(existing_recipients) | ||||||||||||||||||||||||||
| self.fields['emails'].initial = ", ".join([u.email for u in self.recipient_objects]) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| saved_locales = set() | ||||||||||||||||||||||||||
| if self.instance.subject and hasattr(self.instance.subject, '_data'): | ||||||||||||||||||||||||||
| saved_locales |= set(self.instance.subject._data.keys()) | ||||||||||||||||||||||||||
| if self.instance.message and hasattr(self.instance.message, '_data'): | ||||||||||||||||||||||||||
| saved_locales |= set(self.instance.message._data.keys()) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| configured_locales = set(self.event.settings.get('locales', [])) if self.event else set() | ||||||||||||||||||||||||||
| allowed_locales = saved_locales | configured_locales | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| self.fields['subject'] = I18nFormField( | ||||||||||||||||||||||||||
| label=_('Subject'), | ||||||||||||||||||||||||||
| widget=I18nTextInput, | ||||||||||||||||||||||||||
| required=False, | ||||||||||||||||||||||||||
| locales=list(allowed_locales), | ||||||||||||||||||||||||||
| initial=self.instance.subject | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
| self.fields['message'] = I18nFormField( | ||||||||||||||||||||||||||
| label=_('Message'), | ||||||||||||||||||||||||||
| widget=I18nTextarea, | ||||||||||||||||||||||||||
| required=False, | ||||||||||||||||||||||||||
| locales=list(allowed_locales), | ||||||||||||||||||||||||||
| initial=self.instance.message | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if not self.read_only: | ||||||||||||||||||||||||||
| self._set_field_placeholders('subject', base_placeholders) | ||||||||||||||||||||||||||
| self._set_field_placeholders('message', base_placeholders) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def _set_field_placeholders(self, fn, base_parameters): | ||||||||||||||||||||||||||
| phs = ['{%s}' % p for p in sorted(get_available_placeholders(self.event, base_parameters).keys())] | ||||||||||||||||||||||||||
| ht = _('Available placeholders: {list}').format(list=', '.join(phs)) | ||||||||||||||||||||||||||
| if self.fields[fn].help_text: | ||||||||||||||||||||||||||
| self.fields[fn].help_text += ' ' + str(ht) | ||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||
| self.fields[fn].help_text = ht | ||||||||||||||||||||||||||
| self.fields[fn].validators.append(PlaceholderValidator(phs)) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| def clean_emails(self): | ||||||||||||||||||||||||||
| updated_emails = [ | ||||||||||||||||||||||||||
| email.strip() | ||||||||||||||||||||||||||
| for email in self.cleaned_data['emails'].split(',') | ||||||||||||||||||||||||||
| if email.strip() | ||||||||||||||||||||||||||
| ] | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if len(updated_emails) == 0: | ||||||||||||||||||||||||||
| raise ValidationError( | ||||||||||||||||||||||||||
| _("At least one recipient must remain. You cannot remove all recipients.") | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if len(updated_emails) != len(self.recipient_objects): | ||||||||||||||||||||||||||
| raise ValidationError( | ||||||||||||||||||||||||||
| _("You cannot add new recipients or remove recipients. Only editing existing email addresses is allowed.") | ||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||
|
Comment on lines
+520
to
+523
|
||||||||||||||||||||||||||
| if len(updated_emails) != len(self.recipient_objects): | |
| raise ValidationError( | |
| _("You cannot add new recipients or remove recipients. Only editing existing email addresses is allowed.") | |
| ) | |
| if len(updated_emails) > len(self.recipient_objects): | |
| raise ValidationError( | |
| _("You cannot add new recipients. Only editing existing email addresses is allowed.") | |
| ) | |
| if len(updated_emails) < len(self.recipient_objects): | |
| raise ValidationError( | |
| _("You cannot remove recipients. Only editing existing email addresses is allowed.") | |
| ) |
sourcery-ai[bot] marked this conversation as resolved.
Show resolved
Hide resolved
Copilot
AI
Nov 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing validation. The method doesn't validate that the attachment IDs in the form's cleaned_data actually exist as CachedFile objects before storing them. This could lead to broken attachment references.
| cf = CachedFile.objects.create(file=uploaded_file, filename=uploaded_file.name) | |
| cf = CachedFile.objects.create(file=uploaded_file, filename=uploaded_file.name) | |
| # Validate that the CachedFile object exists before assigning | |
| if not CachedFile.objects.filter(id=cf.id).exists(): | |
| raise ValidationError(_("Attachment could not be saved. Please try again.")) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The thumbnail filenames are also changed to "pretixbase" paths, which is inconsistent with the rest of the codebase that uses "eventyay" paths. These should be "eventyay/email/thumb.png" and "eventyay/email/thumb_simple_logo.png" to maintain consistency.