diff --git a/src/app/(dashboard)/inbox/[messageId]/page.tsx b/src/app/(dashboard)/inbox/[messageId]/page.tsx index 6c7499b..40171a8 100644 --- a/src/app/(dashboard)/inbox/[messageId]/page.tsx +++ b/src/app/(dashboard)/inbox/[messageId]/page.tsx @@ -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} />
diff --git a/src/components/message-actions/message-actions.tsx b/src/components/message-actions/message-actions.tsx index 647d2e2..6f1dd51 100644 --- a/src/components/message-actions/message-actions.tsx +++ b/src/components/message-actions/message-actions.tsx @@ -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(null); + const { openDraftComposer } = useCompose(); + const [pendingAction, setPendingAction] = useState< + BulkMessageAction | "unsubscribe" | "reply" | null + >(null); const [error, setError] = useState(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 && {error}}
- diff --git a/src/components/message-actions/types.d.ts b/src/components/message-actions/types.d.ts index 36a636e..3c41827 100644 --- a/src/components/message-actions/types.d.ts +++ b/src/components/message-actions/types.d.ts @@ -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; diff --git a/src/components/message-actions/utils.ts b/src/components/message-actions/utils.ts index 2fee77f..8621a98 100644 --- a/src/components/message-actions/utils.ts +++ b/src/components/message-actions/utils.ts @@ -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; +}