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:
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user