Skip to content

Commit 3180d9d

Browse files
committed
rabbit review
1 parent 313f8ea commit 3180d9d

13 files changed

+283
-195
lines changed

src/backend/core/api/openapi.json

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5266,23 +5266,12 @@
52665266
"readOnly": true,
52675267
"title": "Updated on",
52685268
"description": "date and time at which a record was last updated"
5269-
},
5270-
"abilities": {
5271-
"type": "object",
5272-
"description": "Instance permissions and capabilities",
5273-
"additionalProperties": {
5274-
"type": "boolean"
5275-
},
5276-
"nullable": true,
5277-
"readOnly": true
52785269
}
52795270
},
52805271
"required": [
5281-
"abilities",
52825272
"created_at",
52835273
"id",
52845274
"name",
5285-
"raw_blob",
52865275
"type",
52875276
"updated_at"
52885277
]
@@ -5337,7 +5326,6 @@
53375326
},
53385327
"required": [
53395328
"name",
5340-
"raw_blob",
53415329
"type"
53425330
]
53435331
},
@@ -5696,7 +5684,7 @@
56965684
},
56975685
"is_forced": {
56985686
"type": "boolean",
5699-
"description": "Whether this template is forced no other same type template can be used"
5687+
"description": "Whether this template is forced; no other template of the same type can be used in the same scope"
57005688
},
57015689
"created_at": {
57025690
"type": "string",
@@ -5711,19 +5699,9 @@
57115699
"readOnly": true,
57125700
"title": "Updated on",
57135701
"description": "date and time at which a record was last updated"
5714-
},
5715-
"abilities": {
5716-
"type": "object",
5717-
"description": "Instance permissions and capabilities",
5718-
"additionalProperties": {
5719-
"type": "boolean"
5720-
},
5721-
"nullable": true,
5722-
"readOnly": true
57235702
}
57245703
},
57255704
"required": [
5726-
"abilities",
57275705
"created_at",
57285706
"id",
57295707
"name",

src/backend/core/api/serializers.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -919,7 +919,7 @@ class ImportIMAPSerializer(ImportBaseSerializer):
919919
)
920920

921921

922-
class ReadOnlyMessageTemplateSerializer(AbilitiesModelSerializer):
922+
class ReadOnlyMessageTemplateSerializer(serializers.ModelSerializer):
923923
"""Serialize message templates for read-only operations."""
924924

925925
type = IntegerChoicesField(choices_class=models.MessageTemplateTypeChoices)
@@ -946,7 +946,7 @@ class Meta:
946946
read_only_fields = ["id", "created_at", "updated_at", "raw_blob"]
947947

948948

949-
class MessageTemplateSerializer(AbilitiesModelSerializer):
949+
class MessageTemplateSerializer(serializers.ModelSerializer):
950950
"""Serialize message templates for POST/PUT/PATCH operations."""
951951

952952
type = IntegerChoicesField(choices_class=models.MessageTemplateTypeChoices)
@@ -1068,7 +1068,7 @@ def update(self, instance, validated_data):
10681068
maildomain_id = validated_data.pop("maildomain_id", None)
10691069
raw_blob_content = validated_data.pop("raw_blob", None)
10701070

1071-
# Update mailbox or maildomain if provided
1071+
# Update mailbox or maildomain if provided (validate consistency first)
10721072
if mailbox_id is not None and maildomain_id is not None:
10731073
# check if domain is the same as the mailbox domain
10741074
mailbox = models.Mailbox.objects.get(id=mailbox_id)

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ def get_queryset_for_list(self):
110110
maildomain_id = self.request.query_params.get("maildomain_id")
111111

112112
# Apply filters based on query parameters
113+
# Admin manage domain view (case 1)
113114
if maildomain_id:
114115
# When filtering by maildomain_id, only show templates the user has direct access to
115116
# through maildomain access (not through mailbox access)
@@ -121,6 +122,7 @@ def get_queryset_for_list(self):
121122
)
122123
else:
123124
queryset = queryset.filter(maildomain_id=maildomain_id)
125+
# New message creation view (case 2)
124126
elif mailbox_id:
125127
# Get the mailbox to find its domain
126128
try:
@@ -142,16 +144,21 @@ def get_queryset_for_list(self):
142144
queryset = queryset.filter(
143145
Q(mailbox_id=mailbox_id) | Q(maildomain_id=mailbox.domain_id)
144146
)
147+
# if a forced template exists, user can only see it
148+
if queryset.filter(is_forced=True).exists():
149+
queryset = queryset.filter(is_forced=True)
145150
except Mailbox.DoesNotExist:
146151
return MessageTemplate.objects.none()
147152

148153
# Apply additional filters
149154
is_forced = self.request.query_params.get("is_forced")
150-
_type = self.request.query_params.get("type")
155+
template_type = self.request.query_params.get("type")
151156
is_active = self.request.query_params.get("is_active")
152157

