Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fda4cc6f16 | ||
|
|
0729dbb691 |
@@ -19,6 +19,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
from app.api.mailboxes import router as mailboxes_router
|
||||
from app.api.messages import router as messages_router
|
||||
from app.api.plugins import router as plugins_router
|
||||
from app.api.push import router as push_router
|
||||
from app.api.recipients import router as recipients_router
|
||||
from app.api.saved_searches import router as saved_searches_router
|
||||
from app.api.search import router as search_router
|
||||
@@ -44,6 +45,7 @@ def register_routers(app: FastAPI) -> None:
|
||||
plugins_router,
|
||||
admin_router,
|
||||
spellcheck_router,
|
||||
push_router,
|
||||
):
|
||||
app.include_router(router, prefix="/api")
|
||||
app.include_router(ws_router, prefix="/api")
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Push notifications about new mail: the server's public key, and the devices
|
||||
that want them (Settings → Accounts → Notifications)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.errors import raise_api_error
|
||||
from app.db import get_session
|
||||
from app.identity.deps import CurrentSession, CurrentUser, require_csrf
|
||||
from app.push import service, webpush
|
||||
|
||||
router = APIRouter(prefix="/push", tags=["push"], dependencies=[Depends(require_csrf)])
|
||||
|
||||
DbSession = Annotated[AsyncSession, Depends(get_session)]
|
||||
|
||||
|
||||
class PushKeyOut(BaseModel):
|
||||
public_key: str
|
||||
|
||||
|
||||
class PushKeys(BaseModel):
|
||||
p256dh: str = Field(min_length=1, max_length=200)
|
||||
auth: str = Field(min_length=1, max_length=100)
|
||||
|
||||
|
||||
class SubscriptionIn(BaseModel):
|
||||
"""The browser's ``PushSubscription.toJSON()``."""
|
||||
|
||||
endpoint: str = Field(min_length=1, max_length=2000)
|
||||
keys: PushKeys
|
||||
|
||||
|
||||
class EndpointIn(BaseModel):
|
||||
endpoint: str = Field(min_length=1, max_length=2000)
|
||||
|
||||
|
||||
@router.get("/key", response_model=PushKeyOut)
|
||||
async def push_key(user: CurrentUser, db: DbSession) -> PushKeyOut:
|
||||
"""The key a browser subscribes with (``applicationServerKey``)."""
|
||||
return PushKeyOut(public_key=(await service.vapid(db)).public_b64)
|
||||
|
||||
|
||||
@router.post("/subscriptions")
|
||||
async def add_subscription(
|
||||
body: SubscriptionIn,
|
||||
request: Request,
|
||||
user: CurrentUser,
|
||||
session: CurrentSession,
|
||||
db: DbSession,
|
||||
) -> dict[str, str]:
|
||||
if not webpush.allowed_endpoint(body.endpoint):
|
||||
raise_api_error(400, "push_service", "not a known push service")
|
||||
try:
|
||||
p256dh = webpush.b64u_decode(body.keys.p256dh)
|
||||
auth = webpush.b64u_decode(body.keys.auth)
|
||||
if len(p256dh) != 65 or len(auth) != 16:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise_api_error(400, "push_keys", "invalid subscription keys")
|
||||
await service.subscribe(
|
||||
db,
|
||||
user_id=user.id,
|
||||
session_sid=session.sid,
|
||||
endpoint=body.endpoint,
|
||||
p256dh=body.keys.p256dh,
|
||||
auth=body.keys.auth,
|
||||
user_agent=request.headers.get("user-agent", ""),
|
||||
)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/subscriptions/remove")
|
||||
async def remove_subscription(body: EndpointIn, user: CurrentUser, db: DbSession) -> dict[str, str]:
|
||||
await service.unsubscribe(db, user_id=user.id, endpoint=body.endpoint)
|
||||
return {"status": "ok"}
|
||||
@@ -121,6 +121,13 @@ async def list_sessions(user_id: uuid.UUID, current_token: str) -> list[SessionI
|
||||
return out
|
||||
|
||||
|
||||
async def session_alive(user_id: uuid.UUID | str, sid: str) -> bool:
|
||||
"""Whether the session with public id ``sid`` is still signed in."""
|
||||
redis = get_redis()
|
||||
token = await aw(redis.hget(_index_key(user_id), sid))
|
||||
return token is not None and bool(await redis.exists(_PREFIX + token))
|
||||
|
||||
|
||||
async def revoke_session(user_id: uuid.UUID, sid: str) -> bool:
|
||||
"""Revoke a specific session by its public sid. Returns False if not found."""
|
||||
redis = get_redis()
|
||||
|
||||
@@ -82,6 +82,11 @@ class AppSettings(Base):
|
||||
# Where users reach the app, e.g. https://mail.example.com — reset links are
|
||||
# built from this, never from the request's Host header.
|
||||
public_url: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
# The key pair push notifications are signed with (VAPID, RFC 8292): made on
|
||||
# first use; the private half vault-encrypted, the public half as browsers
|
||||
# take it (base64url, uncompressed P-256 point).
|
||||
vapid_public: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
vapid_private_ct: Mapped[bytes | None] = mapped_column(BYTEA)
|
||||
updated_at: Mapped[dt.datetime] = _now()
|
||||
|
||||
|
||||
@@ -344,6 +349,27 @@ class SavedSearch(Base):
|
||||
pinned: Mapped[bool] = mapped_column(nullable=False, server_default=text("true"))
|
||||
|
||||
|
||||
class PushSubscription(Base):
|
||||
"""A browser or installed app to tell about new mail (Web Push)."""
|
||||
|
||||
__tablename__ = "push_subscriptions"
|
||||
|
||||
id: Mapped[uuid.UUID] = _uuid_pk()
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
# The push service URL the browser handed out; posting to it reaches the device.
|
||||
endpoint: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
|
||||
# The browser's keys the message is encrypted to (RFC 8291), base64url.
|
||||
p256dh: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
auth: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
# The sign-in it was made under: nothing is sent once that session is gone.
|
||||
session_sid: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
user_agent: Mapped[str] = mapped_column(Text, nullable=False, server_default=text("''"))
|
||||
created_at: Mapped[dt.datetime] = _now()
|
||||
last_used_at: Mapped[dt.datetime | None] = mapped_column(TIMESTAMP(timezone=True))
|
||||
|
||||
|
||||
class PluginKv(Base):
|
||||
"""Namespaced key/value store for plugins (SPEC §11 ``plugin_kv``).
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Push notifications about new mail (Web Push)."""
|
||||
@@ -0,0 +1,279 @@
|
||||
"""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:
|
||||
# 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
|
||||
messages = await _news(db, user_id, message_ids)
|
||||
if not messages:
|
||||
return 0
|
||||
payloads = _payloads(messages, await _unread(db, user_id))
|
||||
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)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Web Push, the protocol (RFC 8030 delivery, RFC 8291 encryption, RFC 8292 VAPID).
|
||||
|
||||
A browser that wants notifications gives us a push service URL (Google's,
|
||||
Mozilla's, Apple's, Microsoft's) and two keys. A message is encrypted to those
|
||||
keys, so the push service only carries it, and signed with our own key pair
|
||||
(VAPID), so the push service knows it comes from the server the browser
|
||||
subscribed to. Written here rather than taken from pywebpush, which needs a
|
||||
newer ``cryptography`` than the rest of the backend pins; it is small, and
|
||||
``tests/push`` checks it against the RFC 8291 worked example.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
RECORD_SIZE = 4096
|
||||
# Push services only: the endpoint comes from the browser, i.e. from the user,
|
||||
# and the server posts to it — anything else would let a user aim the server at
|
||||
# an internal address (SSRF). Every browser in use delivers through one of these.
|
||||
PUSH_HOSTS = (
|
||||
"fcm.googleapis.com", # Chrome, Edge on Android, Opera, Samsung Internet
|
||||
"android.googleapis.com",
|
||||
".google.com", # Chromium builds subscribe at jmt17.google.com and the like
|
||||
".push.services.mozilla.com", # Firefox
|
||||
".push.apple.com", # Safari, and installed apps on iPhone/iPad
|
||||
".notify.windows.com", # Edge on Windows
|
||||
)
|
||||
|
||||
|
||||
def b64u_decode(value: str) -> bytes:
|
||||
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
|
||||
|
||||
def b64u_encode(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def allowed_endpoint(endpoint: str) -> bool:
|
||||
"""Whether ``endpoint`` is an https URL of a known push service."""
|
||||
parts = urlsplit(endpoint)
|
||||
host = (parts.hostname or "").lower()
|
||||
if parts.scheme != "https" or not host or parts.port not in (None, 443):
|
||||
return False
|
||||
return any(host == h or (h.startswith(".") and host.endswith(h)) for h in PUSH_HOSTS)
|
||||
|
||||
|
||||
def _public_bytes(key: ec.EllipticCurvePrivateKey) -> bytes:
|
||||
return key.public_key().public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
|
||||
def _hkdf(salt: bytes, ikm: bytes, info: bytes, length: int) -> bytes:
|
||||
return HKDF(algorithm=hashes.SHA256(), length=length, salt=salt, info=info).derive(ikm)
|
||||
|
||||
|
||||
def encrypt(
|
||||
payload: bytes,
|
||||
p256dh: str,
|
||||
auth: str,
|
||||
*,
|
||||
salt: bytes | None = None,
|
||||
sender: ec.EllipticCurvePrivateKey | None = None,
|
||||
) -> bytes:
|
||||
"""``payload`` encrypted for one browser (RFC 8291, ``aes128gcm``), as the
|
||||
request body: header (salt, record size, our one-off public key) + one
|
||||
record. ``salt`` and ``sender`` are for the RFC test vector only."""
|
||||
ua_public = b64u_decode(p256dh)
|
||||
auth_secret = b64u_decode(auth)
|
||||
sender = sender or ec.generate_private_key(ec.SECP256R1())
|
||||
as_public = _public_bytes(sender)
|
||||
ua_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), ua_public)
|
||||
ecdh_secret = sender.exchange(ec.ECDH(), ua_key)
|
||||
ikm = _hkdf(auth_secret, ecdh_secret, b"WebPush: info\x00" + ua_public + as_public, 32)
|
||||
salt = salt or os.urandom(16)
|
||||
cek = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
|
||||
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
|
||||
# One record, no padding: 0x02 marks the last (only) record.
|
||||
record = AESGCM(cek).encrypt(nonce, payload + b"\x02", None)
|
||||
header = salt + RECORD_SIZE.to_bytes(4, "big") + bytes([len(as_public)]) + as_public
|
||||
return header + record
|
||||
|
||||
|
||||
# ---- VAPID (RFC 8292) ---------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Vapid:
|
||||
"""The server's signing key pair."""
|
||||
|
||||
private: ec.EllipticCurvePrivateKey
|
||||
|
||||
@classmethod
|
||||
def generate(cls) -> Vapid:
|
||||
return cls(ec.generate_private_key(ec.SECP256R1()))
|
||||
|
||||
@classmethod
|
||||
def from_pem(cls, pem: bytes) -> Vapid:
|
||||
key = serialization.load_pem_private_key(pem, password=None)
|
||||
if not isinstance(key, ec.EllipticCurvePrivateKey):
|
||||
raise ValueError("VAPID key is not an EC key")
|
||||
return cls(key)
|
||||
|
||||
def pem(self) -> bytes:
|
||||
return self.private.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
|
||||
@property
|
||||
def public_b64(self) -> str:
|
||||
"""The public key as browsers take it (``applicationServerKey``)."""
|
||||
return b64u_encode(_public_bytes(self.private))
|
||||
|
||||
def authorization(self, endpoint: str, subject: str, *, ttl: int = 12 * 3600) -> str:
|
||||
"""The ``Authorization`` header for a push to ``endpoint``: a JWT (ES256)
|
||||
naming the push service's origin, with our public key."""
|
||||
parts = urlsplit(endpoint)
|
||||
claims = {
|
||||
"aud": f"{parts.scheme}://{parts.netloc}",
|
||||
"exp": int(time.time()) + ttl,
|
||||
"sub": subject,
|
||||
}
|
||||
header = {"typ": "JWT", "alg": "ES256"}
|
||||
signing_input = (
|
||||
b64u_encode(json.dumps(header, separators=(",", ":")).encode())
|
||||
+ "."
|
||||
+ b64u_encode(json.dumps(claims, separators=(",", ":")).encode())
|
||||
)
|
||||
der = self.private.sign(signing_input.encode(), ec.ECDSA(hashes.SHA256()))
|
||||
r, s = decode_dss_signature(der)
|
||||
signature = r.to_bytes(32, "big") + s.to_bytes(32, "big")
|
||||
return f"vapid t={signing_input}.{b64u_encode(signature)}, k={self.public_b64}"
|
||||
|
||||
|
||||
# ---- delivery (RFC 8030) ------------------------------------------------------------
|
||||
|
||||
|
||||
class Gone(Exception):
|
||||
"""The subscription no longer exists (404/410): forget it."""
|
||||
|
||||
|
||||
class Failed(Exception):
|
||||
"""The push service refused or could not be reached; worth trying later."""
|
||||
|
||||
|
||||
async def send(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
endpoint: str,
|
||||
p256dh: str,
|
||||
auth: str,
|
||||
payload: dict[str, object],
|
||||
vapid: Vapid,
|
||||
subject: str,
|
||||
ttl: int = 24 * 3600,
|
||||
urgency: str = "normal",
|
||||
topic: str | None = None,
|
||||
) -> None:
|
||||
"""Deliver one encrypted message; raise :class:`Gone` or :class:`Failed`."""
|
||||
if not allowed_endpoint(endpoint):
|
||||
raise Gone("not a push service")
|
||||
body = encrypt(json.dumps(payload, separators=(",", ":")).encode(), p256dh, auth)
|
||||
headers = {
|
||||
"Authorization": vapid.authorization(endpoint, subject),
|
||||
"Content-Encoding": "aes128gcm",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"TTL": str(ttl),
|
||||
"Urgency": urgency,
|
||||
}
|
||||
if topic:
|
||||
headers["Topic"] = topic # replaces an undelivered message with the same topic
|
||||
try:
|
||||
resp = await client.post(endpoint, content=body, headers=headers)
|
||||
except httpx.HTTPError as err:
|
||||
raise Failed(str(err)) from err
|
||||
if resp.status_code in (404, 410):
|
||||
raise Gone(f"HTTP {resp.status_code}")
|
||||
if resp.status_code >= 400:
|
||||
raise Failed(f"HTTP {resp.status_code}: {resp.text[:200]}")
|
||||
@@ -437,6 +437,12 @@ async def apply_delta(
|
||||
await session.execute(update(Mailbox).where(Mailbox.id == mailbox.id).values(**updates))
|
||||
|
||||
await _emit_batch(results, account, mailbox.path, len(results))
|
||||
if results and mailbox.special_use == "inbox":
|
||||
# New mail while live (backfills never come through here): tell the
|
||||
# user's devices, a few seconds later (app/push/service.py).
|
||||
from app.push.service import enqueue_new_mail
|
||||
|
||||
await enqueue_new_mail(account.user_id, [r.message.id for r in results])
|
||||
|
||||
if gone_ids:
|
||||
# Drop expunged messages from the search index (PG keeps its tsvector on the
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""push notifications about new mail (Web Push, RFC 8030/8291/8292)
|
||||
|
||||
``push_subscriptions`` holds one row per browser (or installed app) that asked
|
||||
to be told about new mail: the push service URL it gave, and the two keys the
|
||||
message is encrypted to. ``session_sid`` is the sign-in it was made under — once
|
||||
that session is gone (signed out, revoked, expired) nothing is sent there any
|
||||
more, so a signed-out device never shows a subject line again.
|
||||
|
||||
The server signs every push with its own key pair (VAPID). It is generated on
|
||||
first use and kept in ``app_settings``; the private half is vault-encrypted
|
||||
with MASTER_KEY like every other server secret.
|
||||
|
||||
Revision ID: 0022
|
||||
Revises: 0021
|
||||
Create Date: 2026-10-08
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0022"
|
||||
down_revision: str | None = "0021"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute(
|
||||
"""
|
||||
CREATE TABLE push_subscriptions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
endpoint text NOT NULL UNIQUE,
|
||||
p256dh text NOT NULL,
|
||||
auth text NOT NULL,
|
||||
session_sid text NOT NULL,
|
||||
user_agent text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
last_used_at timestamptz
|
||||
)
|
||||
"""
|
||||
)
|
||||
op.execute("CREATE INDEX ix_push_subscriptions_user ON push_subscriptions (user_id)")
|
||||
op.execute("ALTER TABLE app_settings ADD COLUMN vapid_public text NOT NULL DEFAULT ''")
|
||||
op.execute("ALTER TABLE app_settings ADD COLUMN vapid_private_ct bytea")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE app_settings DROP COLUMN IF EXISTS vapid_private_ct")
|
||||
op.execute("ALTER TABLE app_settings DROP COLUMN IF EXISTS vapid_public")
|
||||
op.execute("DROP TABLE IF EXISTS push_subscriptions")
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Push notifications about new mail: the API devices subscribe through, and the
|
||||
job that decides what to tell them — delivered to a mocked push service and
|
||||
decrypted the way the browser would."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
import pytest_asyncio
|
||||
import respx
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from httpx import ASGITransport
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
from test_webpush import browser_decrypt
|
||||
|
||||
from app.config import get_settings
|
||||
from app.identity.passwords import hash_password
|
||||
from app.identity.sessions import create_session, destroy_session
|
||||
from app.main import create_app
|
||||
from app.models import Account, AppSettings, Mailbox, Message, PushSubscription, User
|
||||
from app.push import service
|
||||
from app.push.webpush import b64u_encode
|
||||
|
||||
CSRF = "csrf"
|
||||
ENDPOINT = "https://fcm.googleapis.com/fcm/send/test-device"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Ctx:
|
||||
client: httpx.AsyncClient
|
||||
db: AsyncSession
|
||||
user_id: uuid.UUID
|
||||
token: str
|
||||
inbox: Mailbox
|
||||
archive: Mailbox
|
||||
ua: ec.EllipticCurvePrivateKey
|
||||
auth: bytes
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def ctx() -> AsyncIterator[Ctx]:
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
db = async_sessionmaker(engine, expire_on_commit=False)()
|
||||
# The server's key pair lives in the one app_settings row: put it back as it was.
|
||||
row = await db.get(AppSettings, 1)
|
||||
saved = (row.vapid_public, row.vapid_private_ct) if row else None
|
||||
user = User(handle=f"push{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"x",
|
||||
discovery_src="manual",
|
||||
state="live",
|
||||
)
|
||||
db.add(account)
|
||||
await db.flush()
|
||||
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
archive = Mailbox(account_id=account.id, user_id=user.id, path="Archive", special_use="archive")
|
||||
db.add_all([inbox, archive])
|
||||
await db.commit()
|
||||
token = await create_session(user.id, "pytest")
|
||||
client = httpx.AsyncClient(
|
||||
transport=ASGITransport(app=create_app()),
|
||||
base_url="http://testserver",
|
||||
cookies={"session": token, "csrf": CSRF},
|
||||
)
|
||||
try:
|
||||
yield Ctx(
|
||||
client,
|
||||
db,
|
||||
user.id,
|
||||
token,
|
||||
inbox,
|
||||
archive,
|
||||
ec.generate_private_key(ec.SECP256R1()),
|
||||
b"0123456789abcdef",
|
||||
)
|
||||
finally:
|
||||
await client.aclose()
|
||||
await db.execute(delete(User).where(User.id == user.id))
|
||||
if saved is not None:
|
||||
await db.execute(
|
||||
update(AppSettings)
|
||||
.where(AppSettings.id == 1)
|
||||
.values(vapid_public=saved[0], vapid_private_ct=saved[1])
|
||||
)
|
||||
await db.commit()
|
||||
await db.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
def _keys(c: Ctx) -> dict[str, str]:
|
||||
public = c.ua.public_key().public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
return {"p256dh": b64u_encode(public), "auth": b64u_encode(c.auth)}
|
||||
|
||||
|
||||
async def _subscribe(c: Ctx, endpoint: str = ENDPOINT) -> httpx.Response:
|
||||
return await c.client.post(
|
||||
"/api/push/subscriptions",
|
||||
json={"endpoint": endpoint, "keys": _keys(c)},
|
||||
headers={"X-CSRF": CSRF},
|
||||
)
|
||||
|
||||
|
||||
_uid = iter(range(1, 10_000))
|
||||
|
||||
|
||||
async def _mail(
|
||||
c: Ctx,
|
||||
*,
|
||||
subject: str = "Hello",
|
||||
sender: str = "alice@example.org",
|
||||
name: str = "Alice",
|
||||
box: Mailbox | None = None,
|
||||
flags: list[str] | None = None,
|
||||
age: dt.timedelta = dt.timedelta(minutes=1),
|
||||
) -> uuid.UUID:
|
||||
m = Message(
|
||||
user_id=c.user_id,
|
||||
account_id=c.inbox.account_id,
|
||||
mailbox_id=(box or c.inbox).id,
|
||||
uid=next(_uid),
|
||||
thread_id=None,
|
||||
subject=subject,
|
||||
from_addr=[{"name": name, "email": sender}],
|
||||
internal_date=dt.datetime.now(dt.UTC) - age,
|
||||
flags=flags or [],
|
||||
snippet=" First line of the message ",
|
||||
blob_sha256="0" * 64,
|
||||
)
|
||||
c.db.add(m)
|
||||
await c.db.commit()
|
||||
return m.id
|
||||
|
||||
|
||||
def _decrypted(c: Ctx, route: respx.Route) -> list[dict[str, object]]:
|
||||
return [json.loads(browser_decrypt(call.request.content, c.ua, c.auth)) for call in route.calls]
|
||||
|
||||
|
||||
async def test_subscribe_validates_and_hands_out_the_key(ctx: Ctx) -> None:
|
||||
key = await ctx.client.get("/api/push/key")
|
||||
assert key.status_code == 200 and len(key.json()["public_key"]) == 87
|
||||
assert (await ctx.client.get("/api/push/key")).json() == key.json() # made once
|
||||
|
||||
assert (await _subscribe(ctx, "https://169.254.169.254/latest")).status_code == 400
|
||||
bad = await ctx.client.post(
|
||||
"/api/push/subscriptions",
|
||||
json={"endpoint": ENDPOINT, "keys": {"p256dh": "AAAA", "auth": "AAAA"}},
|
||||
headers={"X-CSRF": CSRF},
|
||||
)
|
||||
assert bad.status_code == 400
|
||||
assert (await _subscribe(ctx)).status_code == 200
|
||||
assert (await _subscribe(ctx)).status_code == 200 # again: refreshed, not doubled
|
||||
rows = (
|
||||
await ctx.db.scalars(
|
||||
select(PushSubscription).where(PushSubscription.user_id == ctx.user_id)
|
||||
)
|
||||
).all()
|
||||
assert len(rows) == 1
|
||||
|
||||
gone = await ctx.client.post(
|
||||
"/api/push/subscriptions/remove", json={"endpoint": ENDPOINT}, headers={"X-CSRF": CSRF}
|
||||
)
|
||||
assert gone.status_code == 200
|
||||
assert (
|
||||
await ctx.db.scalar(select(PushSubscription).where(PushSubscription.endpoint == ENDPOINT))
|
||||
) is None
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_new_inbox_mail_is_pushed_encrypted_and_signed(ctx: Ctx) -> None:
|
||||
await _subscribe(ctx)
|
||||
route = respx.post(ENDPOINT).mock(return_value=httpx.Response(201))
|
||||
news = await _mail(ctx, subject="Lunch?")
|
||||
skipped = [
|
||||
await _mail(ctx, flags=["\\Seen"]), # read on another device already
|
||||
await _mail(ctx, box=ctx.archive), # filed away by a filter
|
||||
await _mail(ctx, age=dt.timedelta(days=1)), # old mail moved back to the Inbox
|
||||
await _mail(ctx, sender="ME@example.com"), # from yourself
|
||||
]
|
||||
assert await service.notify(ctx.user_id, [news, *skipped]) == 1
|
||||
request = route.calls[0].request
|
||||
assert request.headers["Content-Encoding"] == "aes128gcm"
|
||||
assert request.headers["Authorization"].startswith("vapid t=")
|
||||
[payload] = _decrypted(ctx, route)
|
||||
assert payload["title"] == "Alice"
|
||||
assert payload["body"] == "Lunch?\nFirst line of the message"
|
||||
assert payload["url"] == "/mail" and payload["type"] == "mail"
|
||||
assert payload["unread"] == 3 # Inbox unread: news, old, from-me
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_many_at_once_become_one_summary(ctx: Ctx) -> None:
|
||||
await _subscribe(ctx)
|
||||
route = respx.post(ENDPOINT).mock(return_value=httpx.Response(201))
|
||||
ids = [
|
||||
await _mail(ctx, name=n, sender=f"{n.lower()}@example.org")
|
||||
for n in ("Ann", "Bob", "Cy", "Dee", "Ann")
|
||||
]
|
||||
assert await service.notify(ctx.user_id, ids) == 1
|
||||
[payload] = _decrypted(ctx, route)
|
||||
assert payload["title"] == "5 new messages"
|
||||
assert payload["body"] == "Ann, Bob, Cy…"
|
||||
assert payload["tag"] == "new-mail"
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_signed_out_or_expired_devices_are_forgotten(ctx: Ctx) -> None:
|
||||
await _subscribe(ctx)
|
||||
other = "https://updates.push.services.mozilla.com/wpush/v2/other"
|
||||
await _subscribe(ctx, other)
|
||||
respx.post(ENDPOINT).mock(return_value=httpx.Response(410)) # browser unsubscribed
|
||||
firefox = respx.post(other).mock(return_value=httpx.Response(201))
|
||||
assert await service.notify(ctx.user_id, [await _mail(ctx)]) == 1
|
||||
left = (
|
||||
await ctx.db.scalars(
|
||||
select(PushSubscription.endpoint).where(PushSubscription.user_id == ctx.user_id)
|
||||
)
|
||||
).all()
|
||||
assert left == [other]
|
||||
|
||||
await destroy_session(ctx.token) # signed out: no subject lines on that device any more
|
||||
assert await service.notify(ctx.user_id, [await _mail(ctx)]) == 0
|
||||
assert firefox.call_count == 1
|
||||
assert (
|
||||
await ctx.db.scalar(select(PushSubscription).where(PushSubscription.user_id == ctx.user_id))
|
||||
) is None
|
||||
|
||||
|
||||
async def test_no_devices_no_work(ctx: Ctx) -> None:
|
||||
assert await service.notify(ctx.user_id, [await _mail(ctx)]) == 0
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Web Push protocol: RFC 8291 encryption (against the RFC's worked example and
|
||||
by decrypting as a browser would), VAPID signatures, the push-service allow-list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||
|
||||
from app.push import webpush
|
||||
from app.push.webpush import b64u_decode, b64u_encode
|
||||
|
||||
# RFC 8291 §5 / Appendix A.
|
||||
RFC_AUTH = "BTBZMqHH6r4Tts7J_aSIgg"
|
||||
RFC_UA_PUBLIC = (
|
||||
"BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4"
|
||||
)
|
||||
RFC_AS_PRIVATE = "yfWPiYE-n46HLnH0KqZOF1fJJU3MYrct3AELtAQ-oRw"
|
||||
RFC_SALT = "DGv6ra1nlYgDCS1FRnbzlw"
|
||||
RFC_BODY = (
|
||||
"DGv6ra1nlYgDCS1FRnbzlwAAEABBBP4z9KsN6nGRTbVYI_c7VJSPQTBtkgcy27ml"
|
||||
"mlMoZIIgDll6e3vCYLocInmYWAmS6TlzAC8wEqKK6PBru3jl7A_yl95bQpu6cVPT"
|
||||
"pK4Mqgkf1CXztLVBSt2Ks3oZwbuwXPXLWyouBWLVWGNWQexSgSxsj_Qulcy4a-fN"
|
||||
)
|
||||
|
||||
|
||||
def _hkdf(salt: bytes, ikm: bytes, info: bytes, n: int) -> bytes:
|
||||
return HKDF(algorithm=hashes.SHA256(), length=n, salt=salt, info=info).derive(ikm)
|
||||
|
||||
|
||||
def browser_decrypt(body: bytes, ua: ec.EllipticCurvePrivateKey, auth: bytes) -> bytes:
|
||||
"""What the browser does with a push body (RFC 8291 receiver side)."""
|
||||
salt, idlen = body[:16], body[20]
|
||||
as_public = body[21 : 21 + idlen]
|
||||
ua_public = ua.public_key().public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
as_key = ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), as_public)
|
||||
ikm = _hkdf(
|
||||
auth, ua.exchange(ec.ECDH(), as_key), b"WebPush: info\x00" + ua_public + as_public, 32
|
||||
)
|
||||
cek = _hkdf(salt, ikm, b"Content-Encoding: aes128gcm\x00", 16)
|
||||
nonce = _hkdf(salt, ikm, b"Content-Encoding: nonce\x00", 12)
|
||||
plain = AESGCM(cek).decrypt(nonce, body[21 + idlen :], None)
|
||||
assert plain.endswith(b"\x02")
|
||||
return plain[:-1]
|
||||
|
||||
|
||||
def test_rfc8291_worked_example() -> None:
|
||||
sender = ec.derive_private_key(
|
||||
int.from_bytes(b64u_decode(RFC_AS_PRIVATE), "big"), ec.SECP256R1()
|
||||
)
|
||||
body = webpush.encrypt(
|
||||
b"When I grow up, I want to be a watermelon",
|
||||
RFC_UA_PUBLIC,
|
||||
RFC_AUTH,
|
||||
salt=b64u_decode(RFC_SALT),
|
||||
sender=sender,
|
||||
)
|
||||
assert b64u_encode(body) == RFC_BODY
|
||||
|
||||
|
||||
def test_browser_can_decrypt() -> None:
|
||||
ua = ec.generate_private_key(ec.SECP256R1())
|
||||
auth = b"0123456789abcdef"
|
||||
p256dh = b64u_encode(
|
||||
ua.public_key().public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
)
|
||||
payload = json.dumps({"title": "Zażółć gęślą jaźń"}).encode()
|
||||
body = webpush.encrypt(payload, p256dh, b64u_encode(auth))
|
||||
assert browser_decrypt(body, ua, auth) == payload
|
||||
|
||||
|
||||
def test_vapid_authorization_is_a_valid_es256_jwt() -> None:
|
||||
key = webpush.Vapid.generate()
|
||||
again = webpush.Vapid.from_pem(key.pem())
|
||||
assert again.public_b64 == key.public_b64
|
||||
header = key.authorization("https://fcm.googleapis.com/fcm/send/abc", "https://ultimo.email")
|
||||
token = header.split("t=")[1].split(",")[0]
|
||||
assert header.endswith(f"k={key.public_b64}")
|
||||
head, claims, sig = token.split(".")
|
||||
assert json.loads(b64u_decode(claims)) | {"exp": 0} == {
|
||||
"aud": "https://fcm.googleapis.com",
|
||||
"sub": "https://ultimo.email",
|
||||
"exp": 0,
|
||||
}
|
||||
raw = b64u_decode(sig)
|
||||
der = encode_dss_signature(int.from_bytes(raw[:32], "big"), int.from_bytes(raw[32:], "big"))
|
||||
public = ec.EllipticCurvePublicKey.from_encoded_point(
|
||||
ec.SECP256R1(), b64u_decode(key.public_b64)
|
||||
)
|
||||
public.verify(der, f"{head}.{claims}".encode(), ec.ECDSA(hashes.SHA256())) # raises if bad
|
||||
|
||||
|
||||
def test_only_push_services_are_allowed() -> None:
|
||||
ok = [
|
||||
"https://fcm.googleapis.com/fcm/send/x",
|
||||
"https://jmt17.google.com/fcm/send/x",
|
||||
"https://updates.push.services.mozilla.com/wpush/v2/x",
|
||||
"https://web.push.apple.com/x",
|
||||
"https://wns2-db5p.notify.windows.com/w/?token=x",
|
||||
]
|
||||
bad = [
|
||||
"http://fcm.googleapis.com/fcm/send/x",
|
||||
"https://fcm.googleapis.com:8443/x",
|
||||
"https://evil.com/fcm.googleapis.com",
|
||||
"https://fcm.googleapis.com.evil.com/x",
|
||||
"https://google.com.evil.com/x",
|
||||
"https://notapple-push.apple.com.evil.com/x",
|
||||
"https://127.0.0.1/x",
|
||||
"https://postgres/x",
|
||||
]
|
||||
assert all(webpush.allowed_endpoint(u) for u in ok)
|
||||
assert not any(webpush.allowed_endpoint(u) for u in bad)
|
||||
@@ -317,3 +317,57 @@ async def test_server_side_flag_change_updates_the_thread_counters() -> None:
|
||||
await cleanup.execute(delete(User).where(User.id == user.id))
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def test_new_inbox_mail_while_live_asks_for_a_push(monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
"""New mail found by the live sync in an Inbox enqueues the new-mail push
|
||||
(app/push); new mail in any other folder does not."""
|
||||
from app.push import service as push_service
|
||||
|
||||
asked: list[tuple[uuid.UUID, list[uuid.UUID]]] = []
|
||||
|
||||
async def record(user_id: uuid.UUID, ids: list[uuid.UUID]) -> None:
|
||||
asked.append((user_id, ids))
|
||||
|
||||
monkeypatch.setattr(push_service, "enqueue_new_mail", record)
|
||||
engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
|
||||
session = async_sessionmaker(engine, expire_on_commit=False)()
|
||||
user = User(handle=f"psh{uuid.uuid4().hex[:8]}@test.local", password_hash=hash_password("pw"))
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
account = Account(
|
||||
user_id=user.id,
|
||||
email="me@example.com",
|
||||
imap_config={},
|
||||
smtp_config={},
|
||||
secret_ct=b"",
|
||||
discovery_src="manual",
|
||||
)
|
||||
session.add(account)
|
||||
await session.flush()
|
||||
inbox = Mailbox(account_id=account.id, user_id=user.id, path="INBOX", special_use="inbox")
|
||||
sent = Mailbox(account_id=account.id, user_id=user.id, path="SENT", special_use="sent")
|
||||
session.add_all([inbox, sent])
|
||||
await session.commit()
|
||||
|
||||
class FakeProvider:
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
def delta(uid: int, mid: str) -> MailboxDelta:
|
||||
msg = RemoteMessage(
|
||||
uid=uid, raw=_raw("Hi", mid), flags=frozenset(), internal_date=datetime.now(UTC)
|
||||
)
|
||||
return MailboxDelta([msg], [], [], None, None)
|
||||
|
||||
try:
|
||||
await sync_engine.apply_delta(FakeProvider(), account, sent, delta(3, "<p1@x.com>")) # type: ignore[arg-type]
|
||||
assert asked == []
|
||||
await sync_engine.apply_delta(FakeProvider(), account, inbox, delta(4, "<p2@x.com>")) # type: ignore[arg-type]
|
||||
new_id = await session.scalar(select(Message.id).where(Message.mailbox_id == inbox.id))
|
||||
assert asked == [(user.id, [new_id])]
|
||||
finally:
|
||||
async with session_scope() as cleanup:
|
||||
await cleanup.execute(delete(User).where(User.id == user.id))
|
||||
await session.close()
|
||||
await engine.dispose()
|
||||
|
||||
@@ -16,6 +16,7 @@ from arq import func
|
||||
|
||||
from app.outbox.draft_sync import push_draft_to_imap_job, remove_imap_draft_job
|
||||
from app.outbox.jobs import file_sent_copy_job, send_job
|
||||
from app.push.service import push_new_mail_job
|
||||
from app.sync.actions import (
|
||||
ACTION_MAX_TRIES,
|
||||
delete_thread_job,
|
||||
@@ -57,6 +58,7 @@ INTERACTIVE_FUNCTIONS: list[Any] = [
|
||||
push_draft_to_imap_job,
|
||||
remove_imap_draft_job,
|
||||
refresh_account_job,
|
||||
push_new_mail_job,
|
||||
]
|
||||
|
||||
# Bulk work (§6): long timeout, low concurrency, isolated from interactive jobs.
|
||||
|
||||
@@ -83,3 +83,12 @@ location = /sw.js {
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# The installable app's manifest (frontend/public/manifest.webmanifest):
|
||||
# re-checked on each load so a change of name or icons reaches installed apps.
|
||||
location = /manifest.webmanifest {
|
||||
default_type application/manifest+json;
|
||||
types { }
|
||||
add_header Cache-Control "no-cache";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -569,6 +569,33 @@ Session cookies get the `Secure` and `__Host-` treatment automatically as soon
|
||||
as the app sees an HTTPS request: nginx forwards `X-Forwarded-Proto`, and the
|
||||
API is started with `--proxy-headers`. Nothing to configure.
|
||||
|
||||
### 5.3 Installing as an app, and notifications about new mail
|
||||
|
||||
Over HTTPS the web app is installable (Chrome/Edge: the install icon in the
|
||||
address bar; Android: *Add to Home screen*; iPhone/iPad: *Share → Add to Home
|
||||
Screen*). Each user turns on **Settings → Accounts → Notifications** per
|
||||
device; new mail reaching an Inbox then shows a notification even with the app
|
||||
closed. Desktop Opera shows them too but has no "install app"; Opera on Android
|
||||
can install. On iPhone/iPad notifications work only from the Home Screen app
|
||||
(iOS 16.4+).
|
||||
|
||||
- The server signs notifications with a key pair it makes on first use, stored
|
||||
in the database (private half encrypted with `MASTER_KEY`). Nothing to set.
|
||||
- Set **Public URL** in the admin panel (e.g. `https://ultimo.email`): push
|
||||
services see it as the contact for our notifications. Without it they see
|
||||
`mailto:postmaster@APP_DOMAIN`.
|
||||
- The API container must reach the push services over HTTPS (port 443):
|
||||
`fcm.googleapis.com`, `*.google.com`, `*.push.services.mozilla.com`,
|
||||
`*.push.apple.com`, `*.notify.windows.com`.
|
||||
- The notification shows sender, subject and the first line; it travels
|
||||
end-to-end encrypted through the browser vendor's push service.
|
||||
|
||||
**Later: an Android app (TWA).** The installable web app is what a Trusted
|
||||
Web Activity wraps (e.g. with Bubblewrap from `/manifest.webmanifest`). It
|
||||
then also needs `https://ultimo.email/.well-known/assetlinks.json` naming the
|
||||
Android package and its signing-certificate fingerprint — served by nginx
|
||||
like any other static file.
|
||||
|
||||
---
|
||||
|
||||
## 6. Resource usage
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/ultimo-logo-signet.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<meta name="theme-color" content="#f3f4f6" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#1b1e20" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Ultimo" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title>Ultimo Email</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"id": "/",
|
||||
"name": "Ultimo Email",
|
||||
"short_name": "Ultimo",
|
||||
"description": "Your email, calendar and contacts.",
|
||||
"start_url": "/mail",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#f3f4f6",
|
||||
"categories": ["productivity", "business"],
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "/icon-maskable-192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" },
|
||||
{ "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
],
|
||||
"shortcuts": [
|
||||
{ "name": "Inbox", "url": "/mail", "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] },
|
||||
{ "name": "Calendar", "url": "/p/calendar", "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] }
|
||||
]
|
||||
}
|
||||
+103
-16
@@ -1,12 +1,18 @@
|
||||
// Offline access (Settings → Accounts → "Offline access").
|
||||
// The app's service worker: notifications about new mail (always), and offline
|
||||
// access (Settings → Accounts → "Offline access") on devices where it is on.
|
||||
//
|
||||
// Registered only on devices where the user turned it on. While the server
|
||||
// answers, everything comes from the network exactly as before; each answer is
|
||||
// also saved here. Only when the server cannot be reached is the saved copy
|
||||
// served instead — marked with `X-WM-Offline: 1`, which tells the page to show
|
||||
// that it is offline. Saved mail is the newest Inbox conversations (fetched by
|
||||
// the page, see lib/offlinePrefetch.ts) plus the ones opened recently, at most
|
||||
// MAX_THREADS; older ones are dropped.
|
||||
// Offline access is on when this worker was registered as /sw.js?offline=1
|
||||
// (lib/offline.ts); registered as plain /sw.js it leaves every request alone.
|
||||
// Turning the setting on or off re-registers under the other URL, which the
|
||||
// browser treats as an update, so the change takes effect at once.
|
||||
//
|
||||
// With offline access on: while the server answers, everything comes from the
|
||||
// network exactly as before; each answer is also saved here. Only when the
|
||||
// server cannot be reached is the saved copy served instead — marked with
|
||||
// `X-WM-Offline: 1`, which tells the page to show that it is offline. Saved
|
||||
// mail is the newest Inbox conversations (fetched by the page, see
|
||||
// lib/offlinePrefetch.ts) plus the ones opened recently, at most MAX_THREADS;
|
||||
// older ones are dropped.
|
||||
//
|
||||
// Saved mail is wiped when the server answers a request with 401 (signed out,
|
||||
// session expired or revoked), when another user signs in, and on request from
|
||||
@@ -18,7 +24,9 @@
|
||||
// What the page expects this worker to understand (lib/offlinePrefetch.ts
|
||||
// WORKER_VERSION): raise both together when the messages below change, so a
|
||||
// page never talks to an older worker that would not answer.
|
||||
const VERSION = 3;
|
||||
const VERSION = 4;
|
||||
|
||||
const OFFLINE = new URL(self.location.href).searchParams.get("offline") === "1";
|
||||
|
||||
const SHELL = "wm-shell-v1";
|
||||
const DATA = "wm-data-v1";
|
||||
@@ -413,6 +421,7 @@ function isAppPage(url) {
|
||||
}
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
if (!OFFLINE) return; // the browser does what it would without a worker
|
||||
const req = event.request;
|
||||
if (req.method !== "GET") return;
|
||||
const url = new URL(req.url);
|
||||
@@ -454,7 +463,9 @@ async function fetchForPage(url, port) {
|
||||
|
||||
self.addEventListener("message", (event) => {
|
||||
if (event.data?.type === "purge") event.waitUntil(purge());
|
||||
if (event.data?.type === "version" && event.ports[0]) event.ports[0].postMessage({ version: VERSION });
|
||||
if (event.data?.type === "version" && event.ports[0]) {
|
||||
event.ports[0].postMessage({ version: VERSION, offline: OFFLINE });
|
||||
}
|
||||
if (event.data?.type === "fetch" && event.ports[0]) {
|
||||
event.waitUntil(fetchForPage(event.data.url, event.ports[0]));
|
||||
}
|
||||
@@ -465,11 +476,13 @@ self.addEventListener("install", (event) => {
|
||||
// turned this on is never reloaded.
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/", { cache: "no-store" });
|
||||
if (res.ok) await saveApp(res);
|
||||
} catch {
|
||||
/* saved on the next visit */
|
||||
if (OFFLINE) {
|
||||
try {
|
||||
const res = await fetch("/", { cache: "no-store" });
|
||||
if (res.ok) await saveApp(res);
|
||||
} catch {
|
||||
/* saved on the next visit */
|
||||
}
|
||||
}
|
||||
await self.skipWaiting();
|
||||
})(),
|
||||
@@ -479,10 +492,84 @@ self.addEventListener("install", (event) => {
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
// Offline access off: nothing may stay saved on this device.
|
||||
for (const name of await caches.keys()) {
|
||||
if (name.startsWith("wm-") && name !== SHELL && name !== DATA) await caches.delete(name);
|
||||
if (name.startsWith("wm-") && (!OFFLINE || (name !== SHELL && name !== DATA))) await caches.delete(name);
|
||||
}
|
||||
await self.clients.claim();
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
// ---- notifications about new mail ---------------------------------------------------
|
||||
//
|
||||
// The server (app/push/service.py) sends { type: "mail", title, body, tag, url,
|
||||
// unread } encrypted to this browser. One notification per conversation (`tag`),
|
||||
// replaced when more arrives in it; the app icon's badge shows unread Inbox mail.
|
||||
|
||||
/** A window of the app the user is looking at right now. */
|
||||
async function lookingAtApp() {
|
||||
const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
return wins.some((w) => w.visibilityState === "visible" && w.focused);
|
||||
}
|
||||
|
||||
async function showMail(data) {
|
||||
if (typeof data.unread === "number" && self.navigator.setAppBadge) {
|
||||
await (data.unread ? self.navigator.setAppBadge(data.unread) : self.navigator.clearAppBadge()).catch(() => {});
|
||||
}
|
||||
// The app is open in front of the user: the new mail is already on screen.
|
||||
// Not on Apple's push service, which withdraws permission from a site whose
|
||||
// pushes show nothing.
|
||||
const sub = await self.registration.pushManager.getSubscription();
|
||||
const apple = Boolean(sub?.endpoint.includes(".push.apple.com"));
|
||||
if (!apple && (await lookingAtApp())) return;
|
||||
await self.registration.showNotification(data.title || "New mail", {
|
||||
body: data.body || "",
|
||||
tag: data.tag || "new-mail",
|
||||
renotify: true,
|
||||
icon: "/icon-192.png",
|
||||
badge: "/badge-96.png",
|
||||
timestamp: Date.now(),
|
||||
data: { url: data.url || "/mail" },
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let data = {};
|
||||
try {
|
||||
data = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
/* not ours to read: still say something, browsers require it */
|
||||
}
|
||||
event.waitUntil(showMail(data));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const url = new URL(event.notification.data?.url || "/mail", self.location.origin).href;
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
||||
// An open window of the app: bring it forward and let the app go there
|
||||
// itself (lib/push.ts), keeping whatever else it has open.
|
||||
const win = wins.find((w) => w.focused) ?? wins.find((w) => w.visibilityState === "visible") ?? wins[0];
|
||||
if (win) {
|
||||
await win.focus().catch(() => {});
|
||||
win.postMessage({ type: "open", url });
|
||||
return;
|
||||
}
|
||||
await self.clients.openWindow(url);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
// The push service replaced this browser's subscription (rare; Firefox does it
|
||||
// after long idle). Subscribe again with the same server key; the app sends the
|
||||
// new one to the server the next time it starts (lib/push.ts).
|
||||
self.addEventListener("pushsubscriptionchange", (event) => {
|
||||
const key = event.oldSubscription?.options?.applicationServerKey;
|
||||
if (!key) return;
|
||||
event.waitUntil(
|
||||
self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }).catch(() => {}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useEffect } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
|
||||
import { OfflineController } from "./components/Offline";
|
||||
import { PushController } from "./components/PushController";
|
||||
import { useT } from "./i18n";
|
||||
import { isOfflineError } from "./lib/api";
|
||||
import { useMe } from "./lib/auth";
|
||||
@@ -50,6 +51,7 @@ function RequireAuth({ children }: { children: JSX.Element }) {
|
||||
return (
|
||||
<>
|
||||
<OfflineController />
|
||||
<PushController />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { useT } from "../i18n";
|
||||
import { hideInstall, promptInstall, useInstallWay, type InstallWay } from "../lib/install";
|
||||
|
||||
const STEPS: Record<Exclude<InstallWay, "prompt" | null>, "install.stepsIos" | "install.stepsOpera" | "install.stepsFirefox"> = {
|
||||
ios: "install.stepsIos",
|
||||
opera: "install.stepsOpera",
|
||||
firefox: "install.stepsFirefox",
|
||||
};
|
||||
|
||||
function InstallGlyph() {
|
||||
// A phone with an arrow into it.
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width={20} height={20} fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||
<rect x="6" y="2.5" width="12" height="19" rx="2.5" />
|
||||
<path d="M12 7v7m-3-3 3 3 3-3M10.5 18.5h3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** "Install app" in the mobile drawer: the browser's own install dialog where
|
||||
* there is one, else how to add Ultimo to the Home Screen in this browser.
|
||||
* Hidden once installed, when opened as the app, or when the user hides it. */
|
||||
export function InstallAppEntry({ onDone }: { onDone?: () => void }) {
|
||||
const t = useT();
|
||||
const way = useInstallWay();
|
||||
const [steps, setSteps] = useState<Exclude<InstallWay, "prompt" | null> | null>(null);
|
||||
if (!way && !steps) return null;
|
||||
|
||||
const open = async () => {
|
||||
if (way === "prompt") {
|
||||
await promptInstall();
|
||||
onDone?.();
|
||||
} else if (way) {
|
||||
setSteps(way);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{way && (
|
||||
<button
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left text-app-accent hover:bg-app-hover"
|
||||
onClick={() => void open()}
|
||||
>
|
||||
<InstallGlyph />
|
||||
{t("install.entry")}
|
||||
</button>
|
||||
)}
|
||||
{steps &&
|
||||
createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 p-4 pb-[calc(1rem+env(safe-area-inset-bottom))] backdrop-blur-sm sm:items-center"
|
||||
onClick={() => setSteps(null)}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-label={t("install.title")}
|
||||
className="w-full max-w-sm rounded-2xl bg-app-elevated p-5 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-3">
|
||||
<img src="/icon-192.png" alt="" className="h-10 w-10" />
|
||||
<h2 className="text-base font-semibold">{t("install.title")}</h2>
|
||||
</div>
|
||||
<p className="whitespace-pre-line text-sm text-app-muted">{t(STEPS[steps])}</p>
|
||||
<div className="mt-5 flex items-center justify-between gap-3">
|
||||
<button
|
||||
className="text-sm text-app-faint hover:text-app-fg"
|
||||
onClick={() => {
|
||||
hideInstall();
|
||||
setSteps(null);
|
||||
onDone?.();
|
||||
}}
|
||||
>
|
||||
{t("install.hide")}
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-app-accent px-4 py-1.5 text-sm font-medium text-white"
|
||||
onClick={() => {
|
||||
setSteps(null);
|
||||
onDone?.();
|
||||
}}
|
||||
>
|
||||
{t("install.gotIt")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function OfflineController() {
|
||||
const current = useOfflineNotice();
|
||||
const wasOffline = useRef(false);
|
||||
|
||||
// Install (or remove) the service worker to match this device's setting.
|
||||
// Install the service worker, with offline access as this device has it set.
|
||||
useEffect(() => {
|
||||
void startOffline();
|
||||
}, [enabled]);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useEffect } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { closeThreadNotification, resyncPush } from "../lib/push";
|
||||
|
||||
/** Notifications about new mail, the page's side (signed-in pages only): keep
|
||||
* the server's copy of this device's subscription current, open what a clicked
|
||||
* notification points at, and close a conversation's notification once it is
|
||||
* open here. */
|
||||
export function PushController() {
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
void resyncPush();
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
// A notification was clicked while the app was open (public/sw.js).
|
||||
const onMessage = (e: MessageEvent) => {
|
||||
if (e.data?.type !== "open" || typeof e.data.url !== "string") return;
|
||||
const url = new URL(e.data.url, window.location.origin);
|
||||
if (url.origin === window.location.origin) navigate(url.pathname + url.search);
|
||||
};
|
||||
navigator.serviceWorker.addEventListener("message", onMessage);
|
||||
return () => navigator.serviceWorker.removeEventListener("message", onMessage);
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const m = pathname.match(/^\/mail\/t\/([0-9a-fA-F-]{36})/);
|
||||
if (m) void closeThreadNotification(m[1]).catch(() => {});
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -597,6 +597,25 @@ export const en = {
|
||||
"offline.settingSaving": "Saving recent mail…",
|
||||
"offline.settingUnsupported":
|
||||
"Not available here: browsers allow offline access only when the app is opened over HTTPS.",
|
||||
"push.settingTitle": "Notifications",
|
||||
"push.settingHelp":
|
||||
"Get a notification on this device when new mail arrives in an Inbox, even when Ultimo isn't open. Shows the sender and subject. Turned off when you sign out.",
|
||||
"push.needsInstall":
|
||||
"On iPhone and iPad, add Ultimo to your Home Screen first (Share → Add to Home Screen), then turn this on from the app.",
|
||||
"push.blocked":
|
||||
"Notifications are blocked for this site in your browser. Allow them in the browser's site settings, then turn this on.",
|
||||
"push.unsupported": "Not available here: this browser can't show notifications from a website, or the app isn't opened over HTTPS.",
|
||||
"push.notAllowed": "Notifications weren't allowed.",
|
||||
"push.failed": "Couldn't turn notifications on.",
|
||||
"install.entry": "Install app",
|
||||
"install.title": "Install Ultimo",
|
||||
"install.stepsIos":
|
||||
"1. Tap the Share button (the square with an arrow) in Safari's toolbar.\n2. Choose Add to Home Screen.\n3. Tap Add. Ultimo opens from your Home Screen like an app, and can show notifications about new mail.",
|
||||
"install.stepsOpera":
|
||||
"1. Open Opera's menu (⋮).\n2. Choose Add to → Home screen.\n\nOpera adds a shortcut to Ultimo on your Home Screen. To install Ultimo as a full app, open this page in Chrome and tap Install app there.",
|
||||
"install.stepsFirefox": "1. Open Firefox's menu (⋮).\n2. Choose Add app to Home screen (or Install).",
|
||||
"install.hide": "Don't show this again",
|
||||
"install.gotIt": "Got it",
|
||||
} as const;
|
||||
|
||||
export type MessageKey = keyof typeof en;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type QueryClient, useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, ApiError } from "./api";
|
||||
import { clearOfflineData } from "./offline";
|
||||
import { disablePush } from "./push";
|
||||
import { wipeLocal } from "./scheduled";
|
||||
|
||||
export function useMe() {
|
||||
@@ -28,6 +29,7 @@ export function signedOut(queryClient: QueryClient) {
|
||||
queryClient.setQueryData(["me"], null);
|
||||
void clearOfflineData(); // mail saved for offline reading goes with the session
|
||||
void wipeLocal(); // and so does anything written offline
|
||||
void disablePush(); // and notifications: a signed-out device shows no mail
|
||||
queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// "Install app" (the mobile drawer). Browsers that can install the web app
|
||||
// themselves (Chrome, Edge, Samsung Internet on Android) announce it with
|
||||
// `beforeinstallprompt`; we keep that event and show their own install dialog
|
||||
// on a click. The others only add a Home Screen shortcut from their own menu
|
||||
// (iPhone/iPad Safari: Share → Add to Home Screen; Opera, Firefox: the browser
|
||||
// menu) — for those the entry says how. Imported by main.tsx before the app
|
||||
// renders: the event can come before React has mounted anything.
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
interface InstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;
|
||||
}
|
||||
|
||||
/** How this browser installs the app, or null when there is nothing to offer. */
|
||||
export type InstallWay =
|
||||
| "prompt" // the browser's own install dialog
|
||||
| "ios" // Share → Add to Home Screen
|
||||
| "opera" // Opera menu → Add to → Home screen
|
||||
| "firefox" // Firefox menu → Add app to Home screen
|
||||
| null;
|
||||
|
||||
const HIDDEN_KEY = "install.hidden";
|
||||
|
||||
let deferred: InstallPromptEvent | null = null;
|
||||
let installed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
let version = 0;
|
||||
|
||||
function changed() {
|
||||
version++;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
window.addEventListener("beforeinstallprompt", (e) => {
|
||||
e.preventDefault(); // no mini-infobar: the drawer entry offers it instead
|
||||
deferred = e as InstallPromptEvent;
|
||||
changed();
|
||||
});
|
||||
|
||||
window.addEventListener("appinstalled", () => {
|
||||
deferred = null;
|
||||
installed = true;
|
||||
changed();
|
||||
});
|
||||
|
||||
/** Opened as the installed app (or from a Home Screen shortcut). */
|
||||
export function isStandalone(): boolean {
|
||||
return (
|
||||
window.matchMedia?.("(display-mode: standalone)").matches ||
|
||||
window.matchMedia?.("(display-mode: fullscreen)").matches ||
|
||||
(navigator as { standalone?: boolean }).standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
function hidden(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(HIDDEN_KEY) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function hideInstall(): void {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_KEY, "1");
|
||||
} catch {
|
||||
/* shown again next time: harmless */
|
||||
}
|
||||
changed();
|
||||
}
|
||||
|
||||
export function installWay(): InstallWay {
|
||||
if (installed || isStandalone() || hidden()) return null;
|
||||
if (deferred) return "prompt";
|
||||
const ua = navigator.userAgent;
|
||||
const iOS = /iPhone|iPad|iPod/.test(ua) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
if (iOS) return "ios";
|
||||
if (/\bOPR\/|\bOPT\//.test(ua)) return "opera";
|
||||
if (/Firefox\//.test(ua) && /Android/.test(ua)) return "firefox";
|
||||
// Chrome without the event: already installed, or not installable here (no
|
||||
// HTTPS) — nothing worth offering.
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useInstallWay(): InstallWay {
|
||||
useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => version,
|
||||
);
|
||||
return installWay();
|
||||
}
|
||||
|
||||
/** Show the browser's own install dialog; true when the user installed. */
|
||||
export async function promptInstall(): Promise<boolean> {
|
||||
const event = deferred;
|
||||
if (!event) return false;
|
||||
deferred = null; // each event can show its dialog once
|
||||
await event.prompt();
|
||||
const { outcome } = await event.userChoice;
|
||||
if (outcome === "accepted") installed = true;
|
||||
changed();
|
||||
return outcome === "accepted";
|
||||
}
|
||||
+32
-22
@@ -133,33 +133,43 @@ export function setLastSync(ms: number): void {
|
||||
emitSetting();
|
||||
}
|
||||
|
||||
/** Install the service worker, if this device keeps mail (call on app start). */
|
||||
export async function startOffline(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator)) return;
|
||||
if (offlineEnabled() && offlineSupported()) {
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register("/sw.js", { scope: "/" });
|
||||
// register() does not look for a newer sw.js when one is installed
|
||||
// already; ask explicitly, so a release reaches this device on its next start.
|
||||
void reg.update().catch(() => {});
|
||||
} catch (err) {
|
||||
console.warn("offline access: service worker not installed", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Turned off (perhaps in another tab): make sure nothing is left behind.
|
||||
const regs = await navigator.serviceWorker.getRegistrations();
|
||||
if (regs.length) await disableOffline();
|
||||
/** The service worker's URL: its offline access is on only under `?offline=1`. */
|
||||
function workerUrl(): string {
|
||||
return offlineEnabled() ? "/sw.js?offline=1" : "/sw.js";
|
||||
}
|
||||
|
||||
/** Whether the service worker is installed and running (it does the saving). */
|
||||
/**
|
||||
* Install the app's service worker (call on app start, and when the setting
|
||||
* changes). Always installed where the browser allows it — it shows
|
||||
* notifications about new mail and makes the app installable — and with offline
|
||||
* access only on devices that keep mail. Registering under the other URL is an
|
||||
* update the browser applies at once (the worker skips waiting).
|
||||
*/
|
||||
export async function startOffline(): Promise<void> {
|
||||
if (!("serviceWorker" in navigator) || !window.isSecureContext) return;
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.register(workerUrl(), { scope: "/" });
|
||||
// register() does not look for a newer sw.js when one is installed
|
||||
// already; ask explicitly, so a release reaches this device on its next start.
|
||||
void reg.update().catch(() => {});
|
||||
} catch (err) {
|
||||
console.warn("service worker not installed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the service worker that saves mail is installed and running. */
|
||||
export async function offlineReady(): Promise<boolean> {
|
||||
if (!("serviceWorker" in navigator) || !offlineEnabled()) return false;
|
||||
// Not `controller`: a page loaded with a hard reload is never controlled,
|
||||
// yet the worker is there and can fetch and save on its behalf.
|
||||
const deadline = Date.now() + 10_000;
|
||||
const timeout = new Promise<null>((resolve) => window.setTimeout(() => resolve(null), 10_000));
|
||||
const reg = await Promise.race([navigator.serviceWorker.ready, timeout]);
|
||||
return Boolean(reg?.active);
|
||||
// Just turned on: the worker without offline access may still be the active one.
|
||||
while (reg?.active && !reg.active.scriptURL.includes("offline=1") && Date.now() < deadline) {
|
||||
await new Promise((r) => window.setTimeout(r, 200));
|
||||
}
|
||||
return Boolean(reg?.active?.scriptURL.includes("offline=1"));
|
||||
}
|
||||
|
||||
export async function enableOffline(): Promise<void> {
|
||||
@@ -176,9 +186,9 @@ export async function disableOffline(): Promise<void> {
|
||||
/* ignore */
|
||||
}
|
||||
emitSetting();
|
||||
if ("serviceWorker" in navigator) {
|
||||
for (const reg of await navigator.serviceWorker.getRegistrations()) await reg.unregister();
|
||||
}
|
||||
// The worker stays (notifications), without offline access; it clears what
|
||||
// it saved when it takes over, and so does this, in case it is slow to.
|
||||
await startOffline();
|
||||
if ("caches" in window) {
|
||||
for (const name of await caches.keys()) if (name.startsWith("wm-")) await caches.delete(name);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const COUNT = 50; // the list's page size, so the first page is saved as shown
|
||||
const PARALLEL = 3;
|
||||
const TIMEOUT_MS = 30_000;
|
||||
// The message protocol this page speaks with public/sw.js (its VERSION).
|
||||
const WORKER_VERSION = 3;
|
||||
const WORKER_VERSION = 4;
|
||||
const CID_RE = /\/api\/messages\/[0-9a-fA-F-]{36}\/cid\/[^"'\s)&]+/g;
|
||||
|
||||
// What the app asks for when it starts: without these it cannot open offline.
|
||||
@@ -51,8 +51,13 @@ function ask<T>(worker: ServiceWorker, message: object, ms: number): Promise<T>
|
||||
async function upToDate(worker: ServiceWorker | null): Promise<boolean> {
|
||||
if (!worker) return false;
|
||||
try {
|
||||
const { version } = await ask<{ version?: number }>(worker, { type: "version" }, 2000);
|
||||
return (version ?? 0) >= WORKER_VERSION;
|
||||
const { version, offline } = await ask<{ version?: number; offline?: boolean }>(
|
||||
worker,
|
||||
{ type: "version" },
|
||||
2000,
|
||||
);
|
||||
// A worker without offline access (the setting was just turned on) saves nothing.
|
||||
return (version ?? 0) >= WORKER_VERSION && offline === true;
|
||||
} catch {
|
||||
return false; // too old to know the question
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// Notifications about new mail on this device (Settings → Accounts →
|
||||
// "Notifications"). The browser subscribes to its push service with the
|
||||
// server's key; the server (app/push) sends there when new mail reaches an
|
||||
// Inbox, and the service worker (public/sw.js) shows it. Per device, like
|
||||
// offline access: the subscription itself is the setting.
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from "react";
|
||||
|
||||
import { apiFetch } from "./api";
|
||||
|
||||
export type PushState =
|
||||
| "unsupported" // no service worker / Push API (or not HTTPS)
|
||||
| "needs-install" // iPhone / iPad: only an app added to the Home Screen may notify
|
||||
| "blocked" // the user said no in the browser's prompt (only the browser can undo it)
|
||||
| "off"
|
||||
| "on";
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
let version = 0;
|
||||
|
||||
function changed() {
|
||||
version++;
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
function isAppleMobile(): boolean {
|
||||
return /iPhone|iPad|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
function standalone(): boolean {
|
||||
return window.matchMedia?.("(display-mode: standalone)").matches || (navigator as { standalone?: boolean }).standalone === true;
|
||||
}
|
||||
|
||||
function supported(): boolean {
|
||||
return window.isSecureContext && "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
|
||||
}
|
||||
|
||||
async function registration(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
const timeout = new Promise<null>((resolve) => window.setTimeout(() => resolve(null), 10_000));
|
||||
return Promise.race([navigator.serviceWorker.ready, timeout]);
|
||||
}
|
||||
|
||||
async function current(): Promise<PushSubscription | null> {
|
||||
const reg = await registration();
|
||||
return reg ? reg.pushManager.getSubscription() : null;
|
||||
}
|
||||
|
||||
export async function pushState(): Promise<PushState> {
|
||||
if (!supported()) return isAppleMobile() && !standalone() ? "needs-install" : "unsupported";
|
||||
if (Notification.permission === "denied") return "blocked";
|
||||
return (await current()) && Notification.permission === "granted" ? "on" : "off";
|
||||
}
|
||||
|
||||
/** This device's notification state, kept up to date. */
|
||||
export function usePushState(): PushState | null {
|
||||
const v = useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => version,
|
||||
);
|
||||
const [state, setState] = useState<PushState | null>(null);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
void pushState().then((s) => live && setState(s));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [v]);
|
||||
return state;
|
||||
}
|
||||
|
||||
function b64uToBytes(value: string): Uint8Array {
|
||||
const b64 = (value + "=".repeat((4 - (value.length % 4)) % 4)).replace(/-/g, "+").replace(/_/g, "/");
|
||||
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function tellServer(sub: PushSubscription): Promise<void> {
|
||||
const { endpoint, keys } = sub.toJSON();
|
||||
await apiFetch("/push/subscriptions", { method: "POST", body: JSON.stringify({ endpoint, keys }) });
|
||||
}
|
||||
|
||||
/** Turn notifications on (must run from a click: browsers ask permission only then). */
|
||||
export async function enablePush(): Promise<PushState> {
|
||||
try {
|
||||
if (!supported()) return pushState();
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") return pushState();
|
||||
const reg = await registration();
|
||||
if (!reg) return "unsupported";
|
||||
const { public_key } = await apiFetch<{ public_key: string }>("/push/key");
|
||||
let sub = await reg.pushManager.getSubscription();
|
||||
// Subscribed with another server key (the server's key was replaced): start over.
|
||||
const key = sub?.options.applicationServerKey;
|
||||
if (sub && key && b64uToBytes(public_key).join() !== new Uint8Array(key).join()) {
|
||||
await sub.unsubscribe();
|
||||
sub = null;
|
||||
}
|
||||
sub ??= await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: b64uToBytes(public_key) });
|
||||
await tellServer(sub);
|
||||
return pushState();
|
||||
} finally {
|
||||
changed();
|
||||
}
|
||||
}
|
||||
|
||||
/** Turn notifications off on this device (also on sign-out). */
|
||||
export async function disablePush(): Promise<void> {
|
||||
try {
|
||||
const sub = await current();
|
||||
if (!sub) return;
|
||||
const endpoint = sub.endpoint;
|
||||
await sub.unsubscribe().catch(() => {});
|
||||
// The server forgets it too; if it cannot be told now, it finds out on its
|
||||
// next push (the push service answers "gone").
|
||||
await apiFetch("/push/subscriptions/remove", { method: "POST", body: JSON.stringify({ endpoint }) }).catch(
|
||||
() => {},
|
||||
);
|
||||
void (navigator as { clearAppBadge?: () => Promise<void> }).clearAppBadge?.().catch(() => {});
|
||||
} finally {
|
||||
changed();
|
||||
}
|
||||
}
|
||||
|
||||
/** On app start: make sure the server still has this device's subscription
|
||||
* (it may have been renewed by the browser, or dropped by the server). */
|
||||
export async function resyncPush(): Promise<void> {
|
||||
if (!supported() || Notification.permission !== "granted") return;
|
||||
const sub = await current();
|
||||
if (sub) await tellServer(sub).catch(() => {});
|
||||
}
|
||||
|
||||
/** Show the unread Inbox count on the installed app's icon. */
|
||||
export function setAppBadge(count: number): void {
|
||||
const nav = navigator as { setAppBadge?: (n: number) => Promise<void>; clearAppBadge?: () => Promise<void> };
|
||||
void (count ? nav.setAppBadge?.(count) : nav.clearAppBadge?.())?.catch(() => {});
|
||||
}
|
||||
|
||||
/** Close the notification about a conversation once it is open in the app. */
|
||||
export async function closeThreadNotification(threadId: string): Promise<void> {
|
||||
const reg = await navigator.serviceWorker?.getRegistration();
|
||||
for (const n of (await reg?.getNotifications({ tag: `thread-${threadId}` })) ?? []) n.close();
|
||||
}
|
||||
@@ -14,8 +14,15 @@ function systemPrefersDark(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
// The installed app's title bar / phone status bar: the app's own background
|
||||
// (--app-bg), following the chosen theme rather than the system's.
|
||||
const BAR_COLOR = { light: "#f3f4f6", dark: "#1b1e20" };
|
||||
|
||||
function apply(resolved: "light" | "dark") {
|
||||
document.documentElement.classList.toggle("dark", resolved === "dark");
|
||||
for (const meta of document.querySelectorAll('meta[name="theme-color"]')) {
|
||||
meta.setAttribute("content", BAR_COLOR[resolved]);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { I18nProvider } from "./i18n";
|
||||
import { isSessionGone, signedOut } from "./lib/auth";
|
||||
import "./lib/install"; // catches the install event, which can come before the app renders
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import "./index.css";
|
||||
import "./plugins/plugin-ui.css"; // .wm-* classes for plugin bundles (SPEC §11.3)
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from "../components/compose/windows";
|
||||
import { DraftsList, ScheduledList } from "../components/list/DraftsList";
|
||||
import { Icon } from "../components/Icon";
|
||||
import { InstallAppEntry } from "../components/InstallApp";
|
||||
import { MessageList } from "../components/list/MessageList";
|
||||
import { Resizer } from "../components/Resizer";
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
@@ -34,6 +35,7 @@ import { useT } from "../i18n";
|
||||
import type { MessageKey } from "../i18n/en";
|
||||
import { api, isOfflineError } from "../lib/api";
|
||||
import { clearOfflineData, useOffline } from "../lib/offline";
|
||||
import { disablePush, setAppBadge } from "../lib/push";
|
||||
import { flushLocal, useLocalMessages, wipeLocal } from "../lib/scheduled";
|
||||
import { formatClock, getDateLocale, setTimeFormat } from "../lib/timeFormat";
|
||||
import type { AccountOut, QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
|
||||
@@ -283,6 +285,11 @@ export function MailLayout() {
|
||||
}, [timeFormat]);
|
||||
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
|
||||
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
|
||||
// The installed app's icon shows unread Inbox mail (notifications set it too).
|
||||
const inboxUnread = countsQuery.data?.inbox_unread;
|
||||
useEffect(() => {
|
||||
if (inboxUnread !== undefined) setAppBadge(inboxUnread);
|
||||
}, [inboxUnread]);
|
||||
|
||||
// Folder/inbox views paginate server-side with an accurate grand total; search
|
||||
// results (a single fetched page of hits) still paginate client-side.
|
||||
@@ -985,6 +992,7 @@ export function MailLayout() {
|
||||
<Icon name="settings" size={22} />
|
||||
{!collapsed && t("nav.settings")}
|
||||
</Link>
|
||||
{mobile && <InstallAppEntry onDone={() => setDrawerOpen(false)} />}
|
||||
<button
|
||||
className={`wm-online-only flex w-full items-center gap-2.5 rounded-lg py-2 text-left text-app-muted hover:bg-app-hover hover:text-app-fg ${
|
||||
collapsed ? "justify-center px-0" : "px-3"
|
||||
@@ -994,6 +1002,7 @@ export function MailLayout() {
|
||||
// Anything still waiting in Scheduled goes out first: what is
|
||||
// kept on this device is wiped with the session.
|
||||
await flushLocal().catch(() => []);
|
||||
await disablePush(); // while still signed in, so the server forgets this device
|
||||
await api.logout();
|
||||
await clearOfflineData();
|
||||
await wipeLocal();
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
useOfflineEnabled,
|
||||
} from "../../lib/offline";
|
||||
import { prefetchRecent } from "../../lib/offlinePrefetch";
|
||||
import { disablePush, enablePush, usePushState } from "../../lib/push";
|
||||
import { useTheme } from "../../lib/theme";
|
||||
import { formatClock, getDateLocale, setTimeFormat } from "../../lib/timeFormat";
|
||||
import { PluginSlot } from "../../plugins/PluginSlot";
|
||||
@@ -235,6 +236,66 @@ function formatMB(bytes: number): string {
|
||||
return bytes < 1024 * 1024 ? `${Math.max(1, Math.round(bytes / 1024))} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/** Settings → Accounts: notifications about new mail on this device. */
|
||||
function NotificationsSetting() {
|
||||
const t = useT();
|
||||
const state = usePushState();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const usable = state === "on" || state === "off";
|
||||
|
||||
const toggle = async (on: boolean) => {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (on) {
|
||||
const result = await enablePush();
|
||||
if (result === "off") setError(t("push.notAllowed"));
|
||||
} else {
|
||||
await disablePush();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : t("push.failed"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-lg font-semibold">{t("push.settingTitle")}</h2>
|
||||
<div className="flex gap-1 rounded-lg border border-app-border p-0.5 text-sm">
|
||||
{([true, false] as const).map((value) => (
|
||||
<button
|
||||
key={String(value)}
|
||||
disabled={!usable || busy}
|
||||
onClick={() => void toggle(value)}
|
||||
className={`rounded px-3 py-1 disabled:opacity-50 ${
|
||||
(state === "on") === value && usable
|
||||
? "bg-app-active font-medium text-app-fg"
|
||||
: "text-app-muted hover:bg-app-hover"
|
||||
}`}
|
||||
>
|
||||
{t(value ? "settings.on" : "settings.off")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-app-muted">{t("push.settingHelp")}</p>
|
||||
{state === "needs-install" ? (
|
||||
<p className="text-xs text-app-faint">{t("push.needsInstall")}</p>
|
||||
) : state === "blocked" ? (
|
||||
<p className="text-xs text-app-faint">{t("push.blocked")}</p>
|
||||
) : state === "unsupported" ? (
|
||||
<p className="text-xs text-app-faint">{t("push.unsupported")}</p>
|
||||
) : error ? (
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings → Accounts: keep recent mail on this device for reading offline. */
|
||||
function OfflineAccessSetting() {
|
||||
const t = useT();
|
||||
@@ -563,6 +624,9 @@ function AccountsSection() {
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="border-t border-app-border pt-5">
|
||||
<NotificationsSetting />
|
||||
</div>
|
||||
<div className="border-t border-app-border pt-5">
|
||||
<OfflineAccessSetting />
|
||||
</div>
|
||||
|
||||
@@ -559,8 +559,9 @@ Phases A1–A2: local calendars + full page views + iMIP invite handling; then A
|
||||
Phases B1–B2: contact books, full page, groups, add-from-mail, `recipients.suggest` capability, vCard import/export; then B3: CardDAV client sync. May be built in parallel with M4 — both depend only on the M3 SDK.
|
||||
✅ Accept: criteria in Appendix B §B.7.
|
||||
|
||||
**M6 (optional) — Hosted mailboxes**
|
||||
Inbound MTA (Stalwart) delivering to a `NativeProvider`; handle becomes receiving address. Specified separately when reached.
|
||||
**M6 (optional) — Hosted mailboxes (Appendix D)**
|
||||
A Stalwart mail server beside the app: every user gets a mailbox at their handle (`localpart@APP_DOMAIN`) with the same password, added to their webmail as a permanent (non-removable) account; webmail stops keeping its own full copy of that mail. Phases D0 (hosting decision) → D1 (attachment de-duplication, useful on its own) → D2 (Stalwart spike) → D3 (native account) → D4 (on-demand bodies) → D5 (security) → D6 (production).
|
||||
✅ Accept: criteria in Appendix D §D.9. **Status: planned — awaiting the D0 decisions (§D.10).**
|
||||
|
||||
**M7 — Anti-spam plugin (Appendix C)**
|
||||
May be built any time after M3 (depends only on the plugin SDK; parallelizable with M4/M5). Phases C1–C2 client-side; C3 activates at the M6 edge.
|
||||
@@ -829,5 +830,87 @@ At the hosted-mail phase the VPS edge (Stalwart's built-in filter or Postfix+Rsp
|
||||
5. Deny-listed sender is moved without engine involvement; allow-listed sender bypasses even a high engine score.
|
||||
6. Privacy: with `network_checks=false` (default), a network-capture test during classification shows zero outbound connections from Rspamd.
|
||||
|
||||
## Appendix D — Hosted mailboxes (M6, Stalwart)
|
||||
|
||||
Every webmail user gets a real mailbox on a **Stalwart** server running beside the app. The address is the user's handle (`localpart@APP_DOMAIN`, already mail-grade per §4), and the password is the webmail password. That mailbox appears in webmail as an account the user cannot remove. Because Stalwart holds the mail, webmail stops keeping its own full copy of it (§D.6).
|
||||
|
||||
### D.1 Hosting (decide first — this, not code, is the hard part)
|
||||
Receiving needs inbound port 25, an MX record and a fixed address; many home ISPs block port 25. Sending from a home IP is commonly spam-foldered or refused, and needs reverse DNS (PTR), SPF, DKIM and DMARC.
|
||||
|
||||
| Option | Inbound | Outbound | Notes |
|
||||
|---|---|---|---|
|
||||
| **A. All on the NAS** | MX → home IP, port 25 forwarded | direct | Only with a static IP, settable PTR, unblocked port 25. |
|
||||
| **B. NAS + relay (recommended)** | Small VPS holds the MX, forwards to the NAS over WireGuard | Relay service (SMTP2GO / Postmark / SES) or the VPS | Mail stays on the NAS; reliable delivery; the VPS is the SMTP-time spam edge (§C.7). |
|
||||
| **C. Stalwart on a VPS** | VPS | VPS | Mail stored off the NAS; webmail reaches it over the internet. |
|
||||
|
||||
### D.2 Infrastructure
|
||||
- `stalwart` service in both compose stacks (dev `webmail`, prod `webmail-prod` — never share the project name), image pinned to an exact version.
|
||||
- **Stores:** data store = a separate `stalwart` database on the existing Postgres; blob store = filesystem under `DATA_ROOT/stalwart` (bind mount, not a named volume). `backup.sh` and `webmail.sh update` (backup → migrate → roll → health-gate) then cover it; no RocksDB to snapshot separately.
|
||||
- **TLS:** Stalwart's built-in ACME with DNS-01 (nginx owns 80/443), or the existing certificates mounted read-only.
|
||||
- **Spam:** SMTP-time filtering at the edge (Stalwart's filter or Rspamd) tags an edge-score header; the anti-spam plugin trusts it for hosted accounts (§C.7). The existing Rspamd keeps per-user learning.
|
||||
|
||||
### D.3 Same username and password — no password copying
|
||||
Stalwart uses a **SQL directory** reading a read-only view over `users` (dedicated read-only role):
|
||||
|
||||
```sql
|
||||
CREATE VIEW mail_principals AS
|
||||
SELECT handle AS name, handle AS email, password_hash AS secret,
|
||||
quota_bytes AS quota, (blocked_at IS NOT NULL) AS disabled
|
||||
FROM users;
|
||||
```
|
||||
|
||||
- Stalwart verifies argon2id PHC hashes, which is what `identity/passwords.py` produces, so registration, password change, reset, block and quota apply **instantly** with no provisioning or sync step.
|
||||
- User deletion additionally purges the mailbox through Stalwart's management API.
|
||||
- Webmail never stores the user's password: the worker signs in as a **master user** (`handle%master`, secret in `.env` as `STALWART_MASTER_SECRET`), not via `accounts.secret_ct`. *Verify the master-user feature and syntax in the pinned version during D2.*
|
||||
- Reserved handles (`postmaster`, `abuse`, …) stay unregistrable; route them to the administrator.
|
||||
|
||||
### D.4 The permanent account in webmail
|
||||
- Migration: `accounts.kind text NOT NULL DEFAULT 'external'` (`external` | `native`). The native account is created in `register_user`; a one-off command creates it for users that already exist.
|
||||
- `DELETE /api/accounts/{id}` on a native account → `409 {error:{code:"native_account"}}`. UI hides Remove and the server-settings fields; signature, colour and display name stay editable.
|
||||
- `provider_factory`: native accounts connect to `stalwart:993` / `stalwart:465` on the Docker network with the master credentials.
|
||||
- **Sync:** first reuse the IMAP provider unchanged (IDLE + QRESYNC already work). Optional later: a JMAP provider with push (Stalwart's native protocol).
|
||||
- Sending goes through Stalwart; Sent copy, retries and the Scheduled folder behave as for any account.
|
||||
|
||||
### D.5 External mail clients and 2FA
|
||||
If phones/desktop clients may use the mailbox over IMAP/SMTP, a password alone would bypass TOTP. Rule: users with 2FA enabled must use **app passwords** for IMAP/SMTP — a `user_app_passwords` table (argon2id hashes) added to the directory view, plus a Settings screen to create/revoke them. Stalwart's login rate limits / auto-ban on. If external clients are not wanted, IMAP/SMTP submission is exposed only on the Docker network and this section shrinks to nothing.
|
||||
|
||||
### D.6 Storing mail once
|
||||
Today the indexer keeps every message in full (raw `.eml` blob + sanitized body in `body_parts` + search index), so external accounts are already a second copy, and with Stalwart both copies would sit on the same disk. Ideas, best first:
|
||||
|
||||
1. **Attachments are stored twice inside webmail today.** `indexer.py` stores each attachment as its own blob *and* it is inside the raw blob (base64), ≈2.3× its size; attachments are most of mail's bytes. Fix: keep only the raw message, extract an attachment when downloaded, keep thumbnails cached. Roughly halves storage for **all** accounts, independent of Stalwart. A cleanup script frees existing duplicates.
|
||||
2. **Native accounts: no raw copy in webmail.** Stalwart is the master copy; webmail keeps metadata, sanitized body, snippet and search index (~5–10% of the size). Full message, attachments and "show original" are fetched on demand over the local network (IMAP `BODY[part]` or JMAP blob download), with a small cache for repeat opens and thumbnails.
|
||||
3. **External accounts (optional setting): age out old raw copies.** Keep raw blobs for N days or the newest X GB; refetch older ones over IMAP when needed. Readable bodies stay local; old attachments then need the external server reachable.
|
||||
4. **Filesystem:** at least lz4 compression on the ZFS data share (QuTS hero). ZFS dedup would merge identical copies but costs a lot of RAM and only hits uncompressed byte-identical data — last resort.
|
||||
5. **Rejected — MTA-only Stalwart** delivering straight into webmail with no IMAP mailbox: zero duplication, but phones/Thunderbird lose access unless webmail implements IMAP. Items 1 + 2 give nearly the same saving.
|
||||
|
||||
**Recommendation:** item 1 now, item 2 together with Stalwart, item 3 later as a setting.
|
||||
|
||||
### D.7 Phases
|
||||
- **D0 — Decisions:** hosting option (§D.1), domain, whether external IMAP clients are allowed (§D.5).
|
||||
- **D1 — Attachment de-duplication** (§D.6 item 1): independent, can ship any time.
|
||||
- **D2 — Spike (dev):** Stalwart container, Postgres stores, SQL-directory view, master-user login, IMAP sync of a test user.
|
||||
- **D3 — Native account:** `accounts.kind`, creation at registration + for existing users, undeletable in API and UI, provider wiring, delete/block hooks; tests against a Stalwart container (may replace greenmail).
|
||||
- **D4 — On-demand bodies for native accounts** (§D.6 item 2).
|
||||
- **D5 — Security:** app passwords + 2FA rule, rate limits.
|
||||
- **D6 — Production:** compose, DNS (MX/SPF/DKIM/DMARC/PTR), relay or VPS per §D.1, backup, `docs/nas-install.md` runbook, release.
|
||||
|
||||
### D.8 Configuration additions (§14)
|
||||
`STALWART_URL` (management API), `STALWART_IMAP_HOST` / `STALWART_SMTP_HOST`, `STALWART_MASTER_SECRET`, `STALWART_ADMIN_SECRET`, `HOSTED_MAILBOXES` (on/off; off = M6 inert, byte-identical to pre-M6 behaviour).
|
||||
|
||||
### D.9 Acceptance criteria
|
||||
1. A newly registered user can receive mail from an outside address at their handle and sees it in webmail within one sync cycle; mail they send arrives at Gmail not in spam (SPF/DKIM/DMARC pass).
|
||||
2. The same handle + password signs in to IMAP (when §D.5 allows it); after a webmail password change or reset, the old password fails and the new one works with no other step; a blocked user cannot sign in to IMAP.
|
||||
3. The native account cannot be removed via API (409) or UI; deleting the user removes the Stalwart mailbox.
|
||||
4. Webmail stores no copy of the account's password (`secret_ct` unused for native accounts).
|
||||
5. After D1, an attachment-heavy fixture mailbox uses ≤ 55% of the pre-D1 blob storage, and every attachment still downloads byte-identical.
|
||||
6. After D4, a native message's raw blob is absent from `BLOB_ROOT`, yet opening, downloading attachments and "show original" all work; with Stalwart stopped, list and bodies still render from local data.
|
||||
7. Backup → restore on a fresh host brings back both webmail and Stalwart mail intact.
|
||||
|
||||
### D.10 Open questions (owner: product)
|
||||
1. Hosting option A, B or C? Static IP and open port 25 available?
|
||||
2. Which domain do the addresses use?
|
||||
3. May external mail clients (IMAP/SMTP) use these mailboxes?
|
||||
4. Ship D1 ahead of the rest?
|
||||
|
||||
---
|
||||
*End of specification. Ambiguities: prefer the simplest behavior consistent with the invariants in §1, leave a `TODO(spec)` comment, and list open questions in `docs/open-questions.md` rather than guessing silently.*
|
||||
|
||||
Reference in New Issue
Block a user