Security hardening from the assessment; testing fixes

Security (ultimo-security-report.md):
- Security headers on every response, the app shell included: they lived at
  server level and nginx dropped them in any location with its own header.
  CSP, framing, nosniff, referrer, permissions (docker/nginx-security.conf).
- Plain HTTP forwarded by the reverse proxy is redirected to HTTPS; HSTS on
  HTTPS responses. server_tokens off.
- Cross-site state-changing /api requests refused (login CSRF), WebSocket too.
- Validation errors in the usual error shape, without the request model.
- New-password policy: max 128, common/own-address/repeated refused,
  breached-password check (HIBP k-anonymity, fails open).
- Message HTML: links keep only http(s)/mailto (sanitizer v6); the HTML
  endpoint carries its own no-script CSP.
- Outbound fetches pinned to the checked IP (DNS rebinding); external
  IMAP/SMTP servers re-checked before each connection; CGNAT and mapped
  addresses counted as non-public.

Fixes from testing:
- IDLE loop catches up before idling: mail that came while the worker was
  away no longer waits up to 10 minutes.
- Restore from Spam/Trash/Archive moves only the removed messages, not your
  reply in Sent.
- Narrow desktop list stacks sender/subject/snippet (subject was squeezed
  to nothing at the default width), with hover actions.
- Phone: wide emails scale to fit the screen.
- Singular forms ("1 message"); no zip button for a single attachment;
  app passwords no longer claim "never used" for mail apps.
