Skip to content

Commit 1899fd8

Browse files
committed
✨(signature) allow to create mailbox signature
1 parent a0fed34 commit 1899fd8

34 files changed

Lines changed: 1448 additions & 75 deletions

File tree

src/backend/core/admin.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,7 @@ class MessageTemplateAdmin(admin.ModelAdmin):
656656
"name",
657657
"type",
658658
"is_forced",
659+
"is_default",
659660
"is_active",
660661
"mailbox",
661662
"maildomain",
@@ -664,6 +665,7 @@ class MessageTemplateAdmin(admin.ModelAdmin):
664665
list_filter = (
665666
"type",
666667
"is_forced",
668+
"is_default",
667669
"created_at",
668670
)
669671
autocomplete_fields = ("mailbox", "maildomain")

src/backend/core/api/openapi.json

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6632,6 +6632,11 @@
66326632
"default": false,
66336633
"description": "Set as forced template"
66346634
},
6635+
"is_default": {
6636+
"type": "boolean",
6637+
"default": false,
6638+
"description": "Set as default template (auto-loaded when composing a new message)"
6639+
},
66356640
"created_at": {
66366641
"type": "string",
66376642
"format": "date-time",
@@ -6688,6 +6693,11 @@
66886693
"type": "boolean",
66896694
"default": false,
66906695
"description": "Set as forced template"
6696+
},
6697+
"is_default": {
6698+
"type": "boolean",
6699+
"default": false,
6700+
"description": "Set as default template (auto-loaded when composing a new message)"
66916701
}
66926702
},
66936703
"required": [
@@ -7006,6 +7016,11 @@
70067016
"type": "boolean",
70077017
"default": false,
70087018
"description": "Set as forced template"
7019+
},
7020+
"is_default": {
7021+
"type": "boolean",
7022+
"default": false,
7023+
"description": "Set as default template (auto-loaded when composing a new message)"
70097024
}
70107025
}
70117026
},
@@ -7072,6 +7087,11 @@
70727087
"readOnly": true,
70737088
"description": "Whether this template is forced; no other template of the same type can be used in the same scope"
70747089
},
7090+
"is_default": {
7091+
"type": "boolean",
7092+
"readOnly": true,
7093+
"description": "Whether this template is the default; it will be automatically loaded when composing a new message"
7094+
},
70757095
"created_at": {
70767096
"type": "string",
70777097
"format": "date-time",
@@ -7092,6 +7112,7 @@
70927112
"html_body",
70937113
"id",
70947114
"is_active",
7115+
"is_default",
70957116
"is_forced",
70967117
"name",
70977118
"raw_body",

src/backend/core/api/serializers.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ class Meta:
334334
"type",
335335
"is_active",
336336
"is_forced",
337+
"is_default",
337338
"created_at",
338339
"updated_at",
339340
]
@@ -1366,6 +1367,11 @@ class MessageTemplateSerializer(serializers.ModelSerializer):
13661367
is_forced = serializers.BooleanField(
13671368
required=False, default=False, help_text="Set as forced template"
13681369
)
1370+
is_default = serializers.BooleanField(
1371+
required=False,
1372+
default=False,
1373+
help_text="Set as default template (auto-loaded when composing a new message)",
1374+
)
13691375
html_body = serializers.CharField(required=False)
13701376
text_body = serializers.CharField(required=False)
13711377
raw_body = serializers.CharField(required=False)
@@ -1381,6 +1387,7 @@ class Meta:
13811387
"type",
13821388
"is_active",
13831389
"is_forced",
1390+
"is_default",
13841391
"created_at",
13851392
"updated_at",
13861393
]

