attachments preview overlay, docx/xlsx/odf rendering, composer drag-drop & undo-send UX

Frontend:
- Full-screen attachment preview overlay (AttachmentPreview) for image/pdf/txt
  plus office docs: .docx via docx-preview (rendered live into the sandboxed
  iframe so header/footer VML images survive), .xlsx via xlsx-preview, and
  .odt/.ods/.odp/.odg via a JSZip+DOMParser ODF walker (officeConvert).
- Type-aware attachment icons (green spreadsheet, blue document, red PDF,
  image/archive); new document/image/spreadsheet/print SVGs.
- Composer: drag-drop attachments (body drop inserts images inline, else
  attaches); inserted images are selectable/resizable/deletable (RichTextEditor).
- Undo-send: "Sending…" banner with live "N seconds to Undo" countdown.
- Search result list: pin/archive/delete update correctly; grey-out only for
  search hits; scoped "Search in Trash/Spam".

Backend:
- Search: prefix (search-as-you-type) matching, email-address search, and
  address-first result ordering; Trash/Junk excluded with scoped folder search.
- Attachments: RFC 6266 Content-Disposition for non-ASCII filenames; one-time
  inline image loading.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rcj3TpydMTzTSmU7kunzf8
This commit is contained in:
2026-07-26 16:15:49 +02:00
co-authored by Claude Opus 4.8
parent c023cfa15e
commit dd0b089049
22 changed files with 2726 additions and 82 deletions
+37 -12
View File
@@ -65,6 +65,9 @@ export function MailLayout() {
// Mail-list selection (checkboxes). Lives here so the reading pane can show the
// bulk-action bar / count while any conversation is selected.
const [checkedIds, setCheckedIds] = useState<Set<string>>(new Set());
// Rows just archived/trashed/deleted from the list: greyed out and unclickable
// for immediate feedback (search results don't always drop the row on refetch).
const [dimmedIds, setDimmedIds] = useState<Set<string>>(new Set());
const toggleCheck = (id: string) =>
setCheckedIds((prev) => {
const next = new Set(prev);
@@ -116,9 +119,16 @@ export function MailLayout() {
queryFn: api.remoteDrafts,
enabled: isDrafts,
});
// Trash/Spam are excluded from normal search, so while viewing them the search
// box scopes to that folder instead (via an in: operator) and searches only it.
const searchScope =
currentFolder === "trash" ? "in:trash" : currentFolder === "junk" ? "in:junk" : "";
const effectiveQuery = searchScope
? `${searchScope} ${debouncedTerm}`.trim()
: debouncedTerm;
const searchQuery = useQuery({
queryKey: ["search", debouncedTerm],
queryFn: () => api.search(debouncedTerm),
queryKey: ["search", effectiveQuery],
queryFn: () => api.search(effectiveQuery),
enabled: searching,
});
const savedSearchesQuery = useQuery({ queryKey: ["saved-searches"], queryFn: api.savedSearches });
@@ -192,6 +202,13 @@ export function MailLayout() {
setTerm(s.query);
navigate("/mail");
};
// Switch folder/account view. Clears any active search so the folder actually
// loads — otherwise the list stays stuck on (now-stale) search results.
const selectView = (v: string) => {
setTerm("");
setView(v);
navigate("/mail");
};
// Auto-mark an opened thread read after a short dwell (4s), not instantly — and
// cancel it if you leave before then, so a quick glance doesn't mark it read.
@@ -289,13 +306,18 @@ export function MailLayout() {
detail.pinned ? ["\\Flagged"] : [],
);
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["search"] });
queryClient.invalidateQueries({ queryKey: ["thread", detail.id] });
};
// Per-thread actions for the mail-list hover buttons (act on a given id, not the
// currently-open thread). Close the reading pane only if the acted thread is open.
const listAfterMove = (id: string) => {
// In a folder the refetch below removes the row (it left the folder's scope).
// In search the row may still match a global query, so grey it out for feedback.
if (searching) setDimmedIds((prev) => new Set(prev).add(id));
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["search"] }); // keep search results in sync
queryClient.invalidateQueries({ queryKey: ["quota"] });
queryClient.invalidateQueries({ queryKey: ["folder-counts"] });
if (id === threadId) navigate("/mail");
@@ -319,11 +341,13 @@ export function MailLayout() {
const listTogglePin = async (id: string, pinned: boolean) => {
await api.threadFlags(id, pinned ? [] : ["\\Flagged"], pinned ? ["\\Flagged"] : []);
queryClient.invalidateQueries({ queryKey: ["threads"] });
queryClient.invalidateQueries({ queryKey: ["search"] }); // pin state on search results
queryClient.invalidateQueries({ queryKey: ["thread", id] });
};
// Selection resets when you leave the folder.
useEffect(() => setCheckedIds(new Set()), [view]);
useEffect(() => setDimmedIds(new Set()), [view, debouncedTerm]);
// Bulk actions run the given operation over every selected thread, then clear the
// selection and refresh (navigating away if the open thread was among them).
@@ -382,10 +406,7 @@ export function MailLayout() {
onReplyAll: () => setReplySignal({ kind: "reply_all", seq: Date.now() }),
onForward: () => setReplySignal({ kind: "forward", seq: Date.now() }),
onArchive: archive,
onGoInbox: () => {
setView("folder:inbox");
navigate("/mail");
},
onGoInbox: () => selectView("folder:inbox"),
onEscape: () => setComposing(null),
onNext: () => openRelative(1),
onPrev: () => openRelative(-1),
@@ -447,10 +468,7 @@ export function MailLayout() {
active={view === f.view}
collapsed={collapsed}
title={collapsed ? t(f.label) : undefined}
onClick={() => {
setView(f.view);
navigate("/mail");
}}
onClick={() => selectView(f.view)}
>
<Icon name={f.icon} size={f.icon === "trash" ? 20 : 18} />
{!collapsed && <span className="flex-1 truncate">{t(f.label)}</span>}
@@ -470,7 +488,7 @@ export function MailLayout() {
<SidebarItem
key={a.id}
active={view === `account:${a.id}`}
onClick={() => { setView(`account:${a.id}`); navigate("/mail"); }}
onClick={() => selectView(`account:${a.id}`)}
>
<AccountDot
color={a.color}
@@ -569,7 +587,13 @@ export function MailLayout() {
className={`w-full rounded-lg border border-app-border bg-app-bg px-3 py-1.5 text-[15px] text-app-fg placeholder:text-app-faint focus:border-app-accent focus:outline-none ${
term ? "pr-14" : "pr-8"
}`}
placeholder={t("nav.search")}
placeholder={
currentFolder === "trash"
? t("nav.searchInTrash")
: currentFolder === "junk"
? t("nav.searchInSpam")
: t("nav.search")
}
value={term}
onChange={(e) => setTerm(e.target.value)}
/>
@@ -683,6 +707,7 @@ export function MailLayout() {
onDeletePermanent={listDeletePermanent}
onTogglePin={listTogglePin}
checkedIds={checkedIds}
dimmedIds={dimmedIds}
onToggleCheck={toggleCheck}
/>
)}