Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/src/controllers/Campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 29 additions & 0 deletions apps/api/src/controllers/Templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 19 additions & 7 deletions apps/api/src/services/CampaignService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
public static async sendTest(
projectId: string,
campaignId: string,
testEmail: string,
draft?: {subject?: string; body?: string; from?: string; fromName?: string | null; replyTo?: string | null},
): Promise<void> {
const campaign = await this.get(projectId, campaignId);

// Validate that the test email belongs to a project member
Expand All @@ -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({
Expand All @@ -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',
},
Expand Down
68 changes: 68 additions & 0 deletions apps/api/src/services/TemplateService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand Down Expand Up @@ -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<void> {
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,
});
}
}
6 changes: 6 additions & 0 deletions apps/web/src/pages/campaigns/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
102 changes: 101 additions & 1 deletion apps/web/src/pages/templates/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,17 @@ import {
CardHeader,
CardTitle,
ConfirmDialog,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
Expand All @@ -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';
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -169,6 +209,15 @@ export default function TemplateEditorPage() {
)}
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsTestEmailDialogOpen(true)}
className="flex-1 sm:flex-none"
>
<Send className="h-4 w-4" />
<span className="hidden sm:inline">Send Test</span>
</Button>
<Button
type="button"
variant="destructive"
Expand Down Expand Up @@ -298,6 +347,57 @@ export default function TemplateEditorPage() {
{/* Sticky Save Bar */}
<StickySaveBar hasChanges={hasChanges} isSubmitting={isSubmitting} onSave={handleSave} />

{/* Send Test Email Dialog */}
<Dialog open={isTestEmailDialogOpen} onOpenChange={setIsTestEmailDialogOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Send Test Email</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="testEmail">Project Member</Label>
<Select value={testEmailAddress} onValueChange={setTestEmailAddress}>
<SelectTrigger id="testEmail" className="mt-2">
<SelectValue placeholder="Select a project member..." />
</SelectTrigger>
<SelectContent>
{projectMembers?.data.map(member => (
<SelectItem key={member.userId} value={member.email}>
{member.email}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-neutral-500 mt-2">
For security reasons, test emails can only be sent to project members.
</p>
<p className="text-xs text-neutral-500 mt-1">
Note: Variables will not be replaced in test emails. The email will be sent exactly as designed.
</p>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setIsTestEmailDialogOpen(false);
setTestEmailAddress('');
}}
>
Cancel
</Button>
<Button type="button" onClick={handleSendTestEmail} disabled={sendingTestEmail || !testEmailAddress}>
{sendingTestEmail ? 'Sending...' : 'Send Test Email'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>

{/* Delete Template Confirmation */}
<ConfirmDialog
open={showDeleteDialog}
Expand Down