Skip to content

Commit 8e7d63b

Browse files
Create archive folder if it does not exist (#468)
* Fix stalwart's example config for dev. Stalwart pops fields from the oidc response for some reason, it'll set email as username if we don't specify username. So let's drop that. * Add a middleware that checks and automatically fixes missing archives folder Changes: * Add thunderbird_accounts.mail.middleware.FixMissingArchivesFolderMiddleware * Add Account.verified_archive_folder (bool) * Add functionality to query and create (if needed) an archives folder via jmap * Add settings.STALWART_ARCHIVES_FOLDER_NAME which controls the name of the archives folder that is created. * Move middleware to to mail and start working out the tests * Remove duplicate migration * Finish up the middleware tests * Lint fix * Fix mailbox/set response check and test
1 parent 0fc7139 commit 8e7d63b

12 files changed

Lines changed: 445 additions & 20 deletions

File tree

boot-docker-build.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/sh
2+
3+
sudo docker compose up --build accounts vite-dev celery

boot-docker-deps.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
#!/bin/sh
2+
3+
sudo docker compose up postgres redis kcpostgres keycloak stalwart mailpit

config.toml.example

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,6 @@ cache.ttl.positive = "1h"
5151
endpoint.method = "introspect"
5252
endpoint.url = "http://keycloak:8999/realms/tbpro/protocol/openid-connect/token/introspect"
5353
fields.email = "username"
54-
fields.username = "username"
5554
timeout = "15s"
5655
type = "oidc"
5756

src/thunderbird_accounts/authentication/middleware.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import logging
2+
from typing import Optional
23
from socket import gethostbyname, gethostname
34

45
from django.conf import settings
@@ -14,11 +15,17 @@
1415

1516

1617
class AccountsOIDCBackend(OIDCAuthenticationBackend):
18+
"""User authentication middleware for OIDC
19+
20+
This is our slightly customized mozilla-django-oidc middleware used to create/update/authenticate users
21+
against oidc flows.
22+
"""
23+
1724
def get_user(self, user_id):
1825
"""Retrieve the user from OIDC get_user and additionally check if they're active.
1926
Fixes https://github.com/mozilla/mozilla-django-oidc/issues/520
2027
"""
21-
user: User = super().get_user(user_id)
28+
user: Optional[User] = super().get_user(user_id)
2229
return user if self.user_can_authenticate(user) else None
2330

2431
def _check_allow_list(self, claims: dict):

src/thunderbird_accounts/authentication/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
class User(AbstractUser, BaseModel):
1313
"""
14+
:param username: The thundermail address, aligns with keycloak's usage of username.
1415
:param oidc_id: The ID of the connected oidc account
1516
:param last_used_email: The last used email associated with this account
1617
:param language: The user's preferred language to view the ui/system emails in
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from django.http import HttpRequest
2+
from thunderbird_accounts.mail.utils import fix_archives_folder
3+
4+
5+
class FixMissingArchivesFolderMiddleware:
6+
def __init__(self, get_response):
7+
self.get_response = get_response
8+
9+
def __call__(self, request):
10+
if request.user.is_authenticated and request.user.has_active_subscription:
11+
# This needs to be here for after they subscribe
12+
# we need their oidc access token which is only available on the request...
13+
self.check_if_we_need_to_fix_archives_folder(request)
14+
15+
return self.get_response(request)
16+
17+
def check_if_we_need_to_fix_archives_folder(self, request: HttpRequest):
18+
oidc_access_token = request.session['oidc_access_token']
19+
user = request.user
20+
21+
# If the user exists, has a stalwart account reference and an oidc access token
22+
# Check if we need to fix their archives folder, and do it if we need to.
23+
if user and user.account_set.count() > 0 and oidc_access_token:
24+
archive_folders_to_check = user.account_set.filter(verified_archive_folder=False)
25+
for account in archive_folders_to_check:
26+
fix_archives_folder(oidc_access_token, account)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.2.9 on 2025-12-03 19:13
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('mail', '0008_merge_0007_account_quota_0007_domain'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='account',
15+
name='verified_archive_folder',
16+
field=models.BooleanField(default=False, help_text='On login we check if they have an archive folder. If this is true that check will be skipped.'),
17+
),
18+
]

src/thunderbird_accounts/mail/models.py

Lines changed: 9 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ class Account(BaseStalwartObject):
6363
quota = models.BigIntegerField(
6464
null=True, help_text=_('Amount of mail storage this account has access to (in bytes).')
6565
)
66+
verified_archive_folder = models.BooleanField(
67+
default=False,
68+
help_text=_('On login we check if they have an archive folder. If this is true that check will be skipped.'),
69+
)
6670
# TODO: Implement freeze_quota
6771

6872
class Meta:
@@ -133,29 +137,17 @@ class DomainStatus(models.TextChoices):
133137
VERIFIED = 'verified', _('Verified')
134138
FAILED = 'failed', _('Verification Failed')
135139

136-
name = SmallTextField(
137-
unique=True,
138-
help_text=_('The domain name (e.g., example.com)')
139-
)
140+
name = SmallTextField(unique=True, help_text=_('The domain name (e.g., example.com)'))
140141
status = models.CharField(
141142
max_length=20,
142143
choices=DomainStatus,
143144
default=DomainStatus.PENDING,
144-
help_text=_('Current verification status of the domain')
145+
help_text=_('Current verification status of the domain'),
145146
)
146147
user = models.ForeignKey(
147-
User,
148-
on_delete=models.CASCADE,
149-
related_name='domains',
150-
help_text=_('The user who owns this domain')
151-
)
152-
verified_at = models.DateTimeField(
153-
null=True,
154-
blank=True,
155-
help_text=_('Date and time when the domain was verified')
148+
User, on_delete=models.CASCADE, related_name='domains', help_text=_('The user who owns this domain')
156149
)
150+
verified_at = models.DateTimeField(null=True, blank=True, help_text=_('Date and time when the domain was verified'))
157151
last_verification_attempt = models.DateTimeField(
158-
null=True,
159-
blank=True,
160-
help_text=_('Date and time of the last verification attempt')
152+
null=True, blank=True, help_text=_('Date and time of the last verification attempt')
161153
)

0 commit comments

Comments
 (0)