From 6a56c8bb887d826b9cb89ac403d1bbf2eb4cb729 Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Mon, 16 Mar 2026 14:20:24 +0800 Subject: [PATCH 1/7] fix: allow typing custom event names in workflow event selectors When existing events are present, the event name fields in workflow settings and WAIT_FOR_EVENT steps render as a Select dropdown, preventing users from entering event names that haven't been tracked yet (e.g., system events like email.opened that only appear after first occurrence). Replace the conditional Select/Input pattern with a unified Input + datalist approach that always allows free-text input while still offering autocomplete suggestions from previously tracked events. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/pages/workflows/[id].tsx | 132 ++++++++++---------------- 1 file changed, 51 insertions(+), 81 deletions(-) diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 14b2f957..c304b102 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -852,28 +852,21 @@ function SettingsDialog({workflow, open, onOpenChange, onSave}: SettingsDialogPr
- {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 +

From a0269d4874ac9f11d261fa242063f1a631c657c4 Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Mon, 16 Mar 2026 14:48:32 +0800 Subject: [PATCH 2/7] feat(i18n): add Traditional Chinese (zh-TW) translations Add complete zh-TW locale for contact-facing pages and email footer: - Unsubscribe page - Subscribe page - Manage preferences page - Email footer (unsubscribe text) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared/src/i18n/index.ts | 2 + packages/shared/src/i18n/languages.ts | 1 + packages/shared/src/i18n/locales/zh-TW.json | 45 +++++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 packages/shared/src/i18n/locales/zh-TW.json 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": "更新你的偏好設定" + } + } +} From a9494c8abd2bf852a771298bb9f90a01ae065dd3 Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Mon, 16 Mar 2026 15:36:00 +0800 Subject: [PATCH 3/7] feat: add send test email for templates Add the ability to send test emails directly from the template editor, matching the existing campaign test email functionality. - POST /templates/:id/test API endpoint - TemplateService.sendTest() with project member validation - "Send Test" button in template editor header - Dialog with project member selector Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/api/src/controllers/Templates.ts | 28 +++++++ apps/api/src/services/TemplateService.ts | 56 ++++++++++++++ apps/web/src/pages/templates/[id].tsx | 95 +++++++++++++++++++++++- 3 files changed, 178 insertions(+), 1 deletion(-) 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..55d34bcd 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,23 @@ export default function TemplateEditorPage() { } }; + const handleSendTestEmail = async () => { + if (!testEmailAddress) return; + setSendingTestEmail(true); + try { + await network.fetch('POST', `/templates/${id}/test`, { + email: testEmailAddress, + }); + 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 +202,15 @@ export default function TemplateEditorPage() { )}
+ + + + + + {/* Delete Template Confirmation */} Date: Mon, 16 Mar 2026 15:57:52 +0800 Subject: [PATCH 4/7] fix: add type parameter to network.fetch for template test email --- apps/web/src/pages/templates/[id].tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/web/src/pages/templates/[id].tsx b/apps/web/src/pages/templates/[id].tsx index 55d34bcd..9584d45f 100644 --- a/apps/web/src/pages/templates/[id].tsx +++ b/apps/web/src/pages/templates/[id].tsx @@ -128,9 +128,8 @@ export default function TemplateEditorPage() { if (!testEmailAddress) return; setSendingTestEmail(true); try { - await network.fetch('POST', `/templates/${id}/test`, { - email: testEmailAddress, - }); + // 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(''); From 6a50e1efe07d98e54f6bcd49fc80d6cd41fea436 Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Tue, 24 Mar 2026 14:10:22 +0800 Subject: [PATCH 5/7] ci: trigger docker build on deploy/custom branch Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/docker-publish.yml | 1 + 1 file changed, 1 insertion(+) 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 From 0f56db9c8902404a3a58962e6a41958d10b42019 Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Tue, 24 Mar 2026 14:27:43 +0800 Subject: [PATCH 6/7] fix: use Input + datalist for event selector in CreateWorkflowDialog Replace Select/Input conditional pattern with unified Input + datalist to match the edit page, allowing users to type custom event names while still getting autocomplete from previously tracked events. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/src/pages/workflows/index.tsx | 48 +++++++++----------------- docs/CONTEXT.md | 34 ++++++++++++++++++ 2 files changed, 51 insertions(+), 31 deletions(-) create mode 100644 docs/CONTEXT.md 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/docs/CONTEXT.md b/docs/CONTEXT.md new file mode 100644 index 00000000..b2f226f7 --- /dev/null +++ b/docs/CONTEXT.md @@ -0,0 +1,34 @@ +# Context:fix/workflow-creation-event-selector + +## 問題背景 + +上游 PR https://github.com/useplunk/plunk/pull/320 修改了 workflow **編輯頁** (`apps/web/src/pages/workflows/[id].tsx`) 的 event selector,將 `` 二選一的 pattern 改成 `` + `` 統一方式,讓使用者可以自由輸入 event name 同時有 autocomplete。 + +維護者 @driaug 回覆:「This change would also need to happen on the workflow creation page.」 + +## 根本原因 + +`CreateWorkflowDialog` 元件(`apps/web/src/pages/workflows/index.tsx:314-462`)的 Trigger Event 欄位仍使用舊 pattern: +- 有 tracked events 時 → 強制用 ``(第 408-417 行) + +這導致使用者無法輸入尚未被 track 的 event name(如 `email.opened`)。 + +## 需要修復的項目 + +- [ ] 將 `apps/web/src/pages/workflows/index.tsx` 第 394-423 行的 Select/Input 邏輯改為 `` + `` pattern +- [ ] 參考 `apps/web/src/pages/workflows/[id].tsx` 第 855-872 行的實作方式 +- [ ] 提示文字統一改為 "Type or select from previously tracked events" 之類的描述 + +## 關鍵檔案 + +| 檔案路徑 | 說明 | +|---------|------| +| `apps/web/src/pages/workflows/index.tsx:314-462` | `CreateWorkflowDialog` 元件,需要修改 | +| `apps/web/src/pages/workflows/[id].tsx:853-872` | 已修好的 datalist pattern,作為參考 | + +## 目前進度 + +尚未開始。需要在此 worktree 修改後,cherry-pick 或 merge 回 PR #320 的 branch。 + +注意:PR #320 的 upstream branch 是 `fix/custom-event-name-in-workflow`,修改完成後需要 push 到那個 branch(或直接在 upstream fork 操作)。 From 8e702298ea9982a3b6ffee6e59110970a7ca7b89 Mon Sep 17 00:00:00 2001 From: Hana Chang Date: Tue, 24 Mar 2026 14:30:53 +0800 Subject: [PATCH 7/7] chore: remove branch-specific context doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/CONTEXT.md contained investigation notes and progress tracking for this branch — belongs in PR description, not the codebase. Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/CONTEXT.md | 34 ---------------------------------- 1 file changed, 34 deletions(-) delete mode 100644 docs/CONTEXT.md diff --git a/docs/CONTEXT.md b/docs/CONTEXT.md deleted file mode 100644 index b2f226f7..00000000 --- a/docs/CONTEXT.md +++ /dev/null @@ -1,34 +0,0 @@ -# Context:fix/workflow-creation-event-selector - -## 問題背景 - -上游 PR https://github.com/useplunk/plunk/pull/320 修改了 workflow **編輯頁** (`apps/web/src/pages/workflows/[id].tsx`) 的 event selector,將 `` 二選一的 pattern 改成 `` + `` 統一方式,讓使用者可以自由輸入 event name 同時有 autocomplete。 - -維護者 @driaug 回覆:「This change would also need to happen on the workflow creation page.」 - -## 根本原因 - -`CreateWorkflowDialog` 元件(`apps/web/src/pages/workflows/index.tsx:314-462`)的 Trigger Event 欄位仍使用舊 pattern: -- 有 tracked events 時 → 強制用 ``(第 408-417 行) - -這導致使用者無法輸入尚未被 track 的 event name(如 `email.opened`)。 - -## 需要修復的項目 - -- [ ] 將 `apps/web/src/pages/workflows/index.tsx` 第 394-423 行的 Select/Input 邏輯改為 `` + `` pattern -- [ ] 參考 `apps/web/src/pages/workflows/[id].tsx` 第 855-872 行的實作方式 -- [ ] 提示文字統一改為 "Type or select from previously tracked events" 之類的描述 - -## 關鍵檔案 - -| 檔案路徑 | 說明 | -|---------|------| -| `apps/web/src/pages/workflows/index.tsx:314-462` | `CreateWorkflowDialog` 元件,需要修改 | -| `apps/web/src/pages/workflows/[id].tsx:853-872` | 已修好的 datalist pattern,作為參考 | - -## 目前進度 - -尚未開始。需要在此 worktree 修改後,cherry-pick 或 merge 回 PR #320 的 branch。 - -注意:PR #320 的 upstream branch 是 `fix/custom-event-name-in-workflow`,修改完成後需要 push 到那個 branch(或直接在 upstream fork 操作)。