Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 9 additions & 6 deletions app/eventyay/base/email.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment on lines +310 to +317
Copy link

Copilot AI Nov 11, 2025

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.

Copilot uses AI. Check for mistakes.
template_name = 'pretixbase/email/simple_logo.html'
Comment on lines +310 to +318
Copy link

Copilot AI Nov 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The template path references are inconsistent with the codebase. This file uses "eventyay" paths throughout, but these template paths are changed to "pretixbase". All other template paths in this PR use "pretixplugins" or "pretixcontrol". These should be changed to "eventyay/email/plainwrapper.html" and "eventyay/email/simple_logo.html" to maintain consistency.

Copilot uses AI. Check for mistakes.


@receiver(register_html_mail_renderers, dispatch_uid='eventyay_email_renderers')
Expand Down Expand Up @@ -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

Expand Down
16 changes: 14 additions & 2 deletions app/eventyay/base/services/mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
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
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trailing whitespace on line 190. Remove the trailing spaces after event_reply_to.

Suggested change
headers['Reply-To'] = event_reply_to
headers['Reply-To'] = event_reply_to

Copilot uses AI. Check for mistakes.
elif (
event.settings.mail_from == settings.DEFAULT_FROM_EMAIL
and event.settings.contact_mail
and not headers.get('Reply-To')
Expand Down
206 changes: 195 additions & 11 deletions app/eventyay/plugins/sendmail/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.' )

Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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'):
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Misleading error message. The error states "You cannot add new recipients or remove recipients", but checking len(updated_emails) != len(self.recipient_objects) would also trigger if someone tries to modify the count. The message should clarify that changing the number of recipients is not allowed, or split into two separate checks for adding vs removing.

Suggested change
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.")
)

Copilot uses AI. Check for mistakes.

return updated_emails

def save(self, commit=True):
instance = super().save(commit=False)

updated_emails = self.cleaned_data['emails']

for i, email in enumerate(updated_emails):
self.recipient_objects[i].email = email
if commit:
self.recipient_objects[i].save()

# Handle new attachment
if self.cleaned_data.get('new_attachment'):
uploaded_file = self.cleaned_data['new_attachment']
cf = CachedFile.objects.create(file=uploaded_file, filename=uploaded_file.name)
Copy link

Copilot AI Nov 13, 2025

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.

Suggested change
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."))

Copilot uses AI. Check for mistakes.
instance.attachments = [cf.id]

instance.subject = self.cleaned_data['subject']
instance.message = self.cleaned_data['message']

if commit:
instance.save()

return instance


class TeamMailForm(forms.Form):
attachment = CachedFileField(
label=_('Attachment'),
required=False,
ext_whitelist=(
'.png', '.jpg', '.gif', '.jpeg', '.pdf', '.txt', '.docx', '.svg', '.pptx',
'.ppt', '.doc', '.xlsx', '.xls', '.jfif', '.heic', '.heif', '.pages', '.bmp',
'.tif', '.tiff',
),
help_text=_(
'Sending an attachment increases the chance of your email not arriving or being sorted into spam folders. '
'We recommend only using PDFs of no more than 2 MB in size.'
),
max_size=10 * 1024 * 1024,
)

def __init__(self, *args, **kwargs):
self.event = kwargs.pop('event')
super().__init__(*args, **kwargs)

locales = self.event.settings.get('locales') or [self.event.locale or 'en']
if isinstance(locales, str):
locales = [locales]

placeholder_keys = get_available_placeholders(self.event, ['event', 'team']).keys()
placeholder_text = _("Available placeholders: ") + ', '.join(f"{{{key}}}" for key in sorted(placeholder_keys))

self.fields['subject'] = I18nFormField(
label=_('Subject'),
widget=I18nTextInput,
required=True,
locales=locales,
help_text=placeholder_text
)
self.fields['message'] = I18nFormField(
label=_('Message'),
widget=I18nTextarea,
required=True,
locales=locales,
help_text=placeholder_text
)
self.fields['teams'] = forms.ModelMultipleChoiceField(
queryset=Team.objects.filter(organizer=self.event.organizer),
widget=forms.CheckboxSelectMultiple(attrs={'class': 'scrolling-multiple-choice'}),
label=_("Send to members of these teams")
)
Loading