M6 hosted mailboxes (off by default); dark message mode; fixes

Hosted mailboxes (SPEC App. D), behind HOSTED_MAILBOXES (off: unchanged):
- Stalwart mail server, provisioned by `python -m app.hosted.provision`;
  directory read from a Postgres view, app passwords only for mail apps
- native account per user, the worker signs in as master for the user
- raw messages of hosted mailboxes only cached, fetched back on demand
- migrations 0025 (accounts.kind, directory view), 0026 (IMAP ids bigint)

Mail:
- dark message mode switch in the reading pane (Thunderbird's algorithm)
- moves and folder creation quote the folder name ("Deleted Items"):
  swipe-to-delete / Trash no longer bounce back
- thread unread counters: lock before recount, sorted lock order,
  UIDVALIDITY re-import recounts, nightly repair_threads
- sidebar accounts sorted alphabetically, more space above headings

Settings: Security headings renamed; antispam accounts are explicit
(none ticked = none filtered), input widths, Activity hidden.
Plugin bundles served with Cache-Control: no-cache.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 19:25:10 +02:00
co-authored by Claude Opus 5.5
parent d45fbf2c44
commit b88855931e
62 changed files with 2427 additions and 188 deletions
+22 -6
View File
@@ -3,7 +3,9 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useT } from "../../i18n";
import { api } from "../../lib/api";
import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage";
import { fixInvisibleText } from "../../lib/readability";
import { useTheme } from "../../lib/theme";
import type { MessageEnvelope } from "../../lib/types";
import { Attachments } from "./Attachments";
@@ -30,6 +32,12 @@ export function MessageBody({
const onMailtoRef = useRef(onMailto);
onMailtoRef.current = onMailto;
const [height, setHeight] = useState(80);
// Dark message mode (lib/darkMessage.ts): a dark page in the dark theme,
// unless switched off in the subject bar.
const darkMessages = useDarkMessages();
const dark = useTheme().resolved === "dark" && darkMessages;
const darkRef = useRef(dark);
darkRef.current = dark;
const { data, isLoading, isError, error } = useQuery({
queryKey: ["message-html", message.id, allowed],
@@ -89,7 +97,8 @@ export function MessageBody({
btn.setAttribute(
"style",
"display:inline-block;margin:6px 0;padding:1px 9px;border-radius:8px;border:none;" +
"background:#e5e7eb;color:#4b5563;cursor:pointer;font:inherit;font-size:15px;line-height:1.5;",
(darkRef.current ? "background:#3f444a;color:#c9ccd1;" : "background:#e5e7eb;color:#4b5563;") +
"cursor:pointer;font:inherit;font-size:15px;line-height:1.5;",
);
btn.addEventListener("click", () => {
quote.style.display = quote.style.display === "none" ? "" : "none";
@@ -131,8 +140,13 @@ export function MessageBody({
// overflow removes any phantom scrollbar left by sub-pixel/line-box rounding
// (horizontal stays scrollable for genuinely wide emails).
doc.documentElement.style.overflowY = "hidden";
// Before the quote is folded away: hidden text counts as hidden on purpose.
fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts)
if (darkRef.current) {
// The sender's colours that would not read on the dark page.
adaptForDarkMode(doc);
} else {
// Before the quote is folded away: hidden text counts as hidden on purpose.
fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts)
}
injectQuoteToggle(doc);
resize();
// Re-measure after the first paint settles (fonts/layout can shift the height
@@ -175,7 +189,7 @@ export function MessageBody({
};
frame = requestAnimationFrame(check);
return () => cancelAnimationFrame(frame);
}, [data, prepare]);
}, [data, dark, prepare]);
useEffect(() => () => observerRef.current?.disconnect(), []);
@@ -213,13 +227,15 @@ export function MessageBody({
)}
{data !== undefined && !isLoading && (
<iframe
// A new frame when the mode changes: the message is rendered afresh.
key={dark ? "dark" : "light"}
ref={frameRef}
title={`message-${message.id}`}
sandbox="allow-same-origin"
srcDoc={data}
srcDoc={dark ? withDarkPage(data) : data}
onLoad={onLoad}
style={{ height }}
className="block w-full rounded-lg border-0 bg-white"
className={`block w-full rounded-lg border-0 ${dark ? "bg-[#212427]" : "bg-white"}`}
/>
)}
{message.has_attachments && <Attachments messageId={message.id} />}
+33 -2
View File
@@ -3,9 +3,11 @@ import { Fragment, useEffect, useMemo, useRef, useState } from "react";
import { useT } from "../../i18n";
import { personHue } from "../../lib/avatarColor";
import { setDarkMessages, useDarkMessages } from "../../lib/darkMessage";
import { api } from "../../lib/api";
import { printMessages } from "../../lib/print";
import { replyTargets } from "../../lib/replyTargets";
import { useTheme } from "../../lib/theme";
import { useIsMobile } from "../../lib/useIsMobile";
import { getDateLocale, getHour12 } from "../../lib/timeFormat";
import type { AccountOut, MailboxOut, MessageEnvelope } from "../../lib/types";
@@ -726,6 +728,8 @@ export function ThreadView({
const composerRef = useRef<HTMLDivElement | null>(null);
// On a phone, reply/reply-all/forward open full screen rather than inline.
const mobile = useIsMobile();
const resolvedTheme = useTheme().resolved;
const darkMessages = useDarkMessages();
// Bring the inline composer into view when it opens (reply/reply-all/forward).
useEffect(() => {
if (composing && !mobile) {
@@ -754,8 +758,35 @@ export function ThreadView({
<LabelChips labels={threadLabels} onRemove={(l) => removeThreadLabel.mutate(l)} />
</div>
<div className="flex shrink-0 items-center gap-1">
{/* Dark message mode (lib/darkMessage.ts) — only the dark theme has it. */}
{resolvedTheme === "dark" && (
<button
role="switch"
aria-checked={darkMessages}
className="flex items-center gap-1.5 rounded px-1.5 py-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={darkMessages ? t("mail.darkMessagesOn") : t("mail.darkMessagesOff")}
aria-label={t("mail.darkMessages")}
onClick={() => setDarkMessages(!darkMessages)}
>
<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
<span
aria-hidden
className={`relative inline-block h-4 w-7 rounded-full transition-colors ${
darkMessages ? "bg-app-accent" : "bg-app-border"
}`}
>
<span
className={`absolute top-0.5 h-3 w-3 rounded-full bg-white shadow transition-[left] ${
darkMessages ? "left-[14px]" : "left-0.5"
}`}
/>
</span>
</button>
)}
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
className="flex items-center justify-center rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("mail.print")}
aria-label={t("mail.print")}
onClick={() => void printMessages(subject, visual)}
@@ -763,7 +794,7 @@ export function ThreadView({
<Icon name="print" size={16} />
</button>
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
className="flex items-center justify-center rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
aria-label={allExpanded ? t("mail.collapseAll") : t("mail.expandAll")}
onClick={toggleAll}
+13 -1
View File
@@ -391,7 +391,14 @@ export const en = {
"appPasswords.title": "App passwords",
"appPasswords.help":
"Calendar and contacts apps — your phone's, Thunderbird, macOS — sign in to Ultimo with an app password, never your account password. Give each app its own, so you can revoke one without affecting the others.",
"appPasswords.connectTitle": "Calendars and contacts in other apps",
"appPasswords.helpMail":
"Mail apps — Thunderbird, your phone's Mail app, Outlook — and calendar and contacts apps sign in to Ultimo with an app password. Your account password never works in them. Give each app its own, so you can revoke one without affecting the others.",
"appPasswords.mailTitle": "External mail clients configuration:",
"appPasswords.mailIncoming": "Incoming (IMAP)",
"appPasswords.mailOutgoing": "Outgoing (SMTP)",
"appPasswords.mailPorts": "{host}, port {port}, SSL/TLS",
"appPasswords.mailPortsAlt": "or port {port}, STARTTLS",
"appPasswords.connectTitle": "External Calendars and Contacts apps configuration:",
"appPasswords.connectServer": "Server",
"appPasswords.connectUser": "User name",
"appPasswords.connectPassword": "Password",
@@ -566,6 +573,11 @@ export const en = {
"settings.serverSettingsHint": "Enter your mailbox password to apply server or password changes.",
"settings.signature": "Signature",
"settings.configureAccount": "Configure account",
"settings.yourMailbox": "Your mailbox",
"mail.darkMessages": "Dark message mode",
"mail.darkMessagesOn": "Dark message mode is on: messages are shown on a dark page. Click to show them as sent.",
"mail.darkMessagesOff": "Dark message mode is off: messages are shown as sent. Click to show them on a dark page.",
"settings.yourMailboxHint": "Your Ultimo address. It stays as long as your account does.",
"settings.editSignature": "Edit signature",
"settings.theme": "Theme",
"theme.light": "Light",
+95
View File
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { adaptForDarkMode, contrast, luminance, parseCssColor, sanitizeStyle, withDarkPage } from "./darkMessage";
function styleOf(css: string): CSSStyleDeclaration {
const el = document.createElement("div");
el.setAttribute("style", css);
return el.style;
}
describe("colours", () => {
it("parses hex, rgb and hsl, and refuses what is not a colour", () => {
expect(parseCssColor("#fff")).toEqual([255, 255, 255, 1]);
expect(parseCssColor("#00000080")?.[3]).toBeCloseTo(0.5, 2);
expect(parseCssColor("rgba(10, 20, 30, 0.4)")).toEqual([10, 20, 30, 0.4]);
expect(parseCssColor("rgb(10 20 30 / 50%)")).toEqual([10, 20, 30, 0.5]);
expect(parseCssColor("hsl(0, 100%, 50%)")?.map(Math.round)).toEqual([255, 0, 0, 1]);
expect(parseCssColor("url(x.png)")).toBeNull();
expect(parseCssColor("#fff url(x.png)")).toBeNull();
});
it("uses Thunderbird's luminance (0-255) and contrast", () => {
expect(luminance("#ffffff")).toBeCloseTo(255, 0);
expect(luminance("#000")).toBe(0);
expect(luminance("not a colour")).toBe(0);
expect(contrast("#000", "#fff")).toBeGreaterThan(1000);
expect(contrast("#777", "#888")).toBeLessThan(3.5);
});
});
describe("sanitizeStyle", () => {
it("drops dark text with no background, keeps light text", () => {
const dark = styleOf("color: #333");
sanitizeStyle(dark);
expect(dark.color).toBe("");
const light = styleOf("color: #f0f0f0");
sanitizeStyle(light);
expect(light.color).not.toBe("");
});
it("drops a light background, and the dark text that was on it", () => {
const s = styleOf("background-color: #ffffff; color: #222222");
sanitizeStyle(s);
expect(s.backgroundColor).toBe("");
expect(s.color).toBe("");
});
it("keeps a dark box with light text as designed", () => {
const s = styleOf("background-color: #003366; color: #ffffff");
sanitizeStyle(s);
expect(s.backgroundColor).not.toBe("");
expect(s.color).not.toBe("");
});
it("drops a dark background its text does not stand out from", () => {
const s = styleOf("background-color: #303030; color: #404040");
sanitizeStyle(s);
expect(s.backgroundColor).toBe("");
expect(s.color).toBe("");
});
it("drops gradients", () => {
const s = styleOf("background: linear-gradient(#000, #111); color: #fff");
sanitizeStyle(s);
expect(s.getPropertyValue("background") + s.getPropertyValue("background-image")).not.toContain("gradient");
});
});
describe("adaptForDarkMode", () => {
it("clears the body's colours and colour attributes, and adapts style sheets", () => {
document.head.innerHTML = "<style>.card { background-color: #ffffff; color: #111111 } .ok { color: #eeeeee }</style>";
document.body.innerHTML =
'<table bgcolor="#ffffff"><tr><td><font color="#000080">Hi</font></td></tr></table>' +
'<p style="color: #444444">dark text</p><p style="background-color: #102030; color: #fafafa">box</p>';
document.body.setAttribute("bgcolor", "#ffffff");
document.body.style.color = "#000000";
adaptForDarkMode(document);
expect(document.body.getAttribute("bgcolor")).toBeNull();
expect(document.body.style.color).toBe("");
expect(document.querySelector("table")!.getAttribute("bgcolor")).toBeNull();
expect(document.querySelector("font")!.getAttribute("color")).toBeNull();
const [p1, p2] = Array.from(document.querySelectorAll("p"));
expect(p1.style.color).toBe("");
expect(p2.style.backgroundColor).not.toBe("");
const rules = Array.from((document.querySelector("style")!.sheet as CSSStyleSheet).cssRules) as CSSStyleRule[];
expect(rules[0].style.getPropertyValue("background-color")).toBe("");
expect(rules[0].style.getPropertyValue("color")).toBe("");
expect(rules[1].style.getPropertyValue("color")).not.toBe("");
});
it("puts the dark page in the head", () => {
const html = withDarkPage("<html><head><style>a{}</style></head><body>x</body></html>");
expect(html.indexOf("wm-dark-message")).toBeLessThan(html.indexOf("</head>"));
});
});
+239
View File
@@ -0,0 +1,239 @@
// Dark message mode: show a message on a dark page when the app is dark.
//
// A port of Thunderbird's "dark reader" (mail/base/content/modules/DarkReader.mjs,
// Thunderbird 140 ESR, MPL 2.0). Thunderbird does not invert anything: it gives
// the message a dark page and light text, then takes away the colours a sender
// set that would not read on it — any explicit text colour that is dark
// (luminance ≤ 200 of 255) when it has no background of its own, and any
// explicit background that is light (> 200) or that its text does not stand
// out from (contrast < 3.5). What a sender set that already works on dark — a
// dark coloured box with light text, light text, a picture — is kept.
// Gradients are dropped. Same for `<font color>`, `bgcolor` and table colour
// attributes, the body's own colours, the rules of the message's style sheets,
// and SVG text fills.
import { useSyncExternalStore } from "react";
export type Rgba = [number, number, number, number];
const LUMINANCE_THRESHOLD = 200;
export const CONTRAST_THRESHOLD = 3.5;
/** The dark page and its text: the reading pane's own panel colours. */
export const DARK_MESSAGE_CSS = `<style id="wm-dark-message">
:root { color-scheme: dark; }
html, body { background-color: #212427; color: #e2e4e7; }
a { color: #8ab4f8; }
blockquote { border-left-color: #3f444a; color: #b4b8be; }
</style>`;
// ---- colours -----------------------------------------------------------------
const cache = new Map<string, Rgba | null>();
function parseHex(hex: string): Rgba | null {
const h = hex.slice(1);
if (![3, 4, 6, 8].includes(h.length) || /[^0-9a-f]/i.test(h)) return null;
const full = h.length <= 4 ? [...h].map((c) => c + c).join("") : h;
const n = (i: number) => parseInt(full.slice(i, i + 2), 16);
return [n(0), n(2), n(4), full.length === 8 ? n(6) / 255 : 1];
}
function parseFunction(value: string): Rgba | null {
const m = value.match(/^(rgba?|hsla?)\(([^)]*)\)$/i);
if (!m) return null;
const parts = m[2].split(/[\s,/]+/).filter(Boolean);
if (parts.length < 3) return null;
const num = (p: string, scale: number) => (p.endsWith("%") ? (parseFloat(p) / 100) * scale : parseFloat(p));
const alpha = parts[3] === undefined ? 1 : num(parts[3], 1);
if (m[1].toLowerCase().startsWith("rgb")) {
const [r, g, b] = parts.slice(0, 3).map((p) => num(p, 255));
return [r, g, b, alpha].some(Number.isNaN) ? null : [r, g, b, alpha];
}
const h = (((parseFloat(parts[0]) % 360) + 360) % 360) / 360;
const s = num(parts[1], 1);
const l = num(parts[2], 1);
const k = (n: number) => (n + h * 12) % 12;
const a = s * Math.min(l, 1 - l);
const f = (n: number) => 255 * (l - a * Math.max(-1, Math.min(k(n) - 3, 9 - k(n), 1)));
return [h, s, l, alpha].some(Number.isNaN) ? null : [f(0), f(8), f(4), alpha];
}
/** A CSS colour as RGBA (0–255, alpha 0–1), or null when it is not a colour
* (``none``, ``url(…)``, a background shorthand with more than a colour…). Named
* colours are resolved by the browser, in ``doc``. */
export function parseCssColor(value: string, doc?: Document): Rgba | null {
const v = value.trim().toLowerCase();
if (!v) return null;
const known = cache.get(v);
if (known !== undefined) return known;
let out: Rgba | null = null;
if (v === "transparent") out = [0, 0, 0, 0];
else if (v.startsWith("#")) out = parseHex(v);
else if (/^(rgb|hsl)a?\(/.test(v)) out = parseFunction(v);
else if (/^[a-z]+$/.test(v) && doc?.defaultView && doc.body) {
// A named colour (or a keyword like ``currentcolor``): ask the browser.
const probe = doc.createElement("span");
probe.style.color = v;
if (probe.style.color) {
probe.style.display = "none";
doc.body.appendChild(probe);
out = parseFunction(doc.defaultView.getComputedStyle(probe).color.replace(/\s+/g, " "));
probe.remove();
}
}
cache.set(v, out);
return out;
}
/** Thunderbird's luminance: 0.2125 R + 0.7154 G + 0.0721 B, 0–255; 0 when not a colour. */
export function luminance(color: string, doc?: Document): number {
const rgba = parseCssColor(color, doc);
if (!rgba) return 0;
const [r, g, b] = rgba;
return 0.2125 * r + 0.7154 * g + 0.0721 * b;
}
export function contrast(background: string, foreground: string, doc?: Document): number {
const bg = luminance(background, doc);
const fg = luminance(foreground, doc);
return (Math.max(bg, fg) + 0.05) / (Math.min(bg, fg) + 0.05);
}
/** Alpha of 20% or less counts as transparent (and so does "not a colour"). */
export function isTransparent(color: string, doc?: Document): boolean {
const rgba = parseCssColor(color, doc);
return !rgba || rgba[3] <= 0.2;
}
// ---- adapting a message -----------------------------------------------------------
/** Take away the colours of one style (inline, or a style-sheet rule) that would
* not read on a dark page. */
export function sanitizeStyle(style: CSSStyleDeclaration, doc?: Document): void {
const get = (property: string) => style.getPropertyValue(property);
if (!get("color") && !get("background") && !get("background-color")) return;
// Text colour with no background of its own: keep it only if it is light.
if (
(!get("background") || get("background") === "none") &&
(!get("background-color") || isTransparent(get("background-color"), doc))
) {
if (luminance(get("color"), doc) <= LUMINANCE_THRESHOLD) style.removeProperty("color");
return;
}
const dropBackground = (property: "background-color" | "background") => {
const value = get(property);
if (!value || !parseCssColor(value, doc)) return; // none, or a picture etc.: kept
if (
luminance(value, doc) > LUMINANCE_THRESHOLD ||
(get("color") && contrast(get("color"), value, doc) < CONTRAST_THRESHOLD)
) {
style.removeProperty(property);
// On the dark page now: its text colour must be light too.
if (get("color") && luminance(get("color"), doc) <= LUMINANCE_THRESHOLD) {
style.removeProperty("color");
}
}
};
dropBackground("background-color");
dropBackground("background");
// No chances with gradients behind text.
if (get("background").includes("gradient") || get("background-image").includes("gradient")) {
style.removeProperty("background");
style.removeProperty("background-image");
}
}
function sanitizeRules(rules: CSSRuleList, doc: Document): void {
for (const rule of Array.from(rules)) {
if ("selectorText" in rule && "style" in rule) sanitizeStyle((rule as CSSStyleRule).style, doc);
// @media, @supports…: their rules too.
else if ("cssRules" in rule) sanitizeRules((rule as CSSGroupingRule).cssRules, doc);
}
}
/** Adapt a rendered message document (with DARK_MESSAGE_CSS in its head) for
* dark mode. */
export function adaptForDarkMode(doc: Document): void {
const body = doc.body;
if (!body) return;
for (const attribute of ["bgcolor", "text", "link", "vlink"]) body.removeAttribute(attribute);
body.style.removeProperty("background-color");
body.style.removeProperty("color");
for (const node of Array.from(
doc.querySelectorAll<HTMLElement>(
':not(button):is([style*="color"],[style*="background"],[bgcolor],[color])',
),
)) {
node.removeAttribute("bgcolor"); // tables, mostly
node.removeAttribute("color"); // <font color>
if (node.hasAttribute("style")) sanitizeStyle(node.style, doc);
}
for (const node of Array.from(doc.getElementsByTagName("text"))) {
const fill = node.getAttribute("fill");
if (fill && luminance(fill, doc) <= LUMINANCE_THRESHOLD) node.setAttribute("fill", "currentColor");
}
for (const node of Array.from(doc.getElementsByTagName("style"))) {
if (node.id === "wm-dark-message") continue;
const sheet = (node as HTMLStyleElement).sheet;
if (!sheet) continue;
try {
sanitizeRules(sheet.cssRules, doc);
} catch {
/* an unreadable sheet: left as it is */
}
}
}
/** The message's HTML with the dark page in its head (before it renders, so it
* never flashes white). */
export function withDarkPage(html: string): string {
const at = html.search(/<\/head>/i);
return at < 0 ? DARK_MESSAGE_CSS + html : html.slice(0, at) + DARK_MESSAGE_CSS + html.slice(at);
}
// ---- the switch -----------------------------------------------------------------
// Per device, like the theme (lib/theme.tsx). On unless turned off — as in
// Thunderbird, where mail.dark-reader.enabled defaults to true.
const KEY = "darkMessages";
const listeners = new Set<() => void>();
function read(): boolean {
try {
return localStorage.getItem(KEY) !== "0";
} catch {
return true;
}
}
export function setDarkMessages(on: boolean): void {
try {
localStorage.setItem(KEY, on ? "1" : "0");
} catch {
/* kept for this page only */
}
listeners.forEach((l) => l());
}
export function useDarkMessages(): boolean {
return useSyncExternalStore(
(cb) => {
listeners.add(cb);
const onStorage = (e: StorageEvent) => e.key === KEY && cb();
window.addEventListener("storage", onStorage);
return () => {
listeners.delete(cb);
window.removeEventListener("storage", onStorage);
};
},
read,
() => true,
);
}
+4
View File
@@ -13,6 +13,8 @@ export interface UserOut {
settings: Record<string, unknown>;
totp_enabled: boolean;
is_admin: boolean;
/** The hosted mail server's name for mail apps; null without hosted mailboxes. */
mail_host?: string | null;
}
export interface AdminUser {
@@ -126,6 +128,8 @@ export interface AccountOut {
sync_error?: string | null;
sync_error_since?: string | null;
state_detail?: string;
/** "native": the user's own hosted mailbox — permanent, its settings fixed. */
kind?: "external" | "native";
}
/** A send the server could not take: waiting in Scheduled (retrying, or failed). */
+5 -2
View File
@@ -1054,10 +1054,13 @@ export function MailLayout() {
{/* Per-account views are hidden entirely when collapsed. */}
{!collapsed && (
<>
<div className="px-3 pb-1 pt-3 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
<div className="px-3 pb-1 pt-6 text-[12px] font-semibold uppercase tracking-wide text-app-faint">
{t("settings.accounts")}
</div>
{accounts.map((a) => (
{/* Alphabetical by the address shown. */}
{[...accounts]
.sort((x, y) => x.email.localeCompare(y.email, undefined, { sensitivity: "base" }))
.map((a) => (
<SidebarItem
key={a.id}
active={view === `account:${a.id}`}
+50 -16
View File
@@ -516,7 +516,9 @@ function OfflineAccessSetting() {
*/
function AppPasswordsSetting() {
const t = useT();
const handle = useMe().data?.handle ?? "";
const me = useMe().data;
const handle = me?.handle ?? "";
const mailHost = me?.mail_host ?? null;
const queryClient = useQueryClient();
const list = useQuery({ queryKey: ["app-passwords"], queryFn: api.appPasswords });
const [creating, setCreating] = useState(false);
@@ -558,7 +560,28 @@ function AppPasswordsSetting() {
return (
<div className="space-y-2">
<h2 className="text-lg font-semibold">{t("appPasswords.title")}</h2>
<p className="text-sm text-app-muted">{t("appPasswords.help")}</p>
<p className="text-sm text-app-muted">{t(mailHost ? "appPasswords.helpMail" : "appPasswords.help")}</p>
{/* What to type into a mail app (the hosted mailbox, SPEC App. D §D.5). */}
{mailHost && (
<div className="space-y-1.5 rounded-lg border border-app-border p-3 text-sm">
<p className="font-medium">{t("appPasswords.mailTitle")}</p>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<dt className="text-app-muted">{t("appPasswords.mailIncoming")}</dt>
<dd className="break-all font-mono text-[13px]">
{t("appPasswords.mailPorts", { host: mailHost, port: 993 })}
</dd>
<dt className="text-app-muted">{t("appPasswords.mailOutgoing")}</dt>
<dd className="break-all font-mono text-[13px]">
{t("appPasswords.mailPorts", { host: mailHost, port: 465 })}
<span className="block text-app-muted">{t("appPasswords.mailPortsAlt", { port: 587 })}</span>
</dd>
<dt className="text-app-muted">{t("appPasswords.connectUser")}</dt>
<dd className="select-all break-all font-mono text-[13px]">{handle}</dd>
<dt className="text-app-muted">{t("appPasswords.connectPassword")}</dt>
<dd className="text-app-muted">{t("appPasswords.connectPasswordValue")}</dd>
</dl>
</div>
)}
{/* What to type into a calendar / contacts app (CalDAV / CardDAV, SPEC App. E). */}
<div className="space-y-1.5 rounded-lg border border-app-border p-3 text-sm">
<p className="font-medium">{t("appPasswords.connectTitle")}</p>
@@ -578,10 +601,10 @@ function AppPasswordsSetting() {
<p className="text-sm">{t("appPasswords.madeFor", { name: made.name })}</p>
<div className="flex flex-wrap items-center gap-3">
{/* Shown in groups of four; copied (and entered) without spaces. */}
<code className="select-all rounded bg-app-surface px-3 py-2 font-mono text-lg tracking-wider">
<code className="select-all break-all rounded bg-app-surface px-3 py-2 font-mono text-lg tracking-wider">
{made.secret.match(/.{1,4}/g)?.map((group, i) => (
// Spacing only, no characters: copying by hand gives the bare letters.
<span key={i} className={i ? "ml-2.5" : ""}>
<span key={i} className={`inline-block ${i ? "ml-2.5" : ""}`}>
{group}
</span>
))}
@@ -888,6 +911,14 @@ function AccountsSection() {
<span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: a.color }} />
<span className="min-w-0 break-all">{a.email}</span>
{a.kind === "native" && (
<span
className="rounded bg-app-accent/15 px-1.5 py-0.5 text-[11px] font-medium text-app-accent"
title={t("settings.yourMailboxHint")}
>
{t("settings.yourMailbox")}
</span>
)}
{a.state === "auth_failed" ? (
<span className="text-xs text-red-500" title={a.state_detail || undefined}>
{t("settings.stateAuthFailed")}
@@ -932,17 +963,20 @@ function AccountsSection() {
>
{t("settings.configureAccount")}
</button>
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) {
setDeletingId(a.id);
remove.mutate(a.id);
}
}}
>
{t("common.remove")}
</button>
{/* The user's own mailbox is permanent (SPEC App. D §D.4). */}
{a.kind !== "native" && (
<button
className="text-xs text-red-500"
onClick={() => {
if (confirm(t("settings.confirmRemoveAccount"))) {
setDeletingId(a.id);
remove.mutate(a.id);
}
}}
>
{t("common.remove")}
</button>
)}
</span>
</div>
)}
@@ -1050,7 +1084,7 @@ function AccountEditor({
/>
</div>
{!showServer ? (
{detail.data.kind === "native" ? null : !showServer ? (
<button className="text-xs text-app-accent" onClick={() => setShowServer(true)}>
{t("settings.serverSettings")}
</button>
+4 -1
View File
@@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";
import { ThreadView } from "../components/thread/ThreadView";
import { I18nProvider } from "../i18n";
import { ThemeProvider } from "../lib/theme";
import type { MessageEnvelope } from "../lib/types";
function msg(id: string, minutes: number, unread = false): MessageEnvelope {
@@ -38,7 +39,9 @@ function wrap(children: ReactNode) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return (
<I18nProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
<ThemeProvider>
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
</ThemeProvider>
</I18nProvider>
);
}
+16
View File
@@ -12,3 +12,19 @@ if (typeof globalThis.Worker === "undefined") {
}
(globalThis as unknown as { Worker: unknown }).Worker = TestWorker;
}
// jsdom has no matchMedia; the theme (lib/theme.tsx) asks it for the system's
// light/dark preference. Answers "no match" (a light system).
if (typeof window !== "undefined" && typeof window.matchMedia !== "function") {
window.matchMedia = (query: string) =>
({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
}) as MediaQueryList;
}