M4: Calendar plugin (builtin) + mail HTML/attachment fixes

Calendar plugin (webmail_plugin_calendar, non-removable core plugin):
- Local calendars/events CRUD with RLS isolation; recurrence expanded at
  query time (DST-correct), EXDATE + detached-override merge.
- Calendar app bundle: month/week/day/3-day views, mini-month, event form
  (color, show-as, timezone combobox, repeat, reminders, guests w/ recipient
  autocomplete, Jitsi video call), event popover with RSVP badges, resizable/
  collapsible left panel + theme toggle, overlap layout.
- iMIP invites: sdk.mail sends REQUEST from the organizer's mailbox (HTML +
  text/calendar + invite.ics); public /rsvp endpoint (vault-sealed token)
  records accepted/declined/tentative per attendee.
- "This and following" edit + scoped delete (this / this-and-following), with
  a delete confirmation dialog for recurring events.

Core:
- Settings: time_format (12/24h, default 24h) + jitsi_server; new Integrations
  section; date order follows time format (en-GB vs en-US) across Mail.
- Plugin SDK: mail (send from a user's account), vault (envelope encrypt),
  multi-method SSRF-guarded http; AppSwitcher product switcher; full-bleed
  PluginPage.

Mail HTML rendering:
- Sanitizer allows <style> blocks + class (CSS sanitized: drop script-like,
  strip @import/url() when remote images blocked); SANITIZER_VERSION 3 so
  already-indexed mail re-sanitizes on view.
- Attachments: a Content-Disposition: attachment part is a real attachment
  even when it also carries a Content-ID (fixes hidden invite.ics).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 16:33:34 +02:00
co-authored by Claude Opus 4.8
parent d46ad13b7b
commit 8c634f7967
56 changed files with 3308 additions and 37 deletions
+60
View File
@@ -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<HTMLDivElement>(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 (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((o) => !o)}
className="flex items-center gap-1.5 rounded px-1.5 py-1 text-[15px] font-semibold text-app-fg hover:bg-app-hover"
>
Mail
<svg width="20" height="20" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d="M4 6l4 4 4-4" />
</svg>
</button>
{open && (
<div className="absolute left-0 top-full z-20 mt-1 min-w-[160px] rounded-lg border border-app-border bg-app-elevated py-1 shadow-lg">
{apps.map((a) => (
<button
key={a.key}
onClick={() => {
setOpen(false);
navigate(a.to);
}}
className={`flex w-full items-center px-3 py-1.5 text-left text-sm hover:bg-app-hover ${
a.key === "mail" ? "font-medium text-app-fg" : "text-app-muted"
}`}
>
{a.label}
</button>
))}
</div>
)}
</div>
);
}
+3 -3
View File
@@ -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({
+4 -4
View File
@@ -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.
@@ -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(),
});
}
+7
View File
@@ -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?",
+3 -1
View File
@@ -166,7 +166,9 @@ export const api = {
// --- settings ---
settings: () => apiFetch<UserSettings>("/settings"),
patchSettings: (patch: Partial<Pick<UserSettings, "thread_order" | "tz">>) =>
patchSettings: (
patch: Partial<Pick<UserSettings, "thread_order" | "tz" | "time_format" | "jitsi_server">>,
) =>
apiFetch<UserSettings>("/settings", { method: "PATCH", body: JSON.stringify(patch) }),
quota: () => apiFetch<QuotaOut>("/settings/quota"),
folderCounts: () => apiFetch<FolderCounts>("/mailboxes/counts"),
+3 -1
View File
@@ -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(),
});
}
+41
View File
@@ -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 });
}
+2
View File
@@ -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[];
}
+5 -12
View File
@@ -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 <div ref={ref} data-plugin-page={pluginName} className="min-h-0 flex-1 overflow-auto" />;
return <div ref={ref} data-plugin-page={pluginName} className="h-full overflow-auto" />;
}
export function PluginPage() {
@@ -58,17 +58,10 @@ export function PluginPage() {
}
if (!match) return <Navigate to="/mail" replace />;
// Full-bleed: app pages (like Calendar) own their entire chrome, including the
// product switcher for navigating back to Mail.
return (
<div className="flex h-screen flex-col bg-app-bg">
<header className="flex items-center gap-3 border-b border-app-border px-4 py-2">
<Link
to="/mail"
className="rounded px-2 py-1 text-sm text-app-muted hover:bg-app-hover hover:text-app-fg"
>
← {t("nav.mail")}
</Link>
<span className="text-sm font-semibold text-app-fg">{match.nav.label}</span>
</header>
<div className="h-screen bg-app-bg">
<PageMount pluginName={match.plugin} bundle={match.bundle} subpath={subpath} />
</div>
);
+4 -1
View File
@@ -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<PluginContext>(
() => ({
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],
);
}
+2
View File
@@ -54,6 +54,8 @@ export interface PluginContext {
api: PluginApi;
events: PluginEvents;
t: (key: string, vars?: Record<string, string | number>) => string;
/** Navigate the app's SPA router (e.g. back to "/mail" from a page bundle). */
navigate: (path: string) => void;
}
export type PluginUnmount = () => void;
+7 -5
View File
@@ -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 && (
<span className="text-[13px] font-semibold uppercase tracking-wide text-app-faint">
{t("common.appName")}
</span>
)}
{!collapsed && <AppSwitcher />}
<div className={`flex items-center gap-1 ${collapsed ? "flex-col" : ""}`}>
<button
onClick={() => setCollapsed((v) => !v)}
+50 -1
View File
@@ -8,9 +8,10 @@ import { useT } from "../../i18n";
import { api, ApiError } from "../../lib/api";
import { useMe } from "../../lib/auth";
import { useTheme } from "../../lib/theme";
import { setTimeFormat } from "../../lib/timeFormat";
import { PluginSlot } from "../../plugins/PluginSlot";
const SECTIONS = ["profile", "security", "accounts", "appearance"] as const;
const SECTIONS = ["profile", "security", "accounts", "appearance", "integrations"] as const;
type Section = (typeof SECTIONS)[number];
export function Settings() {
@@ -45,6 +46,7 @@ export function Settings() {
{section === "security" && <SecuritySection />}
{section === "accounts" && <AccountsSection />}
{section === "appearance" && <AppearanceSection />}
{section === "integrations" && <IntegrationsSection />}
{/* Plugin-contributed settings UI (SPEC §11.3 settings.page), under Accounts.
Matches the accounts section width (max-w-5xl) with a gap above it. */}
{section === "accounts" && (
@@ -595,7 +597,54 @@ function AppearanceSection() {
<option value="desc">{t("mail.orderNewest")}</option>
</select>
</label>
<label className="flex items-center justify-between border-b border-app-border py-2 text-sm">
<span className="text-app-muted">{t("settings.timeFormat")}</span>
<select
className="rounded border border-app-border bg-app-surface px-2 py-1"
value={settings.data?.time_format ?? "24"}
onChange={(e) => {
const fmt = e.target.value as "12" | "24";
setTimeFormat(fmt);
patch.mutate({ time_format: fmt });
}}
>
<option value="24">{t("settings.timeFormat24")}</option>
<option value="12">{t("settings.timeFormat12")}</option>
</select>
</label>
<Row label={t("settings.timezone")} value={settings.data?.tz ?? "UTC"} />
</section>
);
}
const DEFAULT_JITSI = "https://meet.jit.si/";
function IntegrationsSection() {
const t = useT();
const queryClient = useQueryClient();
const settings = useQuery({ queryKey: ["settings"], queryFn: api.settings });
const patch = useMutation({
mutationFn: api.patchSettings,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["settings"] }),
});
const [jitsi, setJitsi] = useState<string | null>(null);
const value = jitsi ?? settings.data?.jitsi_server ?? "";
return (
<section className="max-w-4xl space-y-6">
<h2 className="text-lg font-semibold">{t("settings.integrations")}</h2>
<div className="space-y-2">
<h3 className="text-sm font-medium">{t("settings.jitsi")}</h3>
<p className="text-sm text-app-muted">{t("settings.jitsiHint")}</p>
<input
type="url"
className="w-full rounded border border-app-border bg-app-surface px-3 py-2 text-sm"
placeholder={DEFAULT_JITSI}
value={value}
onChange={(e) => setJitsi(e.target.value)}
onBlur={(e) => patch.mutate({ jitsi_server: e.target.value.trim() })}
/>
</div>
</section>
);
}
+6 -3
View File
@@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter } from "react-router-dom";
import { describe, expect, it, vi } from "vitest";
import { I18nProvider } from "../i18n";
@@ -41,9 +42,11 @@ function wrap(children: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(["plugin-ui-manifest"], MANIFEST); // seed so no network fetch
return (
<I18nProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</I18nProvider>
<MemoryRouter>
<I18nProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</I18nProvider>
</MemoryRouter>
);
}