src/backend/core/api/viewsets/message_template.py

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""API ViewSet for message templates."""
22

3-
from django.db.models import Q
3+
from django.db.models import Case, IntegerField, Q, When
44
from django.utils.functional import cached_property
55

66
from drf_spectacular.utils import (
@@ -156,7 +156,8 @@ class AvailableMailboxMessageTemplateViewSet(
156156

157157
def get_queryset(self):
158158
"""Get message templates active for a mailbox and its domain.
159-
If a forced template exists for a template type, user can only see it."""
159+
If a forced template exists for a template type, user can only see it.
160+
Mailbox-level templates are returned before domain-level templates."""
160161
mailbox = get_object_or_404(Mailbox, id=self.kwargs["mailbox_id"])
161162
# get active message templates for mailbox and its domain
162163
queryset = MessageTemplate.objects.filter(
@@ -174,6 +175,16 @@ def get_queryset(self):
174175
if forced_active_templates.exists():
175176
queryset = forced_active_templates
176177

178+
# Order by scope: mailbox templates first, then domain templates
179+
# This ensures mailbox-level defaults take priority over domain-level
180+
queryset = queryset.annotate(
181+
scope_order=Case(
182+
When(mailbox__isnull=False, then=0), # Mailbox templates first
183+
default=1, # Domain templates second
184+
output_field=IntegerField(),
185+
)
186+
).order_by("scope_order", "-is_default", "-created_at")
187+
177188
return queryset.distinct()
178189

179190
@extend_schema(
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 5.1.13 on 2026-01-09 09:27
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0013_thread_is_trashed'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='messagetemplate',
15+
name='is_default',
16+
field=models.BooleanField(default=False, help_text='Whether this template is the default; it will be automatically loaded when composing a new message', verbose_name='is default'),
17+
),
18+
migrations.AddIndex(
19+
model_name='messagetemplate',
20+
index=models.Index(fields=['mailbox', 'type', 'is_default'], name='messages_me_mailbox_3bb022_idx'),
21+
),
22+
migrations.AddIndex(
23+
model_name='messagetemplate',
24+
index=models.Index(fields=['maildomain', 'type', 'is_default'], name='messages_me_maildom_d8eb5d_idx'),
25+
),
26+
migrations.AddConstraint(
27+
model_name='messagetemplate',
28+
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('mailbox', 'type'), name='uniq_default_template_mailbox_type'),
29+
),
30+
migrations.AddConstraint(
31+
model_name='messagetemplate',
32+
constraint=models.UniqueConstraint(condition=models.Q(('is_default', True)), fields=('maildomain', 'type'), name='uniq_default_template_maildomain_type'),
33+
),
34+
]

src/backend/core/models.py

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1790,6 +1790,14 @@ class MessageTemplate(BaseModel):
17901790
),
17911791
)
17921792

1793+
is_default = models.BooleanField(
1794+
_("is default"),
1795+
default=False,
1796+
help_text=_(
1797+
"Whether this template is the default; it will be automatically loaded when composing a new message"
1798+
),
1799+
)
1800+
17931801
class Meta:
17941802
db_table = "messages_messagetemplate"
17951803
verbose_name = _("message template")
@@ -1812,20 +1820,33 @@ class Meta:
18121820
condition=models.Q(is_forced=True),
18131821
name="uniq_forced_template_maildomain_type",
18141822
),
1823+
models.UniqueConstraint(
1824+
fields=("mailbox", "type"),
1825+
condition=models.Q(is_default=True),
1826+
name="uniq_default_template_mailbox_type",
1827+
),
1828+
models.UniqueConstraint(
1829+
fields=("maildomain", "type"),
1830+
condition=models.Q(is_default=True),
1831+
name="uniq_default_template_maildomain_type",
1832+
),
18151833
]
18161834
indexes = [
18171835
models.Index(fields=("mailbox", "type", "is_active")),
18181836
models.Index(fields=("maildomain", "type", "is_active")),
1837+
models.Index(fields=("mailbox", "type", "is_default")),
1838+
models.Index(fields=("maildomain", "type", "is_default")),
18191839
]
18201840

18211841
def __str__(self):
18221842
return f"{self.name} ({self.get_type_display()})"
18231843

18241844
def save(self, *args, **kwargs):
1825-
"""If the template is forced, unforce all other templates of the same type
1826-
in the same scope (mailbox or maildomain)
1827-
only one forced template is allowed per type and scope"""
1845+
"""If the template is forced or default, unset other templates of the same type
1846+
in the same scope (mailbox or maildomain).
1847+
Only one forced/default template is allowed per type and scope."""
18281848
with transaction.atomic():
1849+
# Handle is_forced: only one forced template per type and scope
18291850
if self.is_forced:
18301851
qs = (
18311852
MessageTemplate.objects.select_for_update()
@@ -1837,6 +1858,20 @@ def save(self, *args, **kwargs):
18371858
elif self.maildomain_id:
18381859
qs = qs.filter(maildomain_id=self.maildomain_id)
18391860
qs.update(is_forced=False)
1861+
1862+
# Handle is_default: only one default template per type and scope
1863+
if self.is_default:
1864+
qs = (
1865+
MessageTemplate.objects.select_for_update()
1866+
.filter(type=self.type, is_default=True)
1867+
.exclude(id=self.id)
1868+
)
1869+
if self.mailbox_id:
1870+
qs = qs.filter(mailbox_id=self.mailbox_id)
1871+
elif self.maildomain_id:
1872+
qs = qs.filter(maildomain_id=self.maildomain_id)
1873+
qs.update(is_default=False)
1874+
18401875
super().save(*args, **kwargs)
18411876

18421877
def clean(self):
@@ -1849,9 +1884,10 @@ def clean(self):
18491884
raise ValidationError(
18501885
{"__all__": "Mailbox and maildomain cannot be linked together"}
18511886
)
1852-
# if user desactivate a forced template, the template is no longer forced
1887+
# if user deactivates a template, it should no longer be forced or default
18531888
if not self.is_active:
18541889
self.is_forced = False
1890+
self.is_default = False
18551891
super().clean()
18561892

18571893
@property

src/backend/core/tests/api/test_admin_maildomains_message_template.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,6 +739,46 @@ def test_forced_template_becomes_inactive(self, user, maildomain, admin_detail_u
739739
assert signature.is_forced is False
740740
assert signature.is_active is False
741741

742+
def test_default_template_becomes_inactive(self, user, maildomain, admin_detail_url):
743+
"""Test that when a default template is updated to be inactive, it should also become non-default."""
744+
factories.MailDomainAccessFactory(
745+
maildomain=maildomain,
746+
user=user,
747+
role=models.MailDomainAccessRoleChoices.ADMIN,
748+
)
749+
750+
# Create a default signature template
751+
signature = factories.MessageTemplateFactory(
752+
name="Default Signature Template",
753+
html_body="<p>Default signature content</p>",
754+
text_body="Default signature content",
755+
type=enums.MessageTemplateTypeChoices.SIGNATURE,
756+
maildomain=maildomain,
757+
is_default=True,
758+
is_active=True,
759+
)
760+
761+
assert signature.is_default is True
762+
assert signature.is_active is True
763+
764+
# Update template to be inactive
765+
client = APIClient()
766+
client.force_authenticate(user=user)
767+
768+
data = {"is_active": False}
769+
770+
response = client.patch(
771+
admin_detail_url(signature.id),
772+
data,
773+
format="json",
774+
)
775+
assert response.status_code == status.HTTP_200_OK
776+
777+
# Verify that template is no longer default and is inactive
778+
signature.refresh_from_db()
779+
assert signature.is_default is False
780+
assert signature.is_active is False
781+
742782
def test_is_forced_maildomain(self, user, maildomain, admin_detail_url):
743783
"""Test that updating a template to forced sets others to not forced for the same maildomain and type."""
744784
factories.MailDomainAccessFactory(
@@ -790,6 +830,57 @@ def test_is_forced_maildomain(self, user, maildomain, admin_detail_url):
790830
assert signature1.is_forced is False
791831
assert signature2.is_forced is True
792832

833+
def test_is_default_maildomain(self, user, maildomain, admin_detail_url):
834+
"""Test that updating a template to default sets others to not default for the same maildomain and type."""
835+
factories.MailDomainAccessFactory(
836+
maildomain=maildomain,
837+
user=user,
838+
role=models.MailDomainAccessRoleChoices.ADMIN,
839+
)
840+
841+
# Create signature template as default
842+
signature1 = factories.MessageTemplateFactory(
843+
name="Default Signature Template",
844+
html_body="<p>Default signature content</p>",
845+
text_body="Default signature content",
846+
type=enums.MessageTemplateTypeChoices.SIGNATURE,
847+
maildomain=maildomain,
848+
is_default=True,
849+
)
850+
851+
# Create second signature template as not default
852+
signature2 = factories.MessageTemplateFactory(
853+
name="Second Signature Template",
854+
html_body="<p>Second signature content</p>",
855+
text_body="Second signature content",
856+
type=enums.MessageTemplateTypeChoices.SIGNATURE,
857+
maildomain=maildomain,
858+
is_default=False,
859+
)
860+
861+
assert signature1.is_default is True
862+
assert signature2.is_default is False
863+
864+
# Update second template to be default
865+
client = APIClient()
866+
client.force_authenticate(user=user)
867+
868+
data = {"is_default": True}
869+
870+
response = client.patch(
871+
admin_detail_url(signature2.id),
872+
data,
873+
format="json",
874+
)
875+
assert response.status_code == status.HTTP_200_OK
876+
877+
# Verify that first template is no longer default
878+
signature1.refresh_from_db()
879+
signature2.refresh_from_db()
880+
881+
assert signature1.is_default is False
882+
assert signature2.is_default is True
883+
793884

794885
class TestAdminMailDomainMessageTemplateDelete:
795886
"""Test delete operations for MessageTemplateViewSet."""

0 commit comments

Comments
 (0)