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
+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}