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>
296 lines
10 KiB
Python
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)
|