From a662b2a1ac5fc774d473d1924d5d6ee4dba4af5d Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Tue, 24 Mar 2026 16:47:00 +0800 Subject: [PATCH] feat: add send test email for templates and use draft content for test emails Add a "Send Test Email" feature for templates, matching the existing campaign test email functionality. Also update both template and campaign test email endpoints to accept optional draft fields (subject, body, from, fromName, replyTo) so test emails reflect the current editor content without requiring a save first. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/controllers/Campaigns.ts | 3 +- apps/api/src/controllers/Templates.ts | 29 +++++++ 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 | 102 ++++++++++++++++++++++- 6 files changed, 225 insertions(+), 9 deletions(-) diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index 3f165f95..a68dbc75 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -265,8 +265,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 26a9cf3e..071b8f2b 100644 --- a/apps/api/src/controllers/Templates.ts +++ b/apps/api/src/controllers/Templates.ts @@ -166,6 +166,35 @@ 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'}); + } + + // 支援傳入 draft 內容,讓測試信使用 editor 目前的版本而非 DB 的 + 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 37fdf6d4..9ff1e571 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -590,7 +590,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 @@ -610,8 +615,15 @@ export class CampaignService { throw new HttpException(403, 'Test emails can only be sent to project members'); } + // 有 draft 內容時用 draft,否則 fallback 到 DB 版本 + 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({ @@ -625,15 +637,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: { 'X-Plunk-Test': 'true', }, diff --git a/apps/api/src/services/TemplateService.ts b/apps/api/src/services/TemplateService.ts index 5c7af7f1..f41b66f8 100644 --- a/apps/api/src/services/TemplateService.ts +++ b/apps/api/src/services/TemplateService.ts @@ -5,6 +5,8 @@ import type {PaginatedResponse} from '@plunk/types'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js'; +import {DomainService} from './DomainService.js'; +import {sendRawEmail} from './SESService.js'; export class TemplateService { /** @@ -208,4 +210,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'); + } + + // 有 draft 內容時用 draft,否則 fallback 到 DB 版本 + 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 ce809ae3..8300deec 100644 --- a/apps/web/src/pages/campaigns/[id].tsx +++ b/apps/web/src/pages/campaigns/[id].tsx @@ -186,8 +186,14 @@ export default function CampaignDetailsPage() { setSendingTestEmail(true); try { + // 直接帶 editor 目前的內容,不需要先存檔 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 ca42c3a9..c5287d1d 100644 --- a/apps/web/src/pages/templates/[id].tsx +++ b/apps/web/src/pages/templates/[id].tsx @@ -6,10 +6,17 @@ import { CardHeader, CardTitle, ConfirmDialog, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, Input, Label, Select, SelectContent, + SelectItem, SelectItemWithDescription, SelectTrigger, SelectValue, @@ -21,7 +28,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} from 'lucide-react'; +import {ArrowLeft, Save, Send, Trash2} from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; @@ -43,6 +50,15 @@ export default function TemplateEditorPage() { const [isSubmitting, setIsSubmitting] = useState(false); const [hasChanges, setHasChanges] = 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(() => { @@ -108,6 +124,30 @@ export default function TemplateEditorPage() { } }; + const handleSendTestEmail = async () => { + if (!testEmailAddress) return; + setSendingTestEmail(true); + try { + // 直接帶 editor 目前的內容,不需要先存檔 + // eslint-disable-next-line @typescript-eslint/no-explicit-any + 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 any); + 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}`); @@ -169,6 +209,15 @@ export default function TemplateEditorPage() { )}
+ + + + + + {/* Delete Template Confirmation */}