diff --git a/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx b/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx index 4cc826a..87666e3 100644 --- a/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx +++ b/apps/web/app/(dashboard)/broadcasts/[sequenceId]/page.tsx @@ -317,6 +317,7 @@ export default function BroadcastEditorPage({ {sequence.emails[0] && ( ({ mailingAddress: "1 Main St" })); +const updateGeneralSettings = mock(async (input: { mailingAddress?: string }) => ({ + mailingAddress: input.mailingAddress ?? null, +})); +const sendSequenceEmailTest = mock(async (_s: string, _e: string, to: string[]) => ({ + sent: to, + skipped: [] as { email: string }[], + lastTestSentAt: "2026-10-22T10:00:00.000Z", +})); + +mock.module("@/lib/api", () => ({ + getGeneralSettings, + updateGeneralSettings, + sendSequenceEmailTest, +})); +mock.module("sonner", () => ({ toast: { success: mock(), warning: mock() } })); + +const { act, cleanup, fireEvent, render, screen } = await import( + "@testing-library/react" +); +const { addRecipients, parseRecipients, SendTestDialog } = await import( + "./send-test-dialog" +); + +const mailpit = { + type: "team" as const, + espId: "esp_1", + name: "Mailpit QA", + fromName: null, + fromEmail: "no-reply@predator.local", + isDefault: true, + available: true, + countsAgainstQuota: false as const, +}; + +function openDialog() { + render( + , + ); + fireEvent.click(screen.getByRole("button", { name: "Send test" })); +} + +/** The dialog's own Send test button (the trigger is hidden while it's open). */ +function sendButton() { + return screen + .getAllByRole("button", { name: "Send test" }) + .at(-1) as HTMLButtonElement; +} + +/** Lets pending API mocks resolve and React re-render. */ +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +function addAddress(email: string) { + const input = screen.getByLabelText("Send to"); + fireEvent.change(input, { target: { value: email } }); + fireEvent.keyDown(input, { key: "Enter" }); +} + +describe("recipient helpers", () => { + it("splits on commas, semicolons and spaces", () => { + expect(parseRecipients("a@x.com, b@x.com;c@x.com d@x.com")).toEqual([ + "a@x.com", + "b@x.com", + "c@x.com", + "d@x.com", + ]); + }); + + it("skips duplicates regardless of case", () => { + expect(addRecipients(["a@x.com"], ["A@x.com", "b@x.com"])).toEqual([ + "a@x.com", + "b@x.com", + ]); + }); +}); + +describe("SendTestDialog", () => { + beforeEach(() => { + globalThis.fetch = mock(async () => Response.json(null)) as never; + getGeneralSettings.mockImplementation(async () => ({ + mailingAddress: "1 Main St", + })); + sendSequenceEmailTest.mockClear(); + }); + afterEach(cleanup); + + it("shows from, delivery source and the [Test] subject", async () => { + openDialog(); + await settle(); + expect(screen.getByText("no-reply@predator.local")).toBeTruthy(); + expect(screen.getByText("Mailpit QA")).toBeTruthy(); + expect(screen.getByText("Same as the real email")).toBeTruthy(); + expect(screen.getByText("[Test]")).toBeTruthy(); + expect(screen.getByText("October news")).toBeTruthy(); + }); + + it("adds addresses as chips and sends to them", async () => { + openDialog(); + await settle(); + addAddress("one@example.com"); + addAddress("two@example.com"); + expect(screen.getByText("2 of 5")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Remove one@example.com" })); + expect(screen.getByText("1 of 5")).toBeTruthy(); + await settle(); + expect(sendButton().disabled).toBe(false); + fireEvent.click(sendButton()); + await settle(); + expect(sendSequenceEmailTest).toHaveBeenCalledWith("seq_1", "email_1", [ + "two@example.com", + ]); + }); + + it("asks for the mailing address before sending, and saves it inline", async () => { + getGeneralSettings.mockImplementation(async () => ({ mailingAddress: "" })); + openDialog(); + await settle(); + expect(screen.getByText("Add your mailing address first")).toBeTruthy(); + expect(screen.getByText("Add your mailing address to send")).toBeTruthy(); + expect(sendButton().disabled).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "Add mailing address" })); + fireEvent.change(screen.getByLabelText("Mailing address"), { + target: { value: "1 Main St, Pune" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save address" })); + await settle(); + expect(screen.queryByText("Add your mailing address first")).toBeNull(); + expect(updateGeneralSettings).toHaveBeenCalledWith({ + mailingAddress: "1 Main St, Pune", + }); + expect(sendButton().disabled).toBe(false); + }); +}); diff --git a/apps/web/components/dashboard/send-test-dialog.tsx b/apps/web/components/dashboard/send-test-dialog.tsx index bc502ed..369b555 100644 --- a/apps/web/components/dashboard/send-test-dialog.tsx +++ b/apps/web/components/dashboard/send-test-dialog.tsx @@ -1,9 +1,12 @@ "use client"; -import { FlaskConical } from "lucide-react"; -import { useEffect, useState } from "react"; +import { Check, FlaskConical, Info, MapPin, Send, X } from "lucide-react"; +import { type KeyboardEvent, type ReactNode, useEffect, useState } from "react"; import { toast } from "sonner"; -import { SendErrorMessage } from "@/components/dashboard/send-error-message"; +import { + MAILING_ADDRESS_REQUIRED_ERROR, + SendErrorMessage, +} from "@/components/dashboard/send-error-message"; import { Button } from "@/components/ui/codelit/button"; import { Dialog, @@ -13,22 +16,40 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/codelit/dialog"; -import { Input } from "@/components/ui/codelit/input"; import { Label } from "@/components/ui/codelit/label"; +import { Textarea } from "@/components/ui/codelit/textarea"; import type { SendingOption } from "@/lib/api"; -import { sendSequenceEmailTest } from "@/lib/api"; +import { + getGeneralSettings, + sendSequenceEmailTest, + updateGeneralSettings, +} from "@/lib/api"; import { ApiError } from "@/lib/api-client"; -import { sendingOptionLabel } from "@/lib/delivery-source"; +import { presentDeliverySourceError } from "@/lib/delivery-source"; +import { cn } from "@/lib/utils"; -const MAX_RECIPIENTS = 5; +export const MAX_TEST_RECIPIENTS = 5; -function parseRecipients(value: string) { +/** Splits typed or pasted text into addresses (commas, semicolons, spaces). */ +export function parseRecipients(value: string) { return value .split(/[\s,;]+/) .map((email) => email.trim()) .filter(Boolean); } +/** Adds `incoming` to `current`, skipping duplicates (case-insensitive). */ +export function addRecipients(current: string[], incoming: string[]) { + const seen = new Set(current.map((email) => email.toLowerCase())); + const next = [...current]; + for (const email of incoming) { + if (seen.has(email.toLowerCase())) continue; + seen.add(email.toLowerCase()); + next.push(email); + } + return next; +} + async function currentUserEmail(): Promise { const res = await fetch("/api/auth/get-session", { cache: "no-store" }); if (!res.ok) return ""; @@ -43,6 +64,7 @@ async function currentUserEmail(): Promise { */ export function SendTestDialog({ sendingVia, + subject, sequenceId, emailId, lastTestSentAt, @@ -52,6 +74,8 @@ export function SendTestDialog({ }: { sequenceId: string; emailId: string; + /** The real email's subject; the test adds "[Test]" in front. */ + subject?: string; lastTestSentAt?: string | null; beforeSend?: () => Promise; onSent?: (lastTestSentAt: string | null) => void; @@ -60,25 +84,53 @@ export function SendTestDialog({ sendingVia?: SendingOption | null; }) { const [open, setOpen] = useState(false); - const [recipients, setRecipients] = useState(""); + const [recipients, setRecipients] = useState([]); + const [draft, setDraft] = useState(""); const [sending, setSending] = useState(false); const [error, setError] = useState(null); + // Null until known; a failed lookup leaves it null and the API decides. + const [addressMissing, setAddressMissing] = useState(null); useEffect(() => { - if (!open || recipients) return; + if (!open) return; + setError(null); currentUserEmail() - .then((email) => setRecipients((current) => current || email)) + .then((email) => { + if (email) setRecipients((current) => (current.length ? current : [email])); + }) .catch(() => undefined); - }, [open, recipients]); + getGeneralSettings() + .then((settings) => setAddressMissing(!settings.mailingAddress?.trim())) + .catch(() => setAddressMissing(null)); + }, [open]); + + function commitDraft(value = draft) { + const incoming = parseRecipients(value); + if (incoming.length === 0) return recipients; + const next = addRecipients(recipients, incoming); + setRecipients(next); + setDraft(""); + setError(null); + return next; + } + + function onDraftKeyDown(event: KeyboardEvent) { + if (["Enter", ",", ";", " ", "Tab"].includes(event.key) && draft.trim()) { + event.preventDefault(); + commitDraft(); + } else if (event.key === "Backspace" && !draft && recipients.length) { + setRecipients(recipients.slice(0, -1)); + } + } async function send() { - const to = parseRecipients(recipients); + const to = commitDraft(); if (to.length === 0) { - setError("Enter at least one email address"); + setError("Add at least one email address"); return; } - if (to.length > MAX_RECIPIENTS) { - setError(`Send a test to at most ${MAX_RECIPIENTS} addresses`); + if (to.length > MAX_TEST_RECIPIENTS) { + setError(`Send a test to at most ${MAX_TEST_RECIPIENTS} addresses`); return; } setSending(true); @@ -99,18 +151,29 @@ export function SendTestDialog({ } if (result.sent.length > 0) setOpen(false); } catch (err) { - setError( + const message = err instanceof ApiError ? err.message === "test_send_limit_reached" ? "Too many test emails in the last hour. Try again later." : err.message - : "Failed to send the test", - ); + : "Failed to send the test"; + if (presentDeliverySourceError(message) === MAILING_ADDRESS_REQUIRED_ERROR) { + setAddressMissing(true); + } else { + setError(message); + } } finally { setSending(false); } } + const overLimit = recipients.length > MAX_TEST_RECIPIENTS; + const from = sendingVia?.fromEmail + ? sendingVia.fromName + ? `${sendingVia.fromName} <${sendingVia.fromEmail}>` + : sendingVia.fromEmail + : null; + return ( <> - - + + Send a test email - The test looks exactly like the real email, with "[Test]" added - to the subject. Opens, clicks and the unsubscribe link in a test - don't change anything. + See it in your inbox before your audience does. -
- - setRecipients(e.target.value)} - placeholder="you@example.com" - /> - {sendingVia && ( -

- Sends through{" "} - {sendingOptionLabel(sendingVia)}, - the same delivery source as the real email. -

+ +
+
+
+ + + {recipients.length} of {MAX_TEST_RECIPIENTS} + +
+
+ {recipients.map((email) => ( + + {email} + + + ))} + setDraft(e.target.value)} + onKeyDown={onDraftKeyDown} + onBlur={() => commitDraft()} + onPaste={(e) => { + e.preventDefault(); + commitDraft(draft + e.clipboardData.getData("text")); + }} + placeholder={ + recipients.length ? "Add another address" : "you@example.com" + } + className="h-8 min-w-40 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-muted-foreground" + /> +
+
+ +
+ {from && ( + + {from} + + )} + {sendingVia && ( + + + {sendingVia.type === "organization" + ? `Shared: ${sendingVia.name}` + : sendingVia.name} + + + + Same as the real email + + + )} + + + [Test] + + + {subject?.trim() || ( + No subject yet + )} + + +
+ + {addressMissing && ( + setAddressMissing(false)} /> )} -

- Up to {MAX_RECIPIENTS} addresses, separated by commas. - {lastTestSentAt - ? ` Last test sent ${new Date(lastTestSentAt).toLocaleString()}.` - : " No test sent yet."} -

+ {error && ( -

+

)} + +

+ + The test looks exactly like the real email. Opens, clicks and unsubscribes + from it aren't counted. +

- - - + + +

+ {addressMissing + ? "Add your mailing address to send" + : lastTestSentAt + ? `Last test sent ${new Date(lastTestSentAt).toLocaleString()}` + : "No test sent yet"} +

+
+ + +
); } + +function SummaryRow({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
+ {children} +
+
+ ); +} + +/** Amber prompt to add the team's mailing address without leaving the dialog. */ +function MailingAddressPrompt({ onSaved }: { onSaved: () => void }) { + const [editing, setEditing] = useState(false); + const [address, setAddress] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + async function save() { + if (!address.trim()) { + setError("Enter your mailing address"); + return; + } + setSaving(true); + setError(null); + try { + await updateGeneralSettings({ mailingAddress: address }); + onSaved(); + } catch (err) { + setError(err instanceof ApiError ? err.message : "Failed to save the address"); + } finally { + setSaving(false); + } + } + + return ( +
+ +
+
+

Add your mailing address first

+

+ Anti-spam laws require a postal address in every broadcast and sequence, so + SendLit adds it to your footer. You only need to do this once. +

+
+ {editing ? ( +
+ +