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 */}