diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 4a0a880..0916446 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -163,6 +163,8 @@ "draftSaved": "Draft saved automatically", "draftSaving": "Saving draft...", "drafts": "Drafts", + "deleteForever": "Delete permanently", + "continueDraft": "Continue writing", "emptyBody": "This message has no text body.", "forward": "Forward", "imagesEnabled": "Images loaded", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index d4ba9e1..d83e5fe 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -163,6 +163,8 @@ "draftSaved": "Borrador guardado automáticamente", "draftSaving": "Guardando borrador...", "drafts": "Borradores", + "deleteForever": "Eliminar definitivamente", + "continueDraft": "Seguir escribiendo", "emptyBody": "Este mensaje no tiene cuerpo de texto.", "forward": "Reenviar", "imagesEnabled": "Imágenes cargadas", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 12b6463..2a54018 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -163,6 +163,8 @@ "draftSaved": "Rascunho salvo automaticamente", "draftSaving": "Salvando rascunho...", "drafts": "Rascunhos", + "deleteForever": "Excluir definitivamente", + "continueDraft": "Continuar escrevendo", "emptyBody": "Esta mensagem não tem corpo de texto.", "forward": "Encaminhar", "imagesEnabled": "Imagens carregadas", diff --git a/services/webmail/src/app/(user)/user/compose/page.tsx b/services/webmail/src/app/(user)/user/compose/page.tsx index 6a6b815..c6518f8 100644 --- a/services/webmail/src/app/(user)/user/compose/page.tsx +++ b/services/webmail/src/app/(user)/user/compose/page.tsx @@ -38,7 +38,7 @@ interface AttachedFile { export default function ComposePage({ searchParams, }: { - searchParams?: Promise<{ replyTo?: string; subject?: string; inReplyTo?: string }>; + searchParams?: Promise<{ replyTo?: string; subject?: string; inReplyTo?: string; draft?: string }>; }) { const t = useTranslations(); const params = searchParams ? use(searchParams) : {}; @@ -76,6 +76,43 @@ export default function ComposePage({ } }, []); + // Reopening a draft loads what was written into the composer, and adopts its + // UID so carrying on writing replaces that draft instead of leaving the old + // copy behind and starting a second one. + // + // Opening a draft used to land in the reader, which offered to reply to the + // half-written message and gave no way to finish it. + const draftParam = params?.draft; + useEffect(() => { + const uid = Number(draftParam); + if (!Number.isFinite(uid) || uid <= 0) return; + + let cancelled = false; + void (async () => { + try { + const res = await fetch(`/api/v1/mail/message/${uid}?folder=drafts`, { cache: "no-store" }); + if (!res.ok || cancelled) return; + const message = await res.json(); + if (cancelled) return; + + draftUidRef.current = uid; + setTo((message.to ?? []).join(", ")); + setCc((message.cc ?? []).join(", ")); + if ((message.cc ?? []).length > 0) setShowCcBcc(true); + setSubject(message.subject ?? ""); + const html = message.html || message.text || ""; + if (editorRef.current) editorRef.current.innerHTML = html; + setBody(html); + } catch { + // A draft that cannot be loaded leaves an empty composer rather than + // a broken screen; the draft itself is untouched on the server. + } + })(); + return () => { + cancelled = true; + }; + }, [draftParam]); + // Saves the draft for real. This used to be a setTimeout that set the words // "draft saved automatically" and nothing else - no request, no storage - so // closing the tab lost the message while the screen had just said otherwise. diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx index 2c301e5..2912750 100644 --- a/services/webmail/src/app/(user)/user/layout.tsx +++ b/services/webmail/src/app/(user)/user/layout.tsx @@ -24,7 +24,7 @@ import { useTranslations } from "../../../i18n/provider"; import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; import { useAccount, isAdminRole } from "../../../lib/useAccount"; import { useFolders } from "../../../lib/useFolders"; -import { badgeCount, folderMetrics } from "../../../lib/mailCounts"; +import { folderBadge } from "../../../lib/mailCounts"; import { initialsFor } from "../../../lib/initials"; export default function UserWebmailLayout({ children }: { children: React.ReactNode }) { @@ -88,10 +88,11 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN {navItems.map((item) => { const Icon = item.icon; const isActive = pathname === item.href || (item.role === "inbox" && pathname === "/user/mail"); - // Drafts counts what is waiting rather than what is unread; see - // badgeCount, which is where that rule is tested. - const badge = badgeCount(folders, item.role); - const { total } = folderMetrics(folders, item.role); + // Both numbers plus which one to emphasise; see folderBadge, where + // that rule is tested. It used to fall back to printing the total in + // the unread badge's place, so an inbox of read mail showed "14" and + // reading another message never moved it. + const badge = folderBadge(folders, item.role); return ( setMobileOpen(false)} data-active={isActive} - title={total > 0 ? t("mail.messagesInFolder", { count: total }) : item.label} + title={badge.total > 0 ? t("mail.messagesInFolder", { count: badge.total }) : item.label} className="lm-nav" > @@ -109,19 +110,27 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN {/* The folder's size, shown quietly beside the badge: the sidebar reported unread only, so there was nowhere in the interface that said how much mail a folder holds. */} - {total > 0 && badge > 0 && ( - {total} - )} - {badge > 0 ? ( + {/* The folder size, always in the same quiet style, so it + can never be mistaken for an unread count. */} + {badge.total > 0 && ( - {badge} + {badge.total} + + )} + {/* The unread count, only when there is unread mail, and + visibly distinct from the size beside it. */} + {badge.showsUnread && ( + + {badge.unread} - ) : ( - total > 0 && ( - {total} - ) )} diff --git a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx index 556c33d..27dd335 100644 --- a/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx +++ b/services/webmail/src/app/(user)/user/mail/[folder]/page.tsx @@ -2,6 +2,7 @@ import React, { use, useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { motion, AnimatePresence } from "framer-motion"; import { Search, @@ -14,6 +15,7 @@ import { Clock, MailOpen, Download, + Pencil, RefreshCw, Trash2, X, @@ -27,6 +29,7 @@ import { messageCounts, listHeaderCount, applySeen, + readerActions, type ListFilter, } from "../../../../../lib/mailCounts"; import { sanitizeEmailHtml, blockRemoteImages, unblockRemoteImages } from "../../../../../lib/sanitizer"; @@ -120,6 +123,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s const { folders } = useFolders(); const metrics = folderMetrics(folders, folder); + const router = useRouter(); + // Which buttons this folder's messages deserve. The reader offered the same + // four everywhere, so Sent and Drafts got a mark-as-unread that means + // nothing there, drafts got Reply instead of a way to finish writing, and + // nothing anywhere got a delete. + const actions = readerActions(folder); const loadMessages = useCallback( async (search: string) => { @@ -237,6 +246,12 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s ); const openMessage = async (uid: number) => { + // A draft is an unfinished message: opening it means carrying on writing, + // not reading it in a pane that offers to reply to yourself. + if (actions.canEdit) { + router.push(`/user/compose?draft=${uid}`); + return; + } setLoadRemoteImages(false); setReaderHeight(420); try { @@ -444,7 +459,10 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s - {t("mail.attachments")} + {/* The list knows only that a row has attachments, + not how many, so it names the fact rather than + printing a count it does not have. */} + {t("mail.attachmentsFilter")} )} @@ -490,42 +508,61 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s {/* Toolbar: closing, replying and read state all in one row, so the reader is not the only pane with no way out of itself. */}
- - - + {/* A draft is unfinished, so the thing to do with it is carry + on writing. It used to offer Reply and Forward on the + user's own half-written message and no way to edit it. */} + {actions.canEdit && ( + + + + )} + {actions.canReply && ( + + + + )} {/* Forward carries the subject but no recipient: it used to prefill the original sender, which sends the message straight back to whoever wrote it. */} - - + + )} + {/* Not offered in Sent or Drafts: nothing delivers to those + folders, so an unread flag there says nothing. */} + {actions.canMarkUnread && ( + - - + )}