- ruff format across the backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 22:08:06 +02:00
co-authored by Claude Opus 5.5
parent 30b806e7da
commit addb2232a1
54 changed files with 1265 additions and 187 deletions
+66 -2
View File
@@ -31,6 +31,8 @@ function initials(p: Address | undefined): string {
}
// Past this many characters the sender column switches to first names only.
// List width (px) below which rows stack instead of using one line.
const STACK_BELOW = 560;
const SENDERS_FULL_MAX = 25;
// A lone sender's name longer than this is cut to one character less plus "...".
const SINGLE_SENDER_MAX = 22;
@@ -119,6 +121,11 @@ export function MessageList({
}) {
const t = useT();
const parentRef = useRef<HTMLDivElement>(null);
// A list narrower than this has no room for the one-line row (a 200px sender
// column and the action rail would leave the subject no width at all): it
// stacks sender, subject and snippet as on a phone.
const [narrow, setNarrow] = useState(false);
const stacked = compact || narrow;
const swipe = useSwipeToDelete(onSwipeDelete);
// In Trash/Spam/Archive the middle action restores; elsewhere it archives.
const isRemovalFolder = folder === "trash" || folder === "junk" || folder === "archive";
@@ -162,6 +169,15 @@ export function MessageList({
parentRef.current?.scrollTo({ top: 0 });
}, [page]);
const empty = threads.length === 0;
useEffect(() => {
const el = parentRef.current;
if (!el) return;
const ro = new ResizeObserver(() => setNarrow(el.clientWidth < STACK_BELOW));
ro.observe(el);
return () => ro.disconnect();
}, [empty]);
if (threads.length === 0) {
return <div className="p-6 text-[15px] text-app-faint">{t("mail.noConversations")}</div>;
}
@@ -309,7 +325,7 @@ export function MessageList({
</button>
</div>
{compact ? (
{stacked ? (
<div className="flex min-w-0 flex-1 flex-col justify-center">
<div className="flex min-w-0 items-center gap-1.5">
<span
@@ -324,7 +340,11 @@ export function MessageList({
{thread.message_count}
</span>
)}
<span className="ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-app-faint">
<span
className={`ml-auto flex shrink-0 items-center gap-1.5 pl-2 text-app-faint ${
compact ? "" : "group-hover:hidden"
}`}
>
{thread.has_attachments && <Paperclip />}
{thread.pinned && (
<Icon name="pin" size={15} className="text-[rgb(220_129_143)]" />
@@ -337,6 +357,50 @@ export function MessageList({
{timeLabel(thread.last_message_at)}
</span>
</span>
{/* A narrow desktop list: the actions take the date's place on hover. */}
{!compact && (
<span className="ml-auto hidden shrink-0 items-center gap-3 pl-2 group-hover:flex">
<button
type="button"
title={thread.pinned ? t("mail.unpin") : t("mail.pin")}
onClick={stop(() => onTogglePin(thread.id, thread.pinned))}
className={`wm-online-only ${
thread.pinned ? "text-[rgb(220_129_143)]" : "text-app-muted hover:text-app-fg"
}`}
>
<Icon name="pin" size={16} />
</button>
{isRemovalFolder ? (
<button
type="button"
title={t("mail.restore")}
onClick={stop(() => onRestore(thread.id))}
className="wm-online-only text-app-muted hover:text-app-fg"
>
<Icon name="restore" size={16} />
</button>
) : (
<button
type="button"
title={t("mail.archive")}
onClick={stop(() => onArchive(thread.id))}
className="wm-online-only text-app-muted hover:text-app-fg"
>
<Icon name="archive" size={14} />
</button>
)}
<button
type="button"
title={t("mail.delete")}
onClick={stop(() =>
folder === "trash" ? onDeletePermanent(thread.id) : onTrash(thread.id),
)}
className="wm-online-only text-app-muted hover:text-app-fg"
>
<Icon name="trash" size={16} />
</button>
</span>
)}
</div>
<div
className={`flex min-w-0 items-center gap-1.5 text-[14px] ${
@@ -532,7 +532,8 @@ function PdfView({
try {
const buf = await (await fetch(url, { credentials: "include" })).arrayBuffer();
if (cancelled) return;
const doc = await pdfjs.getDocument({ data: buf, password }).promise;
// No eval: the Content-Security-Policy forbids it (docker/nginx-security.conf).
const doc = await pdfjs.getDocument({ data: buf, password, isEvalSupported: false }).promise;
if (cancelled) return;
docRef.current = doc;
setNeedPassword(false);
@@ -351,6 +351,8 @@ export function Attachments({ messageId }: { messageId: string }) {
/>
))}
</div>
{/* One file has its own download button: a zip of it would add nothing. */}
{attachments.length > 1 && (
<div className="mt-2">
<a
href={api.attachmentsZipUrl(messageId)}
@@ -360,6 +362,7 @@ export function Attachments({ messageId }: { messageId: string }) {
{t("mail.downloadAll", { count: attachments.length })}
</a>
</div>
)}
{previewIdx !== null && (
<Suspense fallback={null}>
<AttachmentPreview
+41 -2
View File
@@ -6,6 +6,7 @@ import { api } from "../../lib/api";
import { adaptForDarkMode, useDarkMessages, withDarkPage } from "../../lib/darkMessage";
import { fixInvisibleText } from "../../lib/readability";
import { useTheme } from "../../lib/theme";
import { useIsMobile } from "../../lib/useIsMobile";
import type { MessageEnvelope } from "../../lib/types";
import { Attachments } from "./Attachments";
@@ -38,6 +39,8 @@ export function MessageBody({
const dark = useTheme().resolved === "dark" && darkMessages;
const darkRef = useRef(dark);
darkRef.current = dark;
const mobileRef = useRef(false);
mobileRef.current = useIsMobile();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["message-html", message.id, allowed],
@@ -64,13 +67,15 @@ export function MessageBody({
// Float-accurate content bottom (getBoundingClientRect keeps sub-pixels that
// scrollHeight would floor), combined with the integer scroll/offset heights.
const rectBottom = body ? body.getBoundingClientRect().bottom : 0;
// The body's own heights are before a phone's fit-to-width scaling (below).
const zoom = Number(body?.style.zoom) || 1;
let h = Math.ceil(
Math.max(
rectBottom,
de.scrollHeight,
de.offsetHeight,
body?.scrollHeight ?? 0,
body?.offsetHeight ?? 0,
(body?.scrollHeight ?? 0) * zoom,
(body?.offsetHeight ?? 0) * zoom,
),
);
// If the email overflows horizontally, its bottom scrollbar steals vertical
@@ -81,6 +86,26 @@ export function MessageBody({
setHeight(h);
}, []);
// Phone: an email laid out wider than the screen (a fixed-width table, one
// long file name that must not wrap) is scaled down to fit, as phone mail
// apps do, instead of being cut off at the right edge. Its no-wrap cells may
// then wrap, so one long name no longer squeezes the other columns to a
// letter per line. On a desktop a wide email scrolls sideways, as before.
const fitWidth = useCallback((doc: Document) => {
const de = doc.documentElement;
const body = doc.body;
if (!body) return;
body.style.zoom = "";
doc.getElementById("wm-fit")?.remove();
if (!mobileRef.current || de.scrollWidth <= de.clientWidth + 1) return;
const scale = Math.max(0.5, de.clientWidth / de.scrollWidth);
const relax = doc.createElement("style");
relax.id = "wm-fit";
relax.textContent = "td,th{white-space:normal!important}";
doc.head.appendChild(relax);
body.style.zoom = String(scale);
}, []);
// Hide the quoted original by default and drop a "•••" toggle right after the
// "On <date>, <sender> wrote:" attribution (inside the white body), so the reader
// sees only the new content until they choose to reveal the quote.
@@ -148,6 +173,7 @@ export function MessageBody({
fixInvisibleText(doc); // white-on-white from a dark-mode sender (lib/readability.ts)
}
injectQuoteToggle(doc);
fitWidth(doc);
resize();
// Re-measure after the first paint settles (fonts/layout can shift the height
// by a hair, which is enough to leave a spurious scrollbar).
@@ -171,9 +197,22 @@ export function MessageBody({
const doc = frameRef.current?.contentDocument;
if (!doc) return;
prepare(doc);
fitWidth(doc); // images may have widened it
resize(); // everything has loaded: one last measure
};
// A phone turned sideways: fit to the new width.
useEffect(() => {
const refit = () => {
const doc = frameRef.current?.contentDocument;
if (preparedRef.current !== doc || !doc) return;
fitWidth(doc);
resize();
};
window.addEventListener("resize", refit);
return () => window.removeEventListener("resize", refit);
}, [fitWidth, resize]);
// Watch for the message's document to be parsed (the frame starts on an
// empty about:blank document, which does not count).
useEffect(() => {
+10
View File
@@ -130,6 +130,7 @@ export const en = {
"mail.selectAll": "Select all",
"mail.conversationSelected": "1 conversation selected",
"mail.conversationsSelected": "{count} conversations selected",
"mail.conversationsSelected_one": "1 conversation selected",
"mail.alsoIn": "Also delivered to",
"mail.emptyTrash": "Empty trash",
"mail.emptySpam": "Empty spam",
@@ -201,6 +202,7 @@ export const en = {
"mail.hideQuoted": "Hide quoted text",
"mail.markReadUnread": "Mark read / unread",
"mail.messageCount": "{count} messages",
"mail.messageCount_one": "1 message",
"mail.downloadAll": "Download all {count} attachments",
"mail.downloadZip": "Download zip",
"mail.download": "Download",
@@ -218,6 +220,7 @@ export const en = {
"compose.closePrompt": "Save this message as a draft?",
"compose.sent": "Message sent.",
"compose.undoIn": "{count} seconds to",
"compose.undoIn_one": "1 second to",
"compose.undo": "Undo",
"compose.sendFailed": "Send failed.",
"compose.savingDraft": "Saving draft…",
@@ -419,6 +422,7 @@ export const en = {
"appPasswords.created": "Created {date}",
"appPasswords.lastUsed": "last used {date}",
"appPasswords.neverUsed": "never used",
"appPasswords.lastUsedDav": "calendars or contacts last synced {date}",
"appPasswords.confirmRevoke": "Revoke the app password “{name}”? The app using it will stop signing in.",
"settings.thisDevice": "this device",
"settings.revoke": "Revoke",
@@ -474,6 +478,7 @@ export const en = {
"settings.icloudHint":
"Use an app-specific password from account.apple.com → Sign-In and Security.",
"settings.calendarCount": "{count} calendars",
"settings.calendarCount_one": "1 calendar",
"settings.removeSubscription": "Remove subscription",
"settings.removeSubscriptionConfirm": "Stop syncing “{name}” and remove its events?",
"settings.deleteCalendar": "Delete calendar",
@@ -597,6 +602,7 @@ export const en = {
"offline.folderNotSaved": "This folder isn't saved on this device.",
"offline.noticeScheduled": "Saved to Scheduled — it will be sent when you're back online.",
"offline.noticeSendingScheduled": "Sending {count} scheduled messages…",
"offline.noticeSendingScheduled_one": "Sending 1 scheduled message…",
"offline.draftsNotSaved": "Only drafts written on this device are available offline.",
"compose.sendLater": "Schedule",
"compose.savedOnDevice": "Saved on this device",
@@ -615,6 +621,8 @@ export const en = {
"scheduled.delete": "Delete",
"scheduled.statusRetrying":
"The mail server couldn't take this message yet ({count} tries). Next try {when}; it keeps trying until {until}.",
"scheduled.statusRetrying_one":
"The mail server couldn't take this message yet (1 try). Next try {when}; it keeps trying until {until}.",
"scheduled.statusFailed": "Not sent: {error}",
"scheduled.lastError": "Last error: {error}",
"scheduled.to": "To",
@@ -631,7 +639,9 @@ export const en = {
"offline.settingHelp":
"Keep your newest 100 Inbox conversations, and the ones you open, on this device, so you can read them without a connection. They are stored unencrypted in this browser and removed when you sign out — leave this off on shared computers.",
"offline.settingStatus": "{count} conversations saved · {size} · updated {when}",
"offline.settingStatus_one": "1 conversation saved · {size} · updated {when}",
"offline.settingStatusNoTime": "{count} conversations saved · {size}",
"offline.settingStatusNoTime_one": "1 conversation saved · {size}",
"offline.settingSaving": "Saving recent mail…",
"offline.settingUnsupported":
"Not available here: browsers allow offline access only when the app is opened over HTTPS.",
+9 -1
View File
@@ -35,7 +35,15 @@ export function I18nProvider({ children }: { children: ReactNode }) {
const value = useMemo<I18nContextValue>(() => {
const catalog = catalogs[locale] ?? en;
const t: TFunc = (key, vars) => interpolate(catalog[key] ?? en[key] ?? key, vars);
// A count of exactly one picks the "<key>_one" form where there is one
// ("1 message", not "1 messages").
const lookup = (key: string): string | undefined =>
(catalog as Record<string, string>)[key] ?? (en as Record<string, string>)[key];
const t: TFunc = (key, vars) =>
interpolate(
(vars?.count === 1 ? lookup(`${key}_one`) : undefined) ?? lookup(key) ?? key,
vars,
);
return {
locale,
setLocale: (l) => {
+7 -3
View File
@@ -630,10 +630,14 @@ function AppPasswordsSetting() {
<span className="min-w-0">
<span className="break-words">{a.name}</span>
<span className="block text-xs text-app-faint">
{t("appPasswords.created", { date: date(a.created_at) })} ·{" "}
{t("appPasswords.created", { date: date(a.created_at) })}
{/* Only calendar and contacts sign-ins are seen here: the mail
server checks IMAP/SMTP ones itself and keeps no record. */}
{a.last_used_at
? t("appPasswords.lastUsed", { date: date(a.last_used_at) })
: t("appPasswords.neverUsed")}
? ` · ${t(mailHost ? "appPasswords.lastUsedDav" : "appPasswords.lastUsed", { date: date(a.last_used_at) })}`
: mailHost
? ""
: ` · ${t("appPasswords.neverUsed")}`}
</span>
</span>
<button
+24
View File
@@ -0,0 +1,24 @@
// A count of one reads in the singular ("1 message"), any other in the plural.
import { renderHook } from "@testing-library/react";
import type { ReactNode } from "react";
import { describe, expect, it } from "vitest";
import { I18nProvider, useT } from "../i18n";
const wrapper = ({ children }: { children: ReactNode }) => <I18nProvider>{children}</I18nProvider>;
describe("t()", () => {
it("uses the singular form for a count of one", () => {
const { result } = renderHook(() => useT(), { wrapper });
const t = result.current;
expect(t("mail.messageCount", { count: 1 })).toBe("1 message");
expect(t("mail.messageCount", { count: 2 })).toBe("2 messages");
expect(t("mail.messageCount", { count: 0 })).toBe("0 messages");
});
it("falls back to the plain string where there is no singular", () => {
const { result } = renderHook(() => useT(), { wrapper });
expect(result.current("mail.downloadAll", { count: 1 })).toBe("Download all 1 attachments");
});
});
+3
View File
@@ -25,6 +25,9 @@ export default defineConfig({
target: "http://127.0.0.1:8000",
changeOrigin: true,
ws: true,
// X-Forwarded-Host: the API's cross-site check (app/security/cross_site.py)
// compares the page's Origin with the name the browser used.
xfwd: true,
},
// CalDAV / CardDAV (SPEC Appendix E), for trying clients against dev.
"/dav": { target: "http://127.0.0.1:8000", changeOrigin: true },