From 9df2ab5272d3c791e9e3ea581e2b8dfc59d3a366 Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Fri, 31 Jul 2026 23:49:22 +0800 Subject: [PATCH] feat: send test emails for templates, using the editor's current draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates had no way to send a test email — only campaigns did. This adds `POST /templates/:id/test`, mirroring the campaign endpoint: recipients must be project members, the sending domain is verified first, and the subject carries a `[TEST]` prefix. Both the template and the campaign test send now accept optional draft fields (subject, body, from, fromName, replyTo), so the test reflects what is currently in the editor rather than the last saved version. Without that, checking a wording change means saving it first, which is awkward while iterating on copy. The fields are optional and fall back to the stored record, so existing callers are unaffected. --- apps/api/src/controllers/Campaigns.ts | 3 +- apps/api/src/controllers/Templates.ts | 30 ++++++ apps/api/src/services/CampaignService.ts | 26 +++-- apps/api/src/services/TemplateService.ts | 68 ++++++++++++++ apps/web/src/pages/campaigns/[id].tsx | 6 ++ apps/web/src/pages/templates/[id].tsx | 115 ++++++++++++++++++++++- 6 files changed, 235 insertions(+), 13 deletions(-) diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index b776ebe04..10b8f0a21 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -309,8 +309,9 @@ export class Campaigns { const auth = res.locals.auth; const {id} = UtilitySchemas.id.parse(req.params); const {email} = CampaignSchemas.sendTest.parse(req.body); + const {subject, body, from, fromName, replyTo} = req.body; - await CampaignService.sendTest(auth.projectId, id!, email); + await CampaignService.sendTest(auth.projectId, id!, email, {subject, body, from, fromName, replyTo}); return res.json({ success: true, diff --git a/apps/api/src/controllers/Templates.ts b/apps/api/src/controllers/Templates.ts index 20d83a0ea..c48e3a03e 100644 --- a/apps/api/src/controllers/Templates.ts +++ b/apps/api/src/controllers/Templates.ts @@ -205,6 +205,36 @@ export class Templates { return res.status(201).json(template); } + /** + * POST /templates/:id/test + * Send a test email for a template + */ + @Post(':id/test') + @Middleware([requireAuth, requireEmailVerified]) + @CatchAsync + public async sendTest(req: Request, res: Response, _next: NextFunction) { + const auth = res.locals.auth; + const {id} = req.params; + const {email, subject, body, from, fromName, replyTo} = req.body; + + if (!id) { + return res.status(400).json({error: 'Template ID is required'}); + } + + if (!email) { + return res.status(400).json({error: 'Email address is required'}); + } + + // Optional draft fields let the test send use what is currently in the + // editor rather than the last saved version. + await TemplateService.sendTest(auth.projectId!, id, email, {subject, body, from, fromName, replyTo}); + + return res.json({ + success: true, + message: `Test email sent to ${email}`, + }); + } + /** * GET /templates/:id/usage * Get template usage statistics diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index deb80c891..e84a411f0 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -767,7 +767,12 @@ export class CampaignService { /** * Send a test email for a campaign */ - public static async sendTest(projectId: string, campaignId: string, testEmail: string): Promise { + public static async sendTest( + projectId: string, + campaignId: string, + testEmail: string, + draft?: {subject?: string; body?: string; from?: string; fromName?: string | null; replyTo?: string | null}, + ): Promise { const campaign = await this.get(projectId, campaignId); // Validate that the test email belongs to a project member @@ -787,8 +792,15 @@ export class CampaignService { throw new HttpException(403, 'Test emails can only be sent to project members'); } + // Prefer the draft the editor sent; fall back to the saved version. + const subject = draft?.subject || campaign.subject; + const body = draft?.body || campaign.body; + const fromEmail = draft?.from || campaign.from; + const fromName = draft?.fromName !== undefined ? draft.fromName : campaign.fromName; + const replyTo = draft?.replyTo !== undefined ? draft.replyTo : campaign.replyTo; + // Verify domain is registered and verified before sending - await DomainService.verifyEmailDomain(campaign.from, projectId); + await DomainService.verifyEmailDomain(fromEmail, projectId); // Get project to validate from address const project = await prisma.project.findUnique({ @@ -813,15 +825,15 @@ export class CampaignService { // Prepare the email content (no variable replacement for test emails) await sendRawEmail({ from: { - name: campaign.fromName || project.name || 'Plunk', - email: campaign.from, + name: fromName || project.name || 'Plunk', + email: fromEmail, }, to: [testEmail], content: { - subject: `[TEST] ${campaign.subject}`, - html: campaign.body, + subject: `[TEST] ${subject}`, + html: body, }, - reply: campaign.replyTo || undefined, + reply: replyTo || undefined, headers: buildEmailHeaders({ emailClass, isCampaign: true, diff --git a/apps/api/src/services/TemplateService.ts b/apps/api/src/services/TemplateService.ts index 8b46117b2..f04bd4d5e 100644 --- a/apps/api/src/services/TemplateService.ts +++ b/apps/api/src/services/TemplateService.ts @@ -6,6 +6,8 @@ import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import type {ListSort} from '../utils/listSort.js'; import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js'; +import {DomainService} from './DomainService.js'; +import {sendRawEmail} from './SESService.js'; export class TemplateService { /** @@ -290,4 +292,70 @@ export class TemplateService { emailsSent: emailsCount, }; } + + /** + * Send a test email for a template + * Only project members can receive test emails + */ + public static async sendTest( + projectId: string, + templateId: string, + testEmail: string, + draft?: {subject?: string; body?: string; from?: string; fromName?: string | null; replyTo?: string | null}, + ): Promise { + const template = await this.get(projectId, templateId); + + // Validate that the test email belongs to a project member + const membership = await prisma.membership.findFirst({ + where: { + projectId, + user: { + email: testEmail, + }, + }, + include: { + user: true, + }, + }); + + if (!membership) { + throw new HttpException(403, 'Test emails can only be sent to project members'); + } + + // Prefer the draft the editor sent; fall back to the saved version. + const subject = draft?.subject || template.subject; + const body = draft?.body || template.body; + const fromEmail = draft?.from || template.from; + const fromName = draft?.fromName !== undefined ? draft.fromName : template.fromName; + const replyTo = draft?.replyTo !== undefined ? draft.replyTo : template.replyTo; + + // Verify domain is registered and verified before sending + await DomainService.verifyEmailDomain(fromEmail, projectId); + + // Get project for fallback sender name + const project = await prisma.project.findUnique({ + where: {id: projectId}, + }); + + if (!project) { + throw new HttpException(404, 'Project not found'); + } + + await sendRawEmail({ + from: { + name: fromName || project.name || 'Plunk', + email: fromEmail, + }, + to: [testEmail], + content: { + subject: `[TEST] ${subject}`, + html: body, + }, + reply: replyTo || undefined, + headers: { + 'X-Plunk-Test': 'true', + }, + tracking: false, + }); + } } diff --git a/apps/web/src/pages/campaigns/[id].tsx b/apps/web/src/pages/campaigns/[id].tsx index 056b9ac13..41f8b8d2f 100644 --- a/apps/web/src/pages/campaigns/[id].tsx +++ b/apps/web/src/pages/campaigns/[id].tsx @@ -196,8 +196,14 @@ export default function CampaignDetailsPage() { setDialog({type: 'testEmail', sending: true}); try { + // Send what is in the editor right now, so no save is required first. await network.fetch<{success: boolean; message: string}>('POST', `/campaigns/${id}/test`, { email: testEmailAddress, + subject: editedCampaign.subject, + body: editedCampaign.body, + from: editedCampaign.from, + fromName: editedCampaign.fromName || null, + replyTo: editedCampaign.replyTo || null, } as any); toast.success(`Test email sent to ${testEmailAddress}`); diff --git a/apps/web/src/pages/templates/[id].tsx b/apps/web/src/pages/templates/[id].tsx index 25b59a030..b798f9521 100644 --- a/apps/web/src/pages/templates/[id].tsx +++ b/apps/web/src/pages/templates/[id].tsx @@ -6,9 +6,21 @@ import { CardHeader, CardTitle, ConfirmDialog, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, IconSpinner, Input, Label, + Select, + SelectContent, + SelectItem, + + SelectTrigger, + SelectValue, StickySaveBar, } from '@plunk/ui'; import type {Template} from '@plunk/db'; @@ -17,7 +29,7 @@ import {EmailSettings} from '../../components/EmailSettings'; import {EmailEditor} from '../../components/EmailEditor'; import {network} from '../../lib/network'; import {useChangeTracking} from '../../lib/hooks/useChangeTracking'; -import {ArrowLeft, Save, Trash2, TriangleAlert} from 'lucide-react'; +import {ArrowLeft, Save, Send, Trash2, TriangleAlert} from 'lucide-react'; import Link from 'next/link'; import {NextSeo} from 'next-seo'; import {useRouter} from 'next/router'; @@ -39,6 +51,15 @@ export default function TemplateEditorPage() { const [editedTemplate, setEditedTemplate] = useState>({}); const [isSubmitting, setIsSubmitting] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [isTestEmailDialogOpen, setIsTestEmailDialogOpen] = useState(false); + const [testEmailAddress, setTestEmailAddress] = useState(''); + const [sendingTestEmail, setSendingTestEmail] = useState(false); + + // Fetch project members for test email recipient selection + const {data: projectMembers} = useSWR<{data: Array<{userId: string; email: string; role: string}>}>( + template?.projectId ? `/projects/${template.projectId}/members` : null, + {revalidateOnFocus: false}, + ); // Initialize edit fields when template loads useEffect(() => { @@ -98,6 +119,29 @@ export default function TemplateEditorPage() { } }; + const handleSendTestEmail = async () => { + if (!testEmailAddress) return; + setSendingTestEmail(true); + try { + // Send what is in the editor right now, so no save is required first. + await network.fetch('POST', `/templates/${id}/test`, { + email: testEmailAddress, + subject: editedTemplate.subject, + body: editedTemplate.body, + from: editedTemplate.from, + fromName: editedTemplate.fromName || null, + replyTo: editedTemplate.replyTo || null, + } as Record as never); + toast.success(`Test email sent to ${testEmailAddress}`); + setIsTestEmailDialogOpen(false); + setTestEmailAddress(''); + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to send test email'); + } finally { + setSendingTestEmail(false); + } + }; + const handleDelete = async () => { try { await network.fetch('DELETE', `/templates/${id}`); @@ -281,10 +325,20 @@ export default function TemplateEditorPage() { Delete Template - +
+ + +
@@ -292,6 +346,57 @@ export default function TemplateEditorPage() { {/* Sticky Save Bar */} + {/* Send Test Email Dialog */} + + + + Send Test Email + + Send a test version of this template to a project member to verify how it looks. The test email will be + prefixed with [TEST] in the subject line. + + +
+
+ + +

+ For security reasons, test emails can only be sent to project members. +

+

+ Note: Variables will not be replaced in test emails. The email will be sent exactly as designed. +

+
+
+ + + + +
+
+ {/* Delete Template Confirmation */}