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. */}
-
-