Skip to content
Closed
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
1 change: 1 addition & 0 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- next
- deploy/custom

jobs:
# Prepare metadata and tags for all builds
Expand Down
28 changes: 28 additions & 0 deletions apps/api/src/controllers/Templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,34 @@ 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} = 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'});
}

await TemplateService.sendTest(auth.projectId!, id, email);

return res.json({
success: true,
message: `Test email sent to ${email}`,
});
}

/**
* GET /templates/:id/usage
* Get template usage statistics
Expand Down
56 changes: 56 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,58 @@ 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): 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');
}

// Verify domain is registered and verified before sending
await DomainService.verifyEmailDomain(template.from, 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: template.fromName || project.name || 'Plunk',
email: template.from,
},
to: [testEmail],
content: {
subject: `[TEST] ${template.subject}`,
html: template.body,
},
reply: template.replyTo || undefined,
headers: {
'X-Plunk-Test': 'true',
},
tracking: false,
});
}
}
94 changes: 93 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,22 @@ export default function TemplateEditorPage() {
}
};

const handleSendTestEmail = async () => {
if (!testEmailAddress) return;
setSendingTestEmail(true);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await network.fetch('POST', `/templates/${id}/test`, {email: testEmailAddress} 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 +201,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 +339,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
Loading