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:
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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user