Files
webmail-client/backend/app/push/service.py
T
lukasandClaude Opus 5.5 fa552392c8 Calendar reminders as push notifications
A job every minute finds the event reminders that fell due — repeating events
and moved occurrences expanded as the calendar view does — and pushes each
once to every device with notifications on (sdk.push, new: plugins can notify
through the new-mail push delivery). A reminder missed while the worker was down
is still sent within 10 minutes; cancelled events and declined invitations stay
quiet. Title, then "In 15 minutes, 14:00 · Room 2" in the user's zone and clock
format; it stays until dismissed and shows even with the app open; clicking it
opens that day (/p/calendar?date=…, new deep link).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 21:49:04 +02:00

296 lines
10 KiB
Python

"""Push notifications about new mail: the server's keys, the devices, and the job.
New Inbox mail found by the live sync enqueues :func:`push_new_mail_job` a few
seconds later, so filters and the spam check have moved what they move by then;
the job looks again at each message and tells the user's devices only about
mail that is still unread in an Inbox, recent, and not from the user.
"""
from __future__ import annotations
import datetime as dt
import logging
import uuid
from typing import Any
import httpx
from sqlalchemy import delete, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.identity.sessions import session_alive
from app.models import Account, AppSettings, Mailbox, Message, PushSubscription
from app.push import webpush
from app.security.crypto import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
# How long after new Inbox mail is found the job runs (filters, spam check).
NOTIFY_DELAY_S = 5
# Mail older than this is not news: a message moved back into the Inbox, or a
# backlog after the server was unreachable for a while.
MAX_AGE = dt.timedelta(hours=2)
# More new messages than this at once become a single "N new messages".
MAX_SINGLE = 3
SNIPPET_CHARS = 140
# ---- the server's key pair ------------------------------------------------------------
async def vapid(db: AsyncSession) -> webpush.Vapid:
"""The server's VAPID key pair, made (once) on first use."""
row = await db.get(AppSettings, 1)
if row is not None and row.vapid_private_ct:
return webpush.Vapid.from_pem(decrypt_secret(row.vapid_private_ct))
row = await db.scalar(select(AppSettings).where(AppSettings.id == 1).with_for_update())
if row is None:
row = AppSettings(id=1)
db.add(row)
elif row.vapid_private_ct: # made by another request meanwhile
return webpush.Vapid.from_pem(decrypt_secret(row.vapid_private_ct))
key = webpush.Vapid.generate()
row.vapid_private_ct = encrypt_secret(key.pem())
row.vapid_public = key.public_b64
await db.commit()
return key
def _subject(row: AppSettings | None) -> str:
"""Who to contact about our pushes (the VAPID ``sub``): the app's address
when it is set and https, else the postmaster of the app's domain."""
url = row.public_url if row is not None else ""
if url.startswith("https://"):
return url
return f"mailto:postmaster@{get_settings().app_domain}"
# ---- devices ------------------------------------------------------------------------
async def subscribe(
db: AsyncSession,
*,
user_id: uuid.UUID,
session_sid: str,
endpoint: str,
p256dh: str,
auth: str,
user_agent: str,
) -> None:
"""Remember a device (or refresh it). One row per endpoint: a browser that
is now signed in as someone else is theirs from now on."""
row = await db.scalar(select(PushSubscription).where(PushSubscription.endpoint == endpoint))
if row is None:
row = PushSubscription(endpoint=endpoint)
db.add(row)
row.user_id = user_id
row.session_sid = session_sid
row.p256dh = p256dh
row.auth = auth
row.user_agent = user_agent[:256]
await db.commit()
async def unsubscribe(db: AsyncSession, *, user_id: uuid.UUID, endpoint: str) -> None:
await db.execute(
delete(PushSubscription).where(
PushSubscription.user_id == user_id, PushSubscription.endpoint == endpoint
)
)
await db.commit()
# ---- what to say --------------------------------------------------------------------
def _sender(msg: Message) -> str:
first = (msg.from_addr or [{}])[0]
return (first.get("name") or first.get("email") or "Unknown sender").strip()
def _payloads(messages: list[Message], unread: int) -> list[dict[str, object]]:
"""One notification per message, or one summary when there are many."""
if len(messages) > MAX_SINGLE:
senders: list[str] = []
for m in messages:
name = _sender(m)
if name not in senders:
senders.append(name)
shown = ", ".join(senders[:3]) + ("…" if len(senders) > 3 else "")
return [
{
"type": "mail",
"title": f"{len(messages)} new messages",
"body": shown,
"tag": "new-mail",
"url": "/mail",
"unread": unread,
}
]
out: list[dict[str, object]] = []
for m in messages:
subject = m.subject.strip() or "(no subject)"
snippet = " ".join(m.snippet.split())[:SNIPPET_CHARS]
out.append(
{
"type": "mail",
"title": _sender(m),
"body": f"{subject}\n{snippet}" if snippet else subject,
"tag": f"thread-{m.thread_id}",
"url": f"/mail/t/{m.thread_id}" if m.thread_id else "/mail",
"thread_id": str(m.thread_id) if m.thread_id else None,
"unread": unread,
}
)
return out
async def _news(db: AsyncSession, user_id: uuid.UUID, ids: list[uuid.UUID]) -> list[Message]:
"""The messages still worth a notification, oldest first."""
own = {
e.lower() for e in await db.scalars(select(Account.email).where(Account.user_id == user_id))
}
rows = await db.scalars(
select(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user_id,
Message.id.in_(ids),
Mailbox.special_use == "inbox",
~Message.flags.contains(["\\Seen"]),
Message.internal_date >= dt.datetime.now(dt.UTC) - MAX_AGE,
)
.order_by(Message.internal_date)
)
out: list[Message] = []
seen: set[str] = set()
for m in rows:
sender = ((m.from_addr or [{}])[0].get("email") or "").lower()
if sender in own:
continue # sent to yourself, or a copy of your own message
key = m.delivery_id or str(m.id) # one email that reached two accounts
if key in seen:
continue
seen.add(key)
out.append(m)
return out
async def _unread(db: AsyncSession, user_id: uuid.UUID) -> int:
"""Unread Inbox messages, for the app icon's badge."""
count = await db.scalar(
select(func.count())
.select_from(Message)
.join(Mailbox, Mailbox.id == Message.mailbox_id)
.where(
Message.user_id == user_id,
Mailbox.special_use == "inbox",
~Message.flags.contains(["\\Seen"]),
)
)
return int(count or 0)
# ---- the job ------------------------------------------------------------------------
async def notify(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> int:
"""Tell the user's signed-in devices about ``message_ids``; returns how many
pushes were delivered to push services."""
from app.db import session_scope
async with session_scope() as db:
if not await db.scalar(
select(PushSubscription.id).where(PushSubscription.user_id == user_id).limit(1)
):
return 0
messages = await _news(db, user_id, message_ids)
if not messages:
return 0
payloads = _payloads(messages, await _unread(db, user_id))
return await deliver(user_id, payloads)
async def deliver(user_id: uuid.UUID, payloads: list[dict[str, object]]) -> int:
"""Push ``payloads`` to every device of the user that is still signed in
(for new mail, and for plugins: calendar reminders); returns how many were
handed to push services. Devices that are gone are forgotten."""
from app.db import session_scope
if not payloads:
return 0
async with session_scope() as db:
# Plain values: the rows expire when the key pair is first saved below.
subs = [
(row.id, row.endpoint, row.p256dh, row.auth, row.session_sid)
for row in await db.scalars(
select(PushSubscription).where(PushSubscription.user_id == user_id)
)
]
if not subs:
return 0
key = await vapid(db)
subject = _subject(await db.get(AppSettings, 1))
sent = 0
gone: list[uuid.UUID] = []
used: list[uuid.UUID] = []
async with httpx.AsyncClient(timeout=10.0) as client:
for sub_id, endpoint, p256dh, auth, sid in subs:
if not await session_alive(user_id, sid):
gone.append(sub_id) # signed out: this device hears nothing more
continue
try:
for payload in payloads:
await webpush.send(
client,
endpoint=endpoint,
p256dh=p256dh,
auth=auth,
payload=payload,
vapid=key,
subject=subject,
)
sent += 1
used.append(sub_id)
except webpush.Gone:
gone.append(sub_id)
except webpush.Failed as err:
logger.warning("push to %s failed: %s", endpoint.split("/")[2], err)
if gone or used:
async with session_scope() as db:
if gone:
await db.execute(delete(PushSubscription).where(PushSubscription.id.in_(gone)))
if used:
await db.execute(
update(PushSubscription)
.where(PushSubscription.id.in_(used))
.values(last_used_at=func.now())
)
return sent
async def push_new_mail_job(ctx: dict[str, Any], user_id: str, message_ids: list[str]) -> int:
"""arq job: notify about new Inbox mail (enqueued by the live sync)."""
return await notify(uuid.UUID(user_id), [uuid.UUID(m) for m in message_ids])
async def enqueue_new_mail(user_id: uuid.UUID, message_ids: list[uuid.UUID]) -> None:
"""Called by the live sync with new Inbox mail. Never raises: a missing
notification must not stop the sync."""
if not message_ids:
return
from app.queue import enqueue_job
try:
await enqueue_job(
"push_new_mail_job",
str(user_id),
[str(m) for m in message_ids],
_defer_by=NOTIFY_DELAY_S,
)
except Exception: # noqa: BLE001 - best effort
logger.warning("could not enqueue new-mail push for user %s", user_id, exc_info=True)