From 12d855dd34c63cf7e637fbbcba3b4116ad7b195b Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Tue, 24 Mar 2026 16:47:00 +0800 Subject: [PATCH 1/2] 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 | 107 ++++++++++++++++++++++- 6 files changed, 230 insertions(+), 9 deletions(-) diff --git a/apps/api/src/controllers/Campaigns.ts b/apps/api/src/controllers/Campaigns.ts index fd9e073e..00cd18cc 100644 --- a/apps/api/src/controllers/Campaigns.ts +++ b/apps/api/src/controllers/Campaigns.ts @@ -267,8 +267,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 e82fb920..2a13e91e 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -597,7 +597,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 @@ -617,8 +622,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({ @@ -632,15 +644,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 9b102c47..e13ac600 100644 --- a/apps/web/src/pages/campaigns/[id].tsx +++ b/apps/web/src/pages/campaigns/[id].tsx @@ -187,8 +187,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 3d5f2e87..1c4cd4b5 100644 --- a/apps/web/src/pages/templates/[id].tsx +++ b/apps/web/src/pages/templates/[id].tsx @@ -6,8 +6,20 @@ import { CardHeader, CardTitle, ConfirmDialog, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, Input, Label, + Select, + SelectContent, + SelectItem, + SelectItemWithDescription, + SelectTrigger, + SelectValue, StickySaveBar, } from '@plunk/ui'; import type {Template} from '@plunk/db'; @@ -16,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, TriangleAlert} from 'lucide-react'; +import {ArrowLeft, Save, Send, Trash2, TriangleAlert} from 'lucide-react'; import Link from 'next/link'; import {useRouter} from 'next/router'; import {useEffect, useState} from 'react'; @@ -38,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(() => { @@ -103,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}`); @@ -164,6 +209,15 @@ export default function TemplateEditorPage() { )}
+ + + + + + {/* Delete Template Confirmation */} Date: Fri, 3 Apr 2026 22:18:17 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20resolve=20lint=20errors=20after=20re?= =?UTF-8?q?base=20=E2=80=94=20remove=20unused=20import=20and=20fix=20no-ex?= =?UTF-8?q?plicit-any?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- apps/web/src/pages/templates/[id].tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/templates/[id].tsx b/apps/web/src/pages/templates/[id].tsx index 1c4cd4b5..6b969fce 100644 --- a/apps/web/src/pages/templates/[id].tsx +++ b/apps/web/src/pages/templates/[id].tsx @@ -17,7 +17,7 @@ import { Select, SelectContent, SelectItem, - SelectItemWithDescription, + SelectTrigger, SelectValue, StickySaveBar, @@ -128,16 +128,15 @@ export default function TemplateEditorPage() { if (!testEmailAddress) return; setSendingTestEmail(true); try { - // 直接帶 editor 目前的內容,不需要先存檔 - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await network.fetch('POST', `/templates/${id}/test`, { + // Send draft content directly without saving 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 any); + } as Record as never); toast.success(`Test email sent to ${testEmailAddress}`); setIsTestEmailDialogOpen(false); setTestEmailAddress('');