Wire up the Reply button

The Reply button rendered without a click handler, so it did nothing. Clicking
it now creates a draft addressed to the sender, with a `Re:` subject and the
original message quoted, then opens the composer on that draft.

This reuses the existing pieces rather than adding new ones: POST /api/drafts
resolves the authorized sender from the mailbox, and openDraftComposer() already
knows how to open the composer onto an existing draft. Quoting goes through
getLatestEmailContent() so replying to a thread does not re-quote the whole
chain.

Fixes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Basile VernouilletandClaude Opus 5 committed 2026-08-20 18:05:27 +02:00
1 parent eb5a06d46b
commit 396fa81fcf
4 files changed
+95 -3

No files matched your search

@@ -100,6 +100,9 @@ export default function MessageDetailPage() {
status={message.status}
read={message.read}
unsubscribeUrl={data.unsubscribeUrl}
subject={message.subject}
bodyText={body?.textBody}
ownAddress={ownAddress}
/>
</div>
<article className="px-6 py-4">
@@ -3,12 +3,14 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Archive, Mail, MailOpen, MoreVertical, Reply, ShieldAlert, Trash2 } from "lucide-react";
import { useCompose } from "@/components/compose/compose-context";
import { Button } from "@/components/ui/button";
import { Tooltip } from "@/components/ui/tooltip";
import type { BulkMessageAction } from "@/app/api/messages/bulk/types";
import type { MessageActionsProps } from "./types";
import {
confirmTrashWithoutUnsubscribe,
createReplyDraft,
createTrashSenderRule,
getMessageActionRedirect,
openUnsubscribeUrl,
@@ -23,9 +25,15 @@ export function MessageActions({
status,
read,
unsubscribeUrl,
subject,
bodyText,
ownAddress,
}: MessageActionsProps) {
const router = useRouter();
const [pendingAction, setPendingAction] = useState<BulkMessageAction | "unsubscribe" | null>(null);
const { openDraftComposer } = useCompose();
const [pendingAction, setPendingAction] = useState<
BulkMessageAction | "unsubscribe" | "reply" | null
>(null);
const [error, setError] = useState<string | null>(null);
const [moreOpen, setMoreOpen] = useState(false);
@@ -70,6 +78,25 @@ export function MessageActions({
}
}
async function handleReply() {
setPendingAction("reply");
setError(null);
try {
const draftId = await createReplyDraft({
mailboxId,
senderAddress,
ownAddress,
subject,
bodyText,
});
openDraftComposer(draftId);
} catch (replyError) {
setError(replyError instanceof Error ? replyError.message : "Could not start reply");
} finally {
setPendingAction(null);
}
}
const disabled = pendingAction !== null;
const markAction: BulkMessageAction = read ? "unread" : "read";
@@ -78,7 +105,14 @@ export function MessageActions({
{error && <span className="text-xs text-red-600">{error}</span>}
<div className="flex items-center gap-2">
<Tooltip label="Reply">
<Button type="button" variant="ghost" size="sm" aria-label="Reply">
<Button
type="button"
variant="ghost"
size="sm"
aria-label="Reply"
disabled={disabled}
onClick={handleReply}
>
<Reply className="h-5 w-5" />
</Button>
</Tooltip>
+11
View File
@@ -9,10 +9,21 @@ export type MessageActionsProps = {
status: string;
read: boolean;
unsubscribeUrl?: string | null;
subject?: string | null;
bodyText?: string | null;
ownAddress?: string | null;
};
export type SingleMessageAction = BulkMessageAction | "reply";
export type ReplyDraftInput = {
mailboxId: string | null;
senderAddress: string;
ownAddress?: string | null;
subject?: string | null;
bodyText?: string | null;
};
export type TrashSenderRuleInput = {
mailboxId: string;
senderAddress: string;
+45 -1
View File
@@ -1,7 +1,8 @@
import type { BulkMessageAction } from "@/app/api/messages/bulk/types";
import { authFetch } from "@/lib/auth/client";
import { getEmailAddress } from "@/lib/email/address";
import type { TrashSenderRuleInput } from "./types";
import { getLatestEmailContent } from "@/lib/email/reply-content-utils";
import type { ReplyDraftInput, TrashSenderRuleInput } from "./types";
export function getMessageBackHref(direction: "inbound" | "outbound", status: string) {
if (status === "trash") return "/trash";
@@ -62,3 +63,46 @@ export function getMessageActionRedirect(action: BulkMessageAction, direction: "
if (action === "inbox") return "/inbox";
return null;
}
export function buildReplySubject(subject: string | null | undefined) {
const trimmed = (subject ?? "").trim();
if (!trimmed) return "Re:";
return /^re:/i.test(trimmed) ? trimmed : `Re: ${trimmed}`;
}
export function buildReplyQuote(senderAddress: string, bodyText: string | null | undefined) {
const latest = getLatestEmailContent(bodyText).trim();
if (!latest) return "";
const quoted = latest
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
return `\n\n${getEmailAddress(senderAddress)} wrote:\n${quoted}\n`;
}
export async function createReplyDraft({
mailboxId,
senderAddress,
ownAddress,
subject,
bodyText,
}: ReplyDraftInput) {
const to = getEmailAddress(senderAddress).trim();
if (!to) throw new Error("Sender address is required");
const response = await authFetch("/api/drafts", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mailboxId,
// The API rejects the draft unless `from` matches the mailbox address.
from: getEmailAddress(ownAddress ?? ""),
to,
subject: buildReplySubject(subject),
text: buildReplyQuote(senderAddress, bodyText),
}),
});
const data = (await response.json()) as { draft?: { id: string }; error?: string };
if (!response.ok || !data.draft) throw new Error(data.error ?? "Unable to create reply draft");
return data.draft.id;
}