153-
if _type:
154-
queryset = queryset.filter(type=MessageTemplateTypeChoices[_type.upper()])
158+
if template_type:
159+
queryset = queryset.filter(
160+
type=MessageTemplateTypeChoices[template_type.upper()]
161+
)
155162
if is_active is not None:
156163
is_active_bool = is_active.lower() in ("true", "1", "yes")
157164
queryset = queryset.filter(is_active=is_active_bool)
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Generated by Django 5.1.11 on 2025-09-01 22:13
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0006_blob_maildomain_alter_blob_mailbox_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='messagetemplate',
15+
name='type',
16+
field=models.SmallIntegerField(choices=[(1, 'reply'), (2, 'new_message'), (3, 'signature')], default=1, help_text='Type of template (reply, new_message, signature)', verbose_name='type'),
17+
),
18+
migrations.AddConstraint(
19+
model_name='blob',
20+
constraint=models.CheckConstraint(condition=models.Q(('mailbox__isnull', False), ('maildomain__isnull', False), _connector='OR'), name='blob_has_owner'),
21+
),
22+
migrations.AddConstraint(
23+
model_name='messagetemplate',
24+
constraint=models.CheckConstraint(condition=models.Q(('mailbox__isnull', False), ('maildomain__isnull', False), _connector='OR'), name='messagetemplate_has_owner'),
25+
),
26+
migrations.AddConstraint(
27+
model_name='messagetemplate',
28+
constraint=models.UniqueConstraint(condition=models.Q(('is_forced', True)), fields=('mailbox', 'type'), name='uniq_forced_template_mailbox_type'),
29+
),
30+
migrations.AddConstraint(
31+
model_name='messagetemplate',
32+
constraint=models.UniqueConstraint(condition=models.Q(('is_forced', True)), fields=('maildomain', 'type'), name='uniq_forced_template_maildomain_type'),
33+
),
34+
]
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Generated by Django 5.1.11 on 2025-09-01 22:34
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('core', '0007_alter_messagetemplate_type_blob_blob_has_owner_and_more'),
10+
]
11+
12+
operations = [
13+
migrations.AlterField(
14+
model_name='messagetemplate',
15+
name='is_forced',
16+
field=models.BooleanField(default=False, help_text='Whether this template is forced; no other template of the same type can be used in the same scope', verbose_name='is forced'),
17+
),
18+
migrations.AddIndex(
19+
model_name='messagetemplate',
20+
index=models.Index(fields=['mailbox', 'type', 'is_active'], name='messages_me_mailbox_00a4d4_idx'),
21+
),
22+
migrations.AddIndex(
23+
model_name='messagetemplate',
24+
index=models.Index(fields=['maildomain', 'type', 'is_active'], name='messages_me_maildom_4a0e75_idx'),
25+
),
26+
]

src/backend/core/models.py

Lines changed: 66 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
from django.contrib.auth.base_user import AbstractBaseUser
1515
from django.core import validators
1616
from django.core.exceptions import ValidationError
17-
from django.db import models
17+
from django.db import models, transaction
18+
from django.utils.html import escape
1819
from django.utils.text import slugify
1920
from django.utils.translation import gettext_lazy as _
2021

@@ -1283,15 +1284,24 @@ class Meta:
12831284
verbose_name = _("blob")
12841285
verbose_name_plural = _("blobs")
12851286
ordering = ["-created_at"]
1287+
constraints = [
1288+
models.CheckConstraint(
1289+
check=(
1290+
models.Q(mailbox__isnull=False) | models.Q(maildomain__isnull=False)
1291+
),
1292+
name="blob_has_owner",
1293+
),
1294+
]
12861295

12871296
def __str__(self):
12881297
return f"Blob {self.id} ({self.size} bytes)"
12891298

1290-
def save(self, *args, **kwargs):
1291-
# at least one of mailbox or maildomain must be set
1292-
if not self.mailbox and not self.maildomain:
1293-
raise ValueError("Blob must have at least a mailbox or maildomain")
1294-
super().save(*args, **kwargs)
1299+
def clean(self):
1300+
if not self.mailbox_id and not self.maildomain_id:
1301+
raise ValidationError(
1302+
{"__all__": "Blob must have at least a mailbox or maildomain"}
1303+
)
1304+
super().clean()
12951305

12961306
def get_content(self) -> bytes:
12971307
"""
@@ -1492,9 +1502,7 @@ class MessageTemplate(BaseModel):
14921502
_("type"),
14931503
choices=MessageTemplateTypeChoices.choices,
14941504
default=MessageTemplateTypeChoices.REPLY,
1495-
help_text=_(
1496-
"Type of template (reply, forward, new message, auto reply, signature)"
1497-
),
1505+
help_text=_("Type of template (reply, new_message, signature)"),
14981506
)
14991507

15001508
is_active = models.BooleanField(
@@ -1525,64 +1533,69 @@ class MessageTemplate(BaseModel):
15251533
_("is forced"),
15261534
default=False,
15271535
help_text=_(
1528-
"Whether this template is forced no other same type template can be used"
1536+
"Whether this template is forced; no other template of the same type can be used in the same scope"
15291537
),
15301538
)
15311539

15321540
class Meta:
15331541
db_table = "messages_messagetemplate"
15341542
verbose_name = _("message template")
15351543
verbose_name_plural = _("message templates")
1536-
ordering = ["name"]
1544+
ordering = ["-created_at"]
1545+
constraints = [
1546+
models.CheckConstraint(
1547+
check=(
1548+
models.Q(mailbox__isnull=False) | models.Q(maildomain__isnull=False)
1549+
),
1550+
name="messagetemplate_has_owner",
1551+
),
1552+
models.UniqueConstraint(
1553+
fields=("mailbox", "type"),
1554+
condition=models.Q(is_forced=True),
1555+
name="uniq_forced_template_mailbox_type",
1556+
),
1557+
models.UniqueConstraint(
1558+
fields=("maildomain", "type"),
1559+
condition=models.Q(is_forced=True),
1560+
name="uniq_forced_template_maildomain_type",
1561+
),
1562+
]
1563+
indexes = [
1564+
models.Index(fields=("mailbox", "type", "is_active")),
1565+
models.Index(fields=("maildomain", "type", "is_active")),
1566+
]
15371567

15381568
def __str__(self):
15391569
return f"{self.name} ({self.get_type_display()})"
15401570

15411571
def save(self, *args, **kwargs):
1542-
# at least one of mailbox or maildomain must be set
1572+
with transaction.atomic():
1573+
if self.is_forced:
1574+
qs = MessageTemplate.objects.filter(
1575+
type=self.type, is_forced=True
1576+
).exclude(id=self.id)
1577+
if self.mailbox_id:
1578+
qs = qs.filter(mailbox_id=self.mailbox_id)
1579+
if self.maildomain_id:
1580+
qs = qs.filter(maildomain_id=self.maildomain_id)
1581+
qs.update(is_forced=False)
1582+
super().save(*args, **kwargs)
1583+
1584+
def clean(self):
1585+
errors = {}
15431586
if not self.mailbox and not self.maildomain:
1544-
raise ValueError(
1587+
errors["__all__"] = (
15451588
"MessageTemplate must have at least a mailbox or maildomain"
15461589
)
1547-
1548-
if self.is_forced:
1549-
# If this template is being set as forced, unforce other templates of the same type
1550-
# in the same maildomain or mailbox
1551-
other_forced_templates = MessageTemplate.objects.filter(
1552-
type=self.type, is_forced=True
1553-
).exclude(id=self.id)
1554-
if self.mailbox:
1555-
other_forced_templates = other_forced_templates.filter(
1556-
mailbox=self.mailbox
1557-
)
1558-
if self.maildomain:
1559-
other_forced_templates = other_forced_templates.filter(
1560-
maildomain=self.maildomain
1561-
)
1562-
1563-
if other_forced_templates.exists():
1564-
other_forced_templates.update(is_forced=False)
1565-
1566-
super().save(*args, **kwargs)
1567-
1568-
def get_abilities(self, user):
1569-
"""Get abilities for this template based on user permissions."""
1570-
abilities = []
1571-
1572-
# Check if user has access to any of the mailboxes or maildomains
15731590
if (
1574-
user.is_superuser
1575-
or (self.mailbox and self.mailbox.accesses.filter(user=user).exists())
1576-
or (self.maildomain and self.maildomain.accesses.filter(user=user).exists())
1591+
self.mailbox
1592+
and self.maildomain
1593+
and self.mailbox.domain_id != self.maildomain_id
15771594
):
1578-
abilities.extend(
1579-
[
1580-
UserAbilities.CAN_VIEW_DOMAIN_ADMIN,
1581-
UserAbilities.CAN_CREATE_MAILDOMAINS,
1582-
]
1583-
)
1584-
1585-
return abilities
1595+
errors["mailbox"] = "Mailbox domain does not match maildomain."
1596+
if errors:
1597+
raise ValidationError(errors)
1598+
super().clean()
15861599

15871600
def render_template(self, user: User) -> Dict[str, str]:
15881601
"""
@@ -1609,7 +1622,9 @@ def render_template(self, user: User) -> Dict[str, str]:
16091622
# Simple placeholder substitution
16101623
for key, value in context.items():
16111624
placeholder = f"{{{key}}}"
1612-
rendered_html_body = rendered_html_body.replace(placeholder, str(value))
1625+
rendered_html_body = rendered_html_body.replace(
1626+
placeholder, escape(str(value))
1627+
)
16131628
rendered_text_body = rendered_text_body.replace(placeholder, str(value))
16141629

16151630
return {

0 commit comments

Comments
 (0)