diff --git a/.gitignore b/.gitignore
index 42cd1f5..91caee1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -31,3 +31,9 @@ frontend/dist/
.DS_Store
.idea/
.vscode/
+
+# Editor backups
+*~
+
+# Local session helper (Claude Code resume command)
+/claude-session.txt
diff --git a/backend/app/accounts/ssrf.py b/backend/app/accounts/ssrf.py
index a0198ea..f8add73 100644
--- a/backend/app/accounts/ssrf.py
+++ b/backend/app/accounts/ssrf.py
@@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio
import ipaddress
import socket
+from dataclasses import dataclass
from urllib.parse import urlsplit
import httpx
@@ -87,3 +88,51 @@ async def guarded_get(url: str) -> str | None:
raise SsrfError("response exceeds 256 KB cap")
chunks.append(chunk)
return b"".join(chunks).decode("utf-8", "replace")
+
+
+@dataclass(frozen=True)
+class GuardedResponse:
+ status: int
+ headers: dict[str, str]
+ text: str
+
+
+async def guarded_request(
+ method: str,
+ url: str,
+ *,
+ headers: dict[str, str] | None = None,
+ content: bytes | str | None = None,
+ auth: tuple[str, str] | None = None,
+ timeout: float = 15.0, # noqa: ASYNC109 - passed straight to httpx's own timeout
+ max_bytes: int = 25 * 1024 * 1024,
+) -> GuardedResponse:
+ """Arbitrary-method HTTPS request under the same SSRF policy as :func:`guarded_get`.
+
+ Used by plugins that speak WebDAV/CalDAV/CardDAV (PROPFIND/REPORT/PUT/DELETE) or
+ fetch ICS feeds. HTTPS-only, public host required, no off-domain redirects, body
+ capped at ``max_bytes``. ``auth`` is an (user, password) basic-auth pair. Returns
+ status + headers + body (all statuses; the caller decides what's an error)."""
+ parts = urlsplit(url)
+ if parts.scheme != "https":
+ raise SsrfError("only https is permitted")
+ if not parts.hostname:
+ raise SsrfError("missing host")
+ await assert_host_is_public(parts.hostname)
+
+ async with (
+ httpx.AsyncClient(follow_redirects=False, timeout=timeout, auth=auth) as client,
+ client.stream(method, url, headers=headers, content=content) as resp,
+ ):
+ chunks: list[bytes] = []
+ total = 0
+ async for chunk in resp.aiter_bytes():
+ total += len(chunk)
+ if total > max_bytes:
+ raise SsrfError("response exceeds size cap")
+ chunks.append(chunk)
+ return GuardedResponse(
+ status=resp.status_code,
+ headers=dict(resp.headers),
+ text=b"".join(chunks).decode("utf-8", "replace"),
+ )
diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py
index 49f618f..79060b9 100644
--- a/backend/app/api/settings.py
+++ b/backend/app/api/settings.py
@@ -18,17 +18,22 @@ router = APIRouter(prefix="/settings", tags=["settings"])
DbSession = Annotated[AsyncSession, Depends(get_session)]
_ALLOWED_ORDER = {"asc", "desc"}
+_ALLOWED_TIME_FORMAT = {"12", "24"}
class SettingsOut(BaseModel):
thread_order: str = "asc"
tz: str = "UTC"
+ time_format: str = "24" # 12 = AM/PM, 24 = 24-hour (default); applies app-wide
+ jitsi_server: str = "" # blank => https://meet.jit.si/
allowed_image_senders: list[str] = []
class SettingsPatch(BaseModel):
thread_order: str | None = None
tz: str | None = None
+ time_format: str | None = None
+ jitsi_server: str | None = None
class QuotaOut(BaseModel):
@@ -56,6 +61,8 @@ def _settings_out(user: User) -> SettingsOut:
return SettingsOut(
thread_order=str(s.get("thread_order", "asc")),
tz=str(s.get("tz", "UTC")),
+ time_format=str(s.get("time_format", "24")),
+ jitsi_server=str(s.get("jitsi_server", "")),
allowed_image_senders=sorted(allowed_image_senders(user)),
)
@@ -81,6 +88,10 @@ async def patch_settings(body: SettingsPatch, user: CurrentUser, db: DbSession)
updates["thread_order"] = body.thread_order
if body.tz is not None:
updates["tz"] = body.tz
+ if body.time_format is not None and body.time_format in _ALLOWED_TIME_FORMAT:
+ updates["time_format"] = body.time_format
+ if body.jitsi_server is not None:
+ updates["jitsi_server"] = body.jitsi_server.strip()
user.settings = updates
await db.commit()
return _settings_out(user)
diff --git a/backend/app/indexer/mime.py b/backend/app/indexer/mime.py
index 52ede62..43db8c4 100644
--- a/backend/app/indexer/mime.py
+++ b/backend/app/indexer/mime.py
@@ -98,16 +98,23 @@ def _extract_attachments(msg: EmailMessage) -> list[ParsedAttachment]:
continue
cid = part.get("Content-ID")
filename = part.get_filename()
- if not (filename or cid or part.get_content_disposition() == "attachment"):
+ disposition = part.get_content_disposition()
+ if not (filename or cid or disposition == "attachment"):
continue
payload = part.get_payload(decode=True)
if not isinstance(payload, bytes):
continue
+ # A part is an INLINE resource (cid: image rendered in-body, hidden from the
+ # attachment list) only when it carries a Content-ID AND is not explicitly an
+ # attachment. A part marked `Content-Disposition: attachment` is a real
+ # attachment even when it also has a Content-ID (e.g. Fastmail's invite.ics),
+ # so we drop the cid for it and it shows as a downloadable chip.
+ inline = cid is not None and disposition != "attachment"
out.append(
ParsedAttachment(
filename=filename or "attachment",
mime_type=part.get_content_type(),
- content_id=cid.strip("<>") if cid else None,
+ content_id=cid.strip("<>") if (inline and cid is not None) else None,
payload=payload,
)
)
diff --git a/backend/app/indexer/sanitize.py b/backend/app/indexer/sanitize.py
index e7406af..0b9526d 100644
--- a/backend/app/indexer/sanitize.py
+++ b/backend/app/indexer/sanitize.py
@@ -17,7 +17,7 @@ import nh3
# Bump when the sanitization policy changes; body_parts.sanitizer_v records it so a
# render re-sanitizes stale content from the raw blob (SPEC §10.4).
-SANITIZER_VERSION = 2
+SANITIZER_VERSION = 3
_ALLOWED_TAGS: set[str] = {
# structure
@@ -76,6 +76,7 @@ _ALLOWED_TAGS: set[str] = {
"span",
"strike",
"strong",
+ "style",
"sub",
"summary",
"sup",
@@ -97,6 +98,7 @@ _ALLOWED_TAGS: set[str] = {
# Attributes allowed on any tag (presentational email formatting).
_GENERIC_ATTRS: set[str] = {
"style",
+ "class",
"align",
"valign",
"dir",
@@ -119,6 +121,7 @@ _TAG_SPECIFIC: dict[str, set[str]] = {
"th": {"colspan", "rowspan", "scope"},
"table": {"cellpadding", "cellspacing", "border"},
"font": {"face", "color", "size"},
+ "style": {"type", "media"},
}
# nh3 has no "generic attributes" option, so expand the generic set onto every tag.
@@ -132,6 +135,12 @@ _STYLE_DANGEROUS = re.compile(
r"(expression\s*\(|javascript:|vbscript:|@import|-moz-binding|behavior\s*:)", re.IGNORECASE
)
_STYLE_URL = re.compile(r"url\s*\([^)]*\)", re.IGNORECASE)
+# Script-like CSS that voids a whole )", re.IGNORECASE | re.DOTALL)
@dataclass(frozen=True)
@@ -150,6 +159,28 @@ def _sanitize_style(value: str, allow_remote: bool) -> str | None:
return value
+def _sanitize_style_elements(html: str, allow_remote: bool, state: dict[str, bool]) -> str:
+ """Sanitize CSS inside ``
hi
'
+ result = sanitize_html(html, MID)
+ assert "hi
'
+ result = sanitize_html(html, MID)
+ assert "expression(" not in result.html.lower()
+ # the block is nuked but the element/class survive
+ assert 'class="x"' in result.html
+
+
+def test_style_block_import_and_url_stripped_when_remote_blocked() -> None:
+ html = ""
+ result = sanitize_html(html, MID)
+ assert "@import" not in result.html.lower()
+ assert "evil" not in result.html.lower()
+ assert result.has_remote_images
+
+
+def test_script_content_still_stripped_with_style_allowed() -> None:
+ html = ""
+ result = sanitize_html(html, MID)
+ assert "alert(1)" not in result.html
+ assert ".a{color:red}" in result.html
diff --git a/backend/tests/plugins/test_calendar_plugin.py b/backend/tests/plugins/test_calendar_plugin.py
new file mode 100644
index 0000000..2831a50
--- /dev/null
+++ b/backend/tests/plugins/test_calendar_plugin.py
@@ -0,0 +1,170 @@
+"""Calendar plugin A1 API: calendars/events CRUD + range expansion (SPEC §A).
+
+Exercises the real HTTP surface: default-calendar bootstrap, event create/list with
+recurrence expansion, edit, delete, read-only rejection, and per-user isolation.
+Requires the calendar plugin installed + migrated.
+"""
+
+from __future__ import annotations
+
+import uuid
+from collections.abc import AsyncIterator
+
+import httpx
+import pytest_asyncio
+from httpx import ASGITransport
+from sqlalchemy import delete, text
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
+from sqlalchemy.pool import NullPool
+
+from app.config import get_settings
+from app.identity.passwords import hash_password
+from app.identity.sessions import create_session
+from app.main import create_app
+from app.models import User
+
+CAL = "/api/plugins/calendar"
+
+
+class _Ctx:
+ user_id: uuid.UUID
+ sm: async_sessionmaker # type: ignore[type-arg]
+ client: httpx.AsyncClient
+ client_b: httpx.AsyncClient
+
+
+@pytest_asyncio.fixture
+async def ctx() -> AsyncIterator[_Ctx]:
+ engine = create_async_engine(get_settings().database_url, poolclass=NullPool)
+ sm = async_sessionmaker(engine, expire_on_commit=False)
+ c = _Ctx()
+ c.sm = sm
+ ids: list[uuid.UUID] = []
+ async with sm() as s:
+ for _ in range(2):
+ handle = f"cal{uuid.uuid4().hex[:8]}@test.local"
+ u = User(handle=handle, password_hash=hash_password("pw"))
+ s.add(u)
+ await s.flush()
+ ids.append(u.id)
+ await s.commit()
+ c.user_id = ids[0]
+ transport = ASGITransport(app=create_app())
+
+ async def client(uid: uuid.UUID) -> httpx.AsyncClient:
+ token = await create_session(uid, "pytest")
+ return httpx.AsyncClient(
+ transport=transport,
+ base_url="http://testserver",
+ cookies={"session": token, "csrf": "csrf"},
+ headers={"X-CSRF": "csrf"},
+ )
+
+ c.client = await client(ids[0])
+ c.client_b = await client(ids[1])
+ try:
+ yield c
+ finally:
+ await c.client.aclose()
+ await c.client_b.aclose()
+ async with sm() as s: # cascade drops plg_calendar_* rows (user_id FK-less but scoped)
+ await s.execute(
+ text("DELETE FROM plg_calendar_calendars WHERE user_id = ANY(:ids)"),
+ {"ids": [str(i) for i in ids]},
+ )
+ await s.execute(delete(User).where(User.id.in_(ids)))
+ await s.commit()
+ await engine.dispose()
+
+
+async def _events(
+ client: httpx.AsyncClient,
+ start: str = "2026-06-01T00:00:00Z",
+ end: str = "2026-06-02T00:00:00Z",
+) -> list[dict[str, object]]:
+ r = await client.get(f"{CAL}/events", params={"start": start, "end": end})
+ return r.json()["events"]
+
+
+async def test_default_calendar_and_event_crud(ctx: _Ctx) -> None:
+ # First calendars call bootstraps a default "Personal" calendar.
+ cals = (await ctx.client.get(f"{CAL}/calendars")).json()
+ assert [c["name"] for c in cals] == ["Personal"]
+ cal_id = cals[0]["id"]
+
+ # Create a timed event and read it back in a range.
+ r = await ctx.client.post(
+ f"{CAL}/events",
+ json={
+ "calendar_id": cal_id,
+ "summary": "Standup",
+ "dtstart": "2026-06-01T09:00:00Z",
+ "dtend": "2026-06-01T09:30:00Z",
+ },
+ )
+ assert r.status_code == 200
+ event_id = r.json()["id"]
+
+ listing = await _events(ctx.client)
+ assert len(listing) == 1
+ assert listing[0]["summary"] == "Standup"
+ assert listing[0]["color"] == "#3b82f6"
+
+ # Edit, then delete.
+ patched = await ctx.client.patch(f"{CAL}/events/{event_id}", json={"summary": "Daily standup"})
+ assert patched.status_code == 200
+ again = await _events(ctx.client)
+ assert again[0]["summary"] == "Daily standup"
+
+ assert (await ctx.client.delete(f"{CAL}/events/{event_id}")).status_code == 200
+ empty = await _events(ctx.client)
+ assert empty == []
+
+
+async def test_recurring_event_expands_in_range(ctx: _Ctx) -> None:
+ cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"]
+ await ctx.client.post(
+ f"{CAL}/events",
+ json={
+ "calendar_id": cal_id,
+ "summary": "Weekly sync",
+ "dtstart": "2026-06-01T14:00:00Z", # Monday
+ "dtend": "2026-06-01T15:00:00Z",
+ "rrule": "FREQ=WEEKLY;BYDAY=MO",
+ },
+ )
+ # A 3-week window contains 3 Monday occurrences of one recurring event.
+ listing = await _events(ctx.client, end="2026-06-22T00:00:00Z")
+ assert len(listing) == 3
+ assert all(i["recurring"] for i in listing)
+ assert [i["start"][:10] for i in listing] == ["2026-06-01", "2026-06-08", "2026-06-15"]
+
+
+async def test_read_only_calendar_rejects_writes(ctx: _Ctx) -> None:
+ cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"]
+ # Simulate an ICS subscription: flip the calendar read-only directly.
+ async with ctx.sm() as s:
+ await s.execute(
+ text("UPDATE plg_calendar_calendars SET read_only = true WHERE id = :id"),
+ {"id": cal_id},
+ )
+ await s.commit()
+ r = await ctx.client.post(
+ f"{CAL}/events",
+ json={"calendar_id": cal_id, "summary": "nope", "dtstart": "2026-06-01T09:00:00Z"},
+ )
+ assert r.status_code == 403
+ body = r.json()
+ detail = body.get("error", {}).get("message") or body.get("detail")
+ assert detail == "read_only_calendar"
+
+
+async def test_events_are_per_user_isolated(ctx: _Ctx) -> None:
+ cal_id = (await ctx.client.get(f"{CAL}/calendars")).json()[0]["id"]
+ await ctx.client.post(
+ f"{CAL}/events",
+ json={"calendar_id": cal_id, "summary": "Private", "dtstart": "2026-06-05T10:00:00Z"},
+ )
+ # User B has their own default calendar and sees none of A's events.
+ b_events = await _events(ctx.client_b, end="2026-06-30T00:00:00Z")
+ assert b_events == []
diff --git a/backend/tests/plugins/test_calendar_recurrence.py b/backend/tests/plugins/test_calendar_recurrence.py
new file mode 100644
index 0000000..62ba7c1
--- /dev/null
+++ b/backend/tests/plugins/test_calendar_recurrence.py
@@ -0,0 +1,84 @@
+"""Calendar recurrence expansion across a DST boundary (SPEC §A.3, acceptance A.7.2)."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from zoneinfo import ZoneInfo
+
+from webmail_plugin_calendar.recurrence import expand_occurrences # type: ignore[import-not-found]
+
+WARSAW = ZoneInfo("Europe/Warsaw")
+
+
+def _local(occ: list[tuple[datetime, datetime]]) -> dict[str, datetime]:
+ # Map "YYYY-MM-DD" (Warsaw date) -> the UTC start of that occurrence.
+ return {s.astimezone(WARSAW).date().isoformat(): s for s, _ in occ}
+
+
+def test_weekly_recurrence_keeps_local_time_across_dst() -> None:
+ # Monday 2026-03-23 10:00 Europe/Warsaw == 09:00 UTC (UTC+1, before DST).
+ # DST starts 2026-03-29, so from 03-30 the offset is UTC+2.
+ dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC)
+ occ = expand_occurrences(
+ dtstart=dtstart,
+ duration=timedelta(hours=1),
+ rrule="FREQ=WEEKLY;BYDAY=MO",
+ exdates=[],
+ range_start=datetime(2026, 3, 1, tzinfo=UTC),
+ range_end=datetime(2026, 4, 30, tzinfo=UTC),
+ tz="Europe/Warsaw",
+ )
+ by_date = _local(occ)
+
+ # Every occurrence is at 10:00 local wall-clock, on both sides of the DST change.
+ for start, _ in occ:
+ assert start.astimezone(WARSAW).strftime("%H:%M") == "10:00"
+
+ # ...but the UTC offset moves across the boundary.
+ assert by_date["2026-03-23"].hour == 9 # UTC+1
+ assert by_date["2026-03-30"].hour == 8 # UTC+2 (DST)
+ assert by_date["2026-04-06"].hour == 8
+
+
+def test_exdates_remove_instances() -> None:
+ dtstart = datetime(2026, 3, 23, 9, 0, tzinfo=UTC)
+ occ = expand_occurrences(
+ dtstart=dtstart,
+ duration=timedelta(hours=1),
+ rrule="FREQ=WEEKLY;BYDAY=MO",
+ # Exclude the 2026-03-30 occurrence (10:00 Warsaw == 08:00 UTC under DST).
+ exdates=[datetime(2026, 3, 30, 8, 0, tzinfo=UTC)],
+ range_start=datetime(2026, 3, 1, tzinfo=UTC),
+ range_end=datetime(2026, 4, 30, tzinfo=UTC),
+ tz="Europe/Warsaw",
+ )
+ dates = set(_local(occ))
+ assert "2026-03-23" in dates
+ assert "2026-03-30" not in dates # excluded
+ assert "2026-04-06" in dates
+
+
+def test_single_event_no_rrule() -> None:
+ dtstart = datetime(2026, 6, 1, 12, 0, tzinfo=UTC)
+ occ = expand_occurrences(
+ dtstart=dtstart,
+ duration=timedelta(minutes=30),
+ rrule=None,
+ exdates=[],
+ range_start=datetime(2026, 6, 1, tzinfo=UTC),
+ range_end=datetime(2026, 6, 2, tzinfo=UTC),
+ tz="UTC",
+ )
+ assert occ == [(dtstart, dtstart + timedelta(minutes=30))]
+
+ # Outside the window -> nothing.
+ empty = expand_occurrences(
+ dtstart=dtstart,
+ duration=timedelta(minutes=30),
+ rrule=None,
+ exdates=[],
+ range_start=datetime(2026, 7, 1, tzinfo=UTC),
+ range_end=datetime(2026, 7, 2, tzinfo=UTC),
+ tz="UTC",
+ )
+ assert empty == []
diff --git a/backend/tests/plugins/test_sdk_security.py b/backend/tests/plugins/test_sdk_security.py
new file mode 100644
index 0000000..55647e5
--- /dev/null
+++ b/backend/tests/plugins/test_sdk_security.py
@@ -0,0 +1,33 @@
+"""SDK security surface for heavy plugins: vault + multi-method guarded HTTP."""
+
+from __future__ import annotations
+
+import pytest
+
+from app.accounts.ssrf import SsrfError, guarded_request
+from app.plugins.sdk import sdk
+
+
+def test_vault_round_trips_secrets() -> None:
+ vault = sdk("calendar").vault
+ secret = b"caldav-app-password-123"
+ blob = vault.encrypt(secret)
+ assert blob != secret # encrypted at rest
+ assert vault.decrypt(blob) == secret
+
+
+async def test_guarded_request_rejects_non_https() -> None:
+ with pytest.raises(SsrfError, match="https"):
+ await guarded_request("PROPFIND", "http://dav.example/cal/")
+
+
+async def test_guarded_request_rejects_private_host() -> None:
+ # Resolves to a loopback/private address -> blocked before any connection.
+ with pytest.raises(SsrfError, match="non-public|resolve"):
+ await guarded_request("GET", "https://localhost/")
+
+
+def test_http_facade_exposes_dav_verbs() -> None:
+ http = sdk("calendar").http
+ for verb in ("request", "get", "put", "delete", "propfind", "report"):
+ assert callable(getattr(http, verb))
diff --git a/frontend/src/components/AppSwitcher.tsx b/frontend/src/components/AppSwitcher.tsx
new file mode 100644
index 0000000..62c63e7
--- /dev/null
+++ b/frontend/src/components/AppSwitcher.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+
+import { usePluginPages } from "../plugins/manifest";
+
+// Product switcher shown in the sidebar header: Webmail + any plugin "app.page"
+// (Calendar, later Contacts). Plugin-agnostic — the app list comes from the
+// manifest, so a new page-plugin appears here with no core change.
+export function AppSwitcher() {
+ const navigate = useNavigate();
+ const pages = usePluginPages();
+ const [open, setOpen] = useState(false);
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!open) return;
+ const onDoc = (e: MouseEvent) => {
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
+ };
+ document.addEventListener("mousedown", onDoc);
+ return () => document.removeEventListener("mousedown", onDoc);
+ }, [open]);
+
+ const apps = [
+ { key: "mail", label: "Mail", to: "/mail" },
+ ...pages.map((p) => ({ key: `${p.plugin}${p.route}`, label: p.nav.label, to: `/p${p.route}` })),
+ ];
+
+ return (
+
+
+ {open && (
+
+ {apps.map((a) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/components/list/DraftsList.tsx b/frontend/src/components/list/DraftsList.tsx
index 07f9819..6fcb95b 100644
--- a/frontend/src/components/list/DraftsList.tsx
+++ b/frontend/src/components/list/DraftsList.tsx
@@ -1,12 +1,12 @@
import { useT } from "../../i18n";
+import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { DraftOut, RemoteDraftOut } from "../../lib/types";
function timeLabel(iso: string): string {
const d = new Date(iso);
const now = new Date();
- if (d.toDateString() === now.toDateString())
- return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
- return d.toLocaleDateString([], { month: "short", day: "numeric" });
+ if (d.toDateString() === now.toDateString()) return formatClock(d);
+ return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
}
function DraftRow({
diff --git a/frontend/src/components/list/MessageList.tsx b/frontend/src/components/list/MessageList.tsx
index f032d98..4adf0f5 100644
--- a/frontend/src/components/list/MessageList.tsx
+++ b/frontend/src/components/list/MessageList.tsx
@@ -2,17 +2,17 @@ import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
import { useT } from "../../i18n";
+import { formatClock, getDateLocale } from "../../lib/timeFormat";
import type { Address, ThreadSummary } from "../../lib/types";
import { Icon } from "../Icon";
function timeLabel(iso: string): string {
const d = new Date(iso);
const now = new Date();
- if (d.toDateString() === now.toDateString())
- return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
+ if (d.toDateString() === now.toDateString()) return formatClock(d);
if (d.getFullYear() === now.getFullYear())
- return d.toLocaleDateString([], { month: "short", day: "numeric" });
- return d.toLocaleDateString([], { year: "2-digit", month: "short", day: "numeric" });
+ return d.toLocaleDateString(getDateLocale(), { month: "short", day: "numeric" });
+ return d.toLocaleDateString(getDateLocale(), { year: "2-digit", month: "short", day: "numeric" });
}
// Stable per-sender hue so a sender's avatar keeps a consistent colour.
diff --git a/frontend/src/components/thread/ThreadView.tsx b/frontend/src/components/thread/ThreadView.tsx
index 124cc94..24f15a7 100644
--- a/frontend/src/components/thread/ThreadView.tsx
+++ b/frontend/src/components/thread/ThreadView.tsx
@@ -4,6 +4,7 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { printMessages } from "../../lib/print";
+import { getDateLocale, getHour12 } from "../../lib/timeFormat";
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
import { PluginSlot } from "../../plugins/PluginSlot";
import { Composer, type ComposerInit } from "../compose/Composer";
@@ -39,13 +40,17 @@ function recipientSummary(addrs: { name: string; email: string }[]): string {
function attributionFor(msg: MessageEnvelope): string {
const d = new Date(msg.date);
- const date = d.toLocaleDateString("en-US", {
+ const date = d.toLocaleDateString(getDateLocale(), {
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
});
- const time = d.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" });
+ const time = d.toLocaleTimeString("en-US", {
+ hour: "numeric",
+ minute: "2-digit",
+ hour12: getHour12(),
+ });
const who = msg.from_addr[0]?.name || msg.from_addr[0]?.email || "";
return `On ${date}, at ${time}, ${who} wrote:`;
}
@@ -110,13 +115,14 @@ function fmtRelative(iso: string): string {
}
function fmtFull(iso: string): string {
- return new Date(iso).toLocaleString("en-US", {
+ return new Date(iso).toLocaleString(getDateLocale(), {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
+ hour12: getHour12(),
});
}
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
index 975dec9..4ed02ff 100644
--- a/frontend/src/i18n/en.ts
+++ b/frontend/src/i18n/en.ts
@@ -223,6 +223,13 @@ export const en = {
"settings.revoke": "Revoke",
"settings.threadOrder": "Conversation order",
"settings.timezone": "Time zone",
+ "settings.timeFormat": "Time format",
+ "settings.timeFormat24": "24-hour (14:30)",
+ "settings.timeFormat12": "12-hour (2:30 PM)",
+ "settings.integrations": "Integrations",
+ "settings.jitsi": "Jitsi Meet",
+ "settings.jitsiHint":
+ "Server used to generate video-call links for calendar events. Leave empty to use the public https://meet.jit.si/ server.",
"settings.addAccount": "Add account",
"settings.syncState": "State: {state}",
"settings.confirmRemoveAccount": "Remove this account and all its mail?",
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 13c33f2..683a5b0 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -166,7 +166,9 @@ export const api = {
// --- settings ---
settings: () => apiFetch("/settings"),
- patchSettings: (patch: Partial>) =>
+ patchSettings: (
+ patch: Partial>,
+ ) =>
apiFetch("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch("/settings/quota"),
folderCounts: () => apiFetch("/mailboxes/counts"),
diff --git a/frontend/src/lib/print.ts b/frontend/src/lib/print.ts
index 3c63a42..d170cbb 100644
--- a/frontend/src/lib/print.ts
+++ b/frontend/src/lib/print.ts
@@ -3,6 +3,7 @@
// the browser's print dialog. Reuses the same sanitized HTML the reader shows.
import { api } from "./api";
+import { getDateLocale, getHour12 } from "./timeFormat";
import type { Address, AttachmentOut, MessageEnvelope } from "./types";
function esc(s: string): string {
@@ -24,13 +25,14 @@ function fmtList(addrs: Address[]): string {
function fmtDate(iso: string): string {
const d = new Date(iso);
- return d.toLocaleString(undefined, {
+ return d.toLocaleString(getDateLocale(), {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
+ hour12: getHour12(),
});
}
diff --git a/frontend/src/lib/timeFormat.ts b/frontend/src/lib/timeFormat.ts
new file mode 100644
index 0000000..34e6935
--- /dev/null
+++ b/frontend/src/lib/timeFormat.ts
@@ -0,0 +1,41 @@
+// Shared 12h/24h time preference (SPEC §9.1). The value lives in user settings
+// (`time_format`), synced here so the module-level date formatters used across
+// Mail can read it synchronously during render. 24-hour is the default.
+
+let hour12 = false;
+
+try {
+ if (typeof localStorage !== "undefined" && localStorage.getItem("time_format") === "12") {
+ hour12 = true;
+ }
+} catch {
+ /* localStorage unavailable (SSR/tests) — keep the 24h default. */
+}
+
+/** Called when the settings query resolves/changes to sync the preference. */
+export function setTimeFormat(fmt: "12" | "24"): void {
+ hour12 = fmt === "12";
+ try {
+ localStorage.setItem("time_format", fmt);
+ } catch {
+ /* ignore */
+ }
+}
+
+/** `hour12` option for Intl date/time formatting, reflecting the preference. */
+export function getHour12(): boolean {
+ return hour12;
+}
+
+/**
+ * Locale for date formatting, tied to the time preference: 24-hour → en-GB
+ * (day/month/year, European), 12-hour → en-US (month/day/year, American).
+ */
+export function getDateLocale(): string {
+ return hour12 ? "en-US" : "en-GB";
+}
+
+/** Format just the clock portion (e.g. "14:30" or "2:30 PM") of a Date. */
+export function formatClock(d: Date): string {
+ return d.toLocaleTimeString([], { hour: hour12 ? "numeric" : "2-digit", minute: "2-digit", hour12 });
+}
diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts
index f508054..be72adf 100644
--- a/frontend/src/lib/types.ts
+++ b/frontend/src/lib/types.ts
@@ -151,6 +151,8 @@ export interface SessionInfo {
export interface UserSettings {
thread_order: "asc" | "desc";
tz: string;
+ time_format: "12" | "24";
+ jitsi_server: string;
allowed_image_senders: string[];
}
diff --git a/frontend/src/plugins/PluginPage.tsx b/frontend/src/plugins/PluginPage.tsx
index df28fb1..d1a8418 100644
--- a/frontend/src/plugins/PluginPage.tsx
+++ b/frontend/src/plugins/PluginPage.tsx
@@ -5,7 +5,7 @@
// header keeps a way back to Mail. (Sidebar persistence is a later refinement.)
import { useEffect, useMemo, useRef } from "react";
-import { Link, Navigate, useLocation } from "react-router-dom";
+import { Navigate, useLocation } from "react-router-dom";
import { useT } from "../i18n";
import { loadBundle } from "./bundleLoader";
@@ -40,7 +40,7 @@ function PageMount({
};
}, [bundle, ctx, props, pluginName]);
- return ;
+ return ;
}
export function PluginPage() {
@@ -58,17 +58,10 @@ export function PluginPage() {
}
if (!match) return ;
+ // Full-bleed: app pages (like Calendar) own their entire chrome, including the
+ // product switcher for navigating back to Mail.
return (
-
-
-
- ← {t("nav.mail")}
-
- {match.nav.label}
-
+
);
diff --git a/frontend/src/plugins/context.ts b/frontend/src/plugins/context.ts
index 6908f92..03d8413 100644
--- a/frontend/src/plugins/context.ts
+++ b/frontend/src/plugins/context.ts
@@ -1,6 +1,7 @@
// Builds the PluginContext handed to plugin bundles at mount (SPEC §11.3).
import { useMemo } from "react";
+import { useNavigate } from "react-router-dom";
import type { MessageKey } from "../i18n/en";
import { useT } from "../i18n";
@@ -11,6 +12,7 @@ import type { PluginContext } from "./types";
/** A memoised context bound to one plugin, usable from slots and pages. */
export function usePluginContext(pluginName: string): PluginContext {
const t = useT();
+ const navigate = useNavigate();
return useMemo
(
() => ({
pluginName,
@@ -27,7 +29,8 @@ export function usePluginContext(pluginName: string): PluginContext {
// Plugins mostly ship their own strings; app keys resolve, unknown keys
// fall through as-is (the app translator returns the key when missing).
t: (key, vars) => t(key as MessageKey, vars),
+ navigate: (path) => navigate(path),
}),
- [pluginName, t],
+ [pluginName, t, navigate],
);
}
diff --git a/frontend/src/plugins/types.ts b/frontend/src/plugins/types.ts
index 46ef407..090c6dc 100644
--- a/frontend/src/plugins/types.ts
+++ b/frontend/src/plugins/types.ts
@@ -54,6 +54,8 @@ export interface PluginContext {
api: PluginApi;
events: PluginEvents;
t: (key: string, vars?: Record) => string;
+ /** Navigate the app's SPA router (e.g. back to "/mail" from a page bundle). */
+ navigate: (path: string) => void;
}
export type PluginUnmount = () => void;
diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx
index 3fe6c91..424cfba 100644
--- a/frontend/src/routes/MailLayout.tsx
+++ b/frontend/src/routes/MailLayout.tsx
@@ -2,6 +2,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tansta
import { useEffect, useRef, useState } from "react";
import { Link, useMatch, useNavigate } from "react-router-dom";
+import { AppSwitcher } from "../components/AppSwitcher";
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
import { DraftsList } from "../components/list/DraftsList";
import { Icon } from "../components/Icon";
@@ -14,6 +15,7 @@ import { ThreadView } from "../components/thread/ThreadView";
import { useT } from "../i18n";
import type { MessageKey } from "../i18n/en";
import { api } from "../lib/api";
+import { setTimeFormat } from "../lib/timeFormat";
import type { QuotaOut, SavedSearch, ThreadSummary } from "../lib/types";
import { useKeyboardShortcuts } from "../lib/useKeyboardShortcuts";
import { usePluginPages } from "../plugins/manifest";
@@ -137,6 +139,10 @@ export function MailLayout() {
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
const settingsQuery = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const threadOrder: "asc" | "desc" = settingsQuery.data?.thread_order ?? "asc";
+ const timeFormat = settingsQuery.data?.time_format;
+ useEffect(() => {
+ if (timeFormat) setTimeFormat(timeFormat);
+ }, [timeFormat]);
const quotaQuery = useQuery({ queryKey: ["quota"], queryFn: api.quota });
const countsQuery = useQuery({ queryKey: ["folder-counts"], queryFn: api.folderCounts });
@@ -430,11 +436,7 @@ export function MailLayout() {
collapsed ? "flex-col items-center gap-1 px-2" : "items-center justify-between px-3"
}`}
>
- {!collapsed && (
-
- {t("common.appName")}
-
- )}
+ {!collapsed && }