diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 6b50d744..4c7c4809 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -4,6 +4,7 @@ on: push: branches: - next + - deploy/custom jobs: # Prepare metadata and tags for all builds diff --git a/apps/api/src/controllers/Templates.ts b/apps/api/src/controllers/Templates.ts index 26a9cf3e..2e973625 100644 --- a/apps/api/src/controllers/Templates.ts +++ b/apps/api/src/controllers/Templates.ts @@ -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 diff --git a/apps/api/src/services/TemplateService.ts b/apps/api/src/services/TemplateService.ts index 5c7af7f1..41c90dd4 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,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 { + 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, + }); + } } diff --git a/apps/web/src/pages/templates/[id].tsx b/apps/web/src/pages/templates/[id].tsx index ca42c3a9..9584d45f 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,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}`); @@ -169,6 +201,15 @@ export default function TemplateEditorPage() { )}
+ + + + + + {/* Delete Template Confirmation */} - {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( - - ) : ( - setEventName(e.target.value)} - placeholder="e.g., contact.created, email.opened" - required - /> + setEventName(e.target.value)} + placeholder="e.g., contact.created, email.opened" + required + /> + {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 && ( + + {eventNamesData.eventNames.map(name => ( + )}

The event that triggers this workflow to start for a contact @@ -1611,34 +1604,25 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo - {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( - - ) : ( - setEventName(e.target.value)} - required - placeholder="e.g., email.clicked, user.upgraded" - className="mt-1.5" - /> + setEventName(e.target.value)} + required + placeholder="e.g., email.clicked, user.upgraded" + className="mt-1.5" + /> + {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 && ( + + {eventNamesData.eventNames.map(name => ( + )}

- {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 - ? 'The workflow will pause until this event occurs' - : 'Enter the event name to wait for'} + Enter the event name to wait for, or select from previously tracked events

@@ -2793,40 +2777,26 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
- {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( - <> - -

- Select from previously tracked events in your project -

- - ) : ( - <> - setEventName(e.target.value)} - required - placeholder="e.g., email.clicked, user.upgraded" - className="mt-1.5" - /> -

- The workflow will pause until this event is triggered by the contact -

- + setEventName(e.target.value)} + required + placeholder="e.g., email.clicked, user.upgraded" + className="mt-1.5" + /> + {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 && ( + + {eventNamesData.eventNames.map(name => ( + )} +

+ Enter the event name to wait for, or select from previously tracked events +

diff --git a/apps/web/src/pages/workflows/index.tsx b/apps/web/src/pages/workflows/index.tsx index 8e825d98..0666fe3a 100644 --- a/apps/web/src/pages/workflows/index.tsx +++ b/apps/web/src/pages/workflows/index.tsx @@ -14,11 +14,6 @@ import { DialogTitle, Input, Label, - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, } from '@plunk/ui'; import type {Workflow} from '@plunk/db'; import type {PaginatedResponse} from '@plunk/types'; @@ -391,34 +386,25 @@ function CreateWorkflowDialog({open, onOpenChange, onSuccess}: CreateWorkflowDia
- - {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( - - ) : ( - setEventName(e.target.value)} - required - placeholder="e.g., contact.created, email.opened" - /> + + setEventName(e.target.value)} + placeholder="e.g., contact.created, email.opened" + required + /> + {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 && ( + + {eventNamesData.eventNames.map(name => ( + )}

- {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 - ? 'Select from previously tracked events' - : 'No events tracked yet. Enter the event name that will trigger this workflow.'} + The event that triggers this workflow to start for a contact

diff --git a/packages/shared/src/i18n/index.ts b/packages/shared/src/i18n/index.ts index dcc824cb..d2486f70 100644 --- a/packages/shared/src/i18n/index.ts +++ b/packages/shared/src/i18n/index.ts @@ -10,6 +10,7 @@ import bgTranslations from './locales/bg.json' with {type: 'json'}; import csTranslations from './locales/cs.json' with {type: 'json'}; import plTranslations from './locales/pl.json' with {type: 'json'}; import esTranslations from './locales/es.json' with {type: 'json'}; +import zhTWTranslations from './locales/zh-TW.json' with {type: 'json'}; export { SUPPORTED_LANGUAGES, @@ -45,6 +46,7 @@ const translationsMap: Record = { cs: csTranslations, pl: plTranslations, es: esTranslations, + 'zh-TW': zhTWTranslations, }; // In-memory cache for loaded translations diff --git a/packages/shared/src/i18n/languages.ts b/packages/shared/src/i18n/languages.ts index 07294abd..f0734dac 100644 --- a/packages/shared/src/i18n/languages.ts +++ b/packages/shared/src/i18n/languages.ts @@ -16,6 +16,7 @@ export const SUPPORTED_LANGUAGES: Language[] = [ {code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿'}, {code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱'}, {code: 'es', name: 'Spanish (Spain)', nativeName: 'Español (España)', flag: '🇪🇸'}, + {code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文', flag: '🇹🇼'}, ]; export const DEFAULT_LANGUAGE = 'en'; diff --git a/packages/shared/src/i18n/locales/zh-TW.json b/packages/shared/src/i18n/locales/zh-TW.json new file mode 100644 index 00000000..baae0e43 --- /dev/null +++ b/packages/shared/src/i18n/locales/zh-TW.json @@ -0,0 +1,45 @@ +{ + "pages": { + "unsubscribe": { + "title": "取消訂閱", + "description": "確定要取消 {email} 的郵件訂閱嗎?", + "button": "取消訂閱", + "buttonLoading": "處理中...", + "managePreferences": "改為管理偏好設定", + "successTitle": "已取消訂閱", + "successDescription": "{email} 已取消訂閱,你將不會再收到我們的郵件。", + "changedMind": "改變主意了嗎?", + "subscribeAgain": "重新訂閱" + }, + "subscribe": { + "title": "訂閱最新消息", + "description": "要為 {email} 訂閱郵件嗎?", + "button": "訂閱", + "buttonLoading": "處理中...", + "successTitle": "訂閱成功!", + "successDescription": "{email} 已訂閱,你將會收到我們的最新消息。" + }, + "manage": { + "title": "管理偏好設定", + "description": "管理 {email} 的郵件偏好設定", + "subscriptionLabel": "郵件訂閱", + "subscribedStatus": "目前已訂閱,會收到郵件通知", + "unsubscribedStatus": "目前已取消訂閱", + "subscribedSuccess": "訂閱成功!", + "unsubscribedSuccess": "已取消訂閱!", + "unsubscribeCompletely": "取消訂閱", + "subscribeToEmails": "訂閱郵件", + "disclaimer": "你可以在此管理郵件偏好設定,訂閱狀態會即時更新。" + }, + "common": { + "loading": "載入中...", + "error": "發生錯誤" + } + }, + "email": { + "footer": { + "unsubscribeText": "你收到這封信是因為你訂閱了 {projectName} 的郵件。如果你不想再收到這類郵件,請", + "updatePreferences": "更新你的偏好設定" + } + } +}