Coverage for app/venv/lib/python3.14/site-packages/weblate/accounts/tasks.py: 65%
155 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 07:15 +0000
1# Copyright © Michal Čihař <michal@weblate.org>
2#
3# SPDX-License-Identifier: GPL-3.0-or-later
5from __future__ import annotations
7import logging
8from datetime import timedelta
9from email.mime.image import MIMEImage
10from smtplib import SMTP, SMTPConnectError
11from types import MethodType
12from typing import TYPE_CHECKING, TypedDict
14import sentry_sdk
15from celery.schedules import crontab
16from django.conf import settings
17from django.core.mail import EmailMultiAlternatives, get_connection
18from django.core.mail.backends.smtp import EmailBackend as DjangoSMTPEmailBackend
19from django.db import transaction
20from django.utils.timezone import now
21from social_django.models import Code, Partial
23from weblate.utils.celery import app
24from weblate.utils.errors import report_error
25from weblate.utils.html import HTML2Text
26from weblate.utils.icons import load_icon
28if TYPE_CHECKING: 28 ↛ 29line 28 didn't jump to line 29 because the condition on line 28 was never true
29 from collections.abc import Generator
31 from weblate.accounts.notifications import Notification
33LOGGER = logging.getLogger("weblate.smtp")
35# The batch size needs to be below exim's default connection_max_messages = 500.
36EMAIL_BATCH_SIZE = 200
39class OutgoingEmail(TypedDict):
40 address: str
41 subject: str
42 body: str
43 headers: dict[str, str]
46@app.task(trail=False)
47def cleanup_social_auth() -> None:
48 """Cleanup expired partial social authentications."""
49 age = now() - timedelta(seconds=settings.AUTH_TOKEN_VALID)
50 # Delete old not verified codes
51 Code.objects.filter(verified=False, timestamp__lt=age).delete()
53 # Delete old partial data
54 Partial.objects.filter(timestamp__lt=age).delete()
57@app.task(trail=False)
58def cleanup_auditlog() -> None:
59 """Cleanup old auditlog entries."""
60 from weblate.accounts.models import AuditLog
62 timestamp = now()
64 # Cleanup old entries
65 AuditLog.objects.filter(
66 timestamp__lt=timestamp - timedelta(days=settings.AUDITLOG_EXPIRY)
67 ).delete()
69 # Finalize pending two-factor entries, these happen due to
70 # WebAuthn keys being added in two stages. Mature entries older than 5 minutes
71 # but look only two hours into past for performance reasons
72 for audit in AuditLog.objects.filter( 72 ↛ 79line 72 didn't jump to line 79 because the loop on line 72 never started
73 timestamp__range=(
74 timestamp - timedelta(hours=2),
75 timestamp - timedelta(minutes=5),
76 ),
77 activity="twofactor-add",
78 ):
79 if "skip_notify" in audit.params:
80 del audit.params["skip_notify"]
81 audit.save(update_fields=["params"])
84class NotificationFactory:
85 def __init__(self) -> None:
86 self.outgoing: list[OutgoingEmail] = []
87 self.instances: dict[str, Notification] = {}
89 def for_action(self, action: int) -> Generator[Notification]:
90 from weblate.accounts.notifications import NOTIFICATIONS_ACTIONS
92 if action not in NOTIFICATIONS_ACTIONS: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true
93 return
94 for notification_cls in NOTIFICATIONS_ACTIONS[action]:
95 name = notification_cls.get_name()
96 try:
97 yield self.instances[name]
98 except KeyError:
99 result = self.instances[name] = notification_cls(self.outgoing)
100 yield result
102 def send_queued(self) -> None:
103 if self.outgoing:
104 queue_mails(self.outgoing)
105 self.outgoing.clear()
108@app.task(trail=False)
109@transaction.atomic
110def notify_changes(change_ids: list[int]) -> None:
111 from weblate.trans.models import Change
113 changes = Change.objects.prefetch_for_render().filter(pk__in=change_ids)
114 factory = NotificationFactory()
116 for change in changes.iterator(chunk_size=200):
117 change.fill_in_prefetched()
118 for notification in factory.for_action(change.action):
119 notification.notify_immediate(change)
120 factory.send_queued()
123@transaction.atomic
124def notify_digest(method) -> None:
125 from weblate.accounts.notifications import NOTIFICATIONS
127 outgoing: list[OutgoingEmail] = []
128 for notification_cls in NOTIFICATIONS:
129 notification = notification_cls(outgoing)
130 getattr(notification, method)()
131 if outgoing:
132 queue_mails(outgoing)
135@app.task(trail=False)
136def notify_daily() -> None:
137 notify_digest("notify_daily")
140@app.task(trail=False)
141def notify_weekly() -> None:
142 notify_digest("notify_weekly")
145@app.task(trail=False)
146def notify_monthly() -> None:
147 notify_digest("notify_monthly")
150@app.task(trail=False)
151def notify_auditlog(log_id: int, email: str) -> None:
152 from weblate.accounts.models import AuditLog
153 from weblate.accounts.notifications import send_notification_email
155 audit = AuditLog.objects.get(pk=log_id)
156 send_notification_email(
157 audit.user.profile.language if audit.user else "en",
158 [email],
159 "account_activity",
160 context={
161 "message": audit.get_message,
162 "extra_message": audit.get_extra_message,
163 "address": audit.shortened_address,
164 "user_agent": audit.user_agent,
165 },
166 info=f"{audit.activity} from {audit.address}",
167 )
170SMTP_DATA_PATCH = "_weblate_patched_data"
173def weblate_logging_smtp_data(self, msg):
174 (code, msg) = getattr(self, SMTP_DATA_PATCH)(msg)
175 if code == 250:
176 LOGGER.debug("SMTP completed (%s): %s", code, msg.decode())
177 else:
178 LOGGER.error("SMTP failed (%s): %s", code, msg.decode())
179 return (code, msg)
182def monkey_patch_smtp_logging(connection):
183 if isinstance(connection, DjangoSMTPEmailBackend):
184 # Ensure the connection is open
185 connection.open()
187 # Monkey patch smtplib.SMTP or smtplib.SMTP_SSL
188 backend = connection.connection
189 if isinstance(backend, SMTP) and not hasattr(backend, SMTP_DATA_PATCH):
190 setattr(backend, SMTP_DATA_PATCH, backend.data)
191 backend.data = MethodType(weblate_logging_smtp_data, backend) # type: ignore[method-assign]
193 return connection
196def queue_mails(mails: list[OutgoingEmail]) -> None:
197 """Enqueue e-mails for delivery in reasonable batches."""
198 for offset in range(0, len(mails), EMAIL_BATCH_SIZE):
199 send_mails.delay(mails[offset : offset + EMAIL_BATCH_SIZE])
202@app.task(
203 trail=False,
204 autoretry_for=(SMTPConnectError, OSError),
205 retry_backoff=600,
206 retry_backoff_max=3600,
207)
208def send_mails(mails: list[OutgoingEmail]) -> None:
209 """Send multiple mails in single connection."""
210 images = []
211 with sentry_sdk.start_span(op="email.images"):
212 for name in ("email-logo.png", "email-logo-footer.png"):
213 image = MIMEImage(load_icon(name, auto_prefix=False))
214 image.add_header("Content-ID", f"<{name}@cid.weblate.org>")
215 image.add_header("Content-Disposition", "inline", filename=name)
216 images.append(image)
218 with sentry_sdk.start_span(op="email.connect"):
219 connection = get_connection()
220 try:
221 connection.open()
222 except Exception:
223 LOGGER.exception("Could not initialize e-mail backend")
224 report_error("Could not send notifications")
225 connection.close()
226 return
227 connection = monkey_patch_smtp_logging(connection)
229 html2text = HTML2Text()
231 try:
232 for mail in mails:
233 with sentry_sdk.start_span(op="email.text"):
234 text = html2text.handle(mail["body"])
235 email = EmailMultiAlternatives(
236 settings.EMAIL_SUBJECT_PREFIX + mail["subject"],
237 text,
238 to=[mail["address"]],
239 headers=mail["headers"],
240 connection=connection,
241 )
242 email.mixed_subtype = "related"
243 for image in images:
244 email.attach(image)
245 email.attach_alternative(mail["body"], "text/html")
246 with sentry_sdk.start_span(op="email.send"):
247 LOGGER.debug("sending e-mail to %s", mail["address"])
248 email.send()
249 finally:
250 connection.close()
253@app.on_after_finalize.connect
254def setup_periodic_tasks(sender, **kwargs) -> None:
255 sender.add_periodic_task(3600, cleanup_social_auth.s(), name="social-auth-cleanup")
256 sender.add_periodic_task(3600, cleanup_auditlog.s(), name="auditlog-cleanup")
257 sender.add_periodic_task(
258 crontab(hour=1, minute=0), notify_daily.s(), name="notify-daily"
259 )
260 sender.add_periodic_task(
261 crontab(hour=2, minute=0, day_of_week="mon"),
262 notify_weekly.s(),
263 name="notify-weekly",
264 )
265 sender.add_periodic_task(
266 crontab(hour=3, minute=0, day_of_month=1),
267 notify_monthly.s(),
268 name="notify-monthly",
269 )