diff --git a/apps/api/src/jobs/__tests__/email-processor.test.ts b/apps/api/src/jobs/__tests__/email-processor.test.ts index 633b735..d1551ca 100644 --- a/apps/api/src/jobs/__tests__/email-processor.test.ts +++ b/apps/api/src/jobs/__tests__/email-processor.test.ts @@ -1,6 +1,6 @@ import {beforeEach, describe, expect, it, vi} from 'vitest'; import type {Prisma} from '@plunk/db'; -import {EmailSourceType, EmailStatus} from '@plunk/db'; +import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db'; import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers'; // Mock MeterService @@ -16,7 +16,7 @@ describe('Email Processor', () => { const _serviceMocks = createServiceMocks(); beforeEach(async () => { - const {project} = await factories.createUserWithProject({}, {trackingEnabled: true}); + const {project} = await factories.createUserWithProject({}, {tracking: TrackingMode.ENABLED}); projectId = project.id; }); diff --git a/apps/api/src/jobs/email-processor.ts b/apps/api/src/jobs/email-processor.ts index 64071f4..96cae3e 100644 --- a/apps/api/src/jobs/email-processor.ts +++ b/apps/api/src/jobs/email-processor.ts @@ -89,6 +89,9 @@ export function createEmailWorker() { ? {name: email.toName, email: email.contact.email} : email.contact.email; + // Determine tracking based on project settings and email type + const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType); + // Send via AWS SES const result = await sendRawEmail({ from: { @@ -101,7 +104,7 @@ export function createEmailWorker() { html: compiledHtml, }, reply: email.replyTo || undefined, - tracking: email.project.trackingEnabled, // Use project's tracking preference + tracking: shouldTrack, attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null, }); diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index a1ca47c..b120403 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -1,5 +1,5 @@ import type {Contact, Email, Prisma, Project} from '@plunk/db'; -import {EmailSourceType, EmailStatus} from '@plunk/db'; +import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db'; import signale from 'signale'; import {DASHBOARD_URI, LANDING_URI, STRIPE_ENABLED} from '../app/constants.js'; @@ -365,6 +365,9 @@ export class EmailService { ? (email.attachments as Array<{filename: string; content: string; contentType: string}>) : undefined; + // Determine tracking based on project settings and email type + const shouldTrack = this.shouldTrackEmail(email.project.tracking, email.sourceType); + // Send via AWS SES const result = await sendRawEmail({ from: { @@ -379,7 +382,7 @@ export class EmailService { reply: email.replyTo || undefined, headers: customHeaders, attachments: attachments, - tracking: email.project.trackingEnabled, // Use project's tracking preference + tracking: shouldTrack, }); // Mark as sent with SES message ID @@ -565,6 +568,23 @@ export class EmailService { }; } + /** + * Determine if an email should be tracked based on project tracking mode and email source type + */ + public static shouldTrackEmail(trackingMode: TrackingMode, sourceType: EmailSourceType): boolean { + switch (trackingMode) { + case TrackingMode.ENABLED: + return true; + case TrackingMode.DISABLED: + return false; + case TrackingMode.MARKETING_ONLY: + // Track only campaigns and workflows (marketing), not transactional emails + return sourceType !== EmailSourceType.TRANSACTIONAL; + default: + return true; + } + } + /** * Compile HTML email with optional unsubscribe footer and badge * Adds unsubscribe link and Plunk badge for free tier users (only when billing is enabled) diff --git a/apps/web/src/components/TeamSettings.tsx b/apps/web/src/components/TeamSettings.tsx index fc56b3a..2303fd5 100644 --- a/apps/web/src/components/TeamSettings.tsx +++ b/apps/web/src/components/TeamSettings.tsx @@ -29,6 +29,7 @@ import { Select, SelectContent, SelectItem, + SelectItemWithDescription, SelectTrigger, SelectValue, Table, @@ -346,8 +347,16 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe - Member - Can view and use the project - Admin - Can manage settings and members + + diff --git a/apps/web/src/pages/settings/index.tsx b/apps/web/src/pages/settings/index.tsx index 9c116d1..ddf248d 100644 --- a/apps/web/src/pages/settings/index.tsx +++ b/apps/web/src/pages/settings/index.tsx @@ -2,6 +2,7 @@ import {useEffect, useState} from 'react'; import {useForm} from 'react-hook-form'; import {zodResolver} from '@hookform/resolvers/zod'; import {ProjectSchemas} from '@plunk/shared'; +import {TrackingMode} from '@plunk/db'; import { Alert, Button, @@ -24,7 +25,11 @@ import { FormLabel, FormMessage, Input, - Switch, + Select, + SelectContent, + SelectItemWithDescription, + SelectTrigger, + SelectValue, Tabs, TabsContent, TabsList, @@ -158,7 +163,7 @@ export default function Settings() { resolver: zodResolver(ProjectSchemas.update), defaultValues: { name: activeProject?.name || '', - trackingEnabled: activeProject?.trackingEnabled ?? true, + tracking: activeProject?.tracking ?? TrackingMode.ENABLED, }, }); @@ -167,7 +172,7 @@ export default function Settings() { if (activeProject) { form.reset({ name: activeProject.name, - trackingEnabled: activeProject.trackingEnabled ?? true, + tracking: activeProject.tracking ?? TrackingMode.ENABLED, }); } }, [activeProject, form]); @@ -395,23 +400,42 @@ export default function Settings() { )} /> - {/* Email Tracking Toggle - only show if feature is available */} + {/* Email Tracking Mode - only show if feature is available */} {trackingToggleEnabled && ( ( - -
- Email Tracking - - Enable open and click tracking for emails sent from this project. When disabled, - emails will be sent without tracking pixels. - -
- - - + + Email Tracking + + + Control how email opens and clicks are tracked for this project. + + )} /> @@ -659,7 +683,11 @@ export default function Settings() { {/* Team Tab */} - + {/* Domains Tab */} diff --git a/apps/web/src/pages/templates/[id].tsx b/apps/web/src/pages/templates/[id].tsx index e9c1bed..adbf17e 100644 --- a/apps/web/src/pages/templates/[id].tsx +++ b/apps/web/src/pages/templates/[id].tsx @@ -10,7 +10,7 @@ import { Label, Select, SelectContent, - SelectItem, + SelectItemWithDescription, SelectTrigger, SelectValue, StickySaveBar, @@ -232,8 +232,16 @@ export default function TemplateEditorPage() { - Marketing - Transactional + +

diff --git a/apps/web/src/pages/templates/create.tsx b/apps/web/src/pages/templates/create.tsx index fa0039a..36bfaa7 100644 --- a/apps/web/src/pages/templates/create.tsx +++ b/apps/web/src/pages/templates/create.tsx @@ -9,7 +9,7 @@ import { Label, Select, SelectContent, - SelectItem, + SelectItemWithDescription, SelectTrigger, SelectValue, } from '@plunk/ui'; @@ -74,117 +74,127 @@ export default function CreateTemplatePage() { return ( <> - -

- {/* Header */} -
-
- - + +
+

Create Template

+

+ Create a reusable email template for campaigns and workflows +

+
+
+
+ - -
-

Create Template

-

Create a reusable email template for campaigns and workflows

-
- -
-
-
- {/* Template Settings */} - - - Template Settings - Configure your template details and email settings - - -
+ + {/* Template Settings */} + + + Template Settings + Configure your template details and email settings + + +
+
+ + setName(e.target.value)} + required + placeholder="Welcome Email" + /> +
+ +
+ + +
+
+
- + setName(e.target.value)} - required - placeholder="Welcome Email" + value={description} + onChange={e => setDescription(e.target.value)} + placeholder="Sent to new subscribers" />
- - + + setSubject(e.target.value)} + required + placeholder="Welcome to our platform!" + />
-
-
- - setDescription(e.target.value)} - placeholder="Sent to new subscribers" + -
+
+
-
- - setSubject(e.target.value)} - required - placeholder="Welcome to our platform!" + {/* Email Body */} + + + Email Body + Create your email using the visual editor or paste custom HTML + + + -
- - - - - - {/* Email Body */} - - - Email Body - Create your email using the visual editor or paste custom HTML - - - - - - -
- + + + + + ); } diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index f44a316..78fd853 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -23,6 +23,7 @@ import { Select, SelectContent, SelectItem, + SelectItemWithDescription, SelectTrigger, SelectValue, Switch @@ -1076,13 +1077,37 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo - Send Email - Delay - Wait for time - Wait for Event - Condition - If/else branching - Webhook - Call external API - Update Contact - Exit - End workflow + + + + + + +

diff --git a/packages/db/prisma/migrations/20251205144203_init/migration.sql b/packages/db/prisma/migrations/20251207125935_init/migration.sql similarity index 99% rename from packages/db/prisma/migrations/20251205144203_init/migration.sql rename to packages/db/prisma/migrations/20251207125935_init/migration.sql index 00797a7..7c537c7 100644 --- a/packages/db/prisma/migrations/20251205144203_init/migration.sql +++ b/packages/db/prisma/migrations/20251207125935_init/migration.sql @@ -7,6 +7,9 @@ CREATE TYPE "Role" AS ENUM ('OWNER', 'ADMIN', 'MEMBER'); -- CreateEnum CREATE TYPE "TemplateType" AS ENUM ('TRANSACTIONAL', 'MARKETING'); +-- CreateEnum +CREATE TYPE "TrackingMode" AS ENUM ('ENABLED', 'DISABLED', 'MARKETING_ONLY'); + -- CreateEnum CREATE TYPE "CampaignStatus" AS ENUM ('DRAFT', 'SCHEDULED', 'SENDING', 'SENT', 'CANCELLED'); @@ -55,7 +58,7 @@ CREATE TABLE "projects" ( "billingLimitWorkflows" INTEGER, "billingLimitCampaigns" INTEGER, "billingLimitTransactional" INTEGER, - "trackingEnabled" BOOLEAN NOT NULL DEFAULT true, + "tracking" "TrackingMode" NOT NULL DEFAULT 'ENABLED', "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updatedAt" TIMESTAMP(3) NOT NULL, diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 53c95d1..035d19d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -54,7 +54,7 @@ model Project { billingLimitTransactional Int? // Max transactional emails per month // Email Tracking - trackingEnabled Boolean @default(true) // Enable/disable open and click tracking + tracking TrackingMode @default(ENABLED) // Open and click tracking mode // Relations members Membership[] @@ -636,6 +636,12 @@ enum TemplateType { MARKETING } +enum TrackingMode { + ENABLED // Track opens and clicks for all emails + DISABLED // No tracking for any emails + MARKETING_ONLY // Track only marketing/campaign emails, not transactional +} + enum CampaignStatus { DRAFT SCHEDULED diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 371e39a..181b7c9 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -1,4 +1,4 @@ -import {CampaignAudienceType, TemplateType, WorkflowStepType, WorkflowTriggerType} from '@plunk/db'; +import {CampaignAudienceType, TemplateType, TrackingMode, WorkflowStepType, WorkflowTriggerType} from '@plunk/db'; import {z} from 'zod'; const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null(), z.date()]); @@ -59,7 +59,7 @@ export const ProjectSchemas = { }), update: z.object({ name: z.string().min(1).max(100).optional(), - trackingEnabled: z.boolean().optional(), + tracking: z.nativeEnum(TrackingMode).optional(), }), } as const; @@ -223,11 +223,7 @@ export const WorkflowStepConfigSchemas = { ), waitForEvent: z.object({ eventName: z.string().min(1), - timeout: z - .number() - .positive() - .max(31536000, 'Timeout cannot exceed 365 days (31,536,000 seconds)') - .optional(), + timeout: z.number().positive().max(31536000, 'Timeout cannot exceed 365 days (31,536,000 seconds)').optional(), }), condition: z.object({ field: z.string().min(1), diff --git a/packages/ui/src/components/atoms/Select.tsx b/packages/ui/src/components/atoms/Select.tsx index 29ac1cc..7234db8 100644 --- a/packages/ui/src/components/atoms/Select.tsx +++ b/packages/ui/src/components/atoms/Select.tsx @@ -122,6 +122,37 @@ const SelectItem = React.forwardRef< )); SelectItem.displayName = SelectPrimitive.Item.displayName; +interface SelectItemWithDescriptionProps extends React.ComponentPropsWithoutRef { + title: string; + description?: string; +} + +const SelectItemWithDescription = React.forwardRef< + React.ElementRef, + SelectItemWithDescriptionProps +>(({className, title, description, ...props}, ref) => ( + + + + + + + +

+ {title} + {description && {description}} +
+ +)); +SelectItemWithDescription.displayName = 'SelectItemWithDescription'; + const SelectSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef @@ -138,6 +169,7 @@ export { SelectContent, SelectLabel, SelectItem, + SelectItemWithDescription, SelectSeparator, SelectScrollUpButton, SelectScrollDownButton, diff --git a/test/helpers/factories.ts b/test/helpers/factories.ts index 434210b..9b2914d 100644 --- a/test/helpers/factories.ts +++ b/test/helpers/factories.ts @@ -7,6 +7,7 @@ import { PrismaClient, Role, TemplateType, + TrackingMode, WorkflowExecutionStatus, WorkflowStepType, WorkflowTriggerType @@ -34,7 +35,7 @@ export interface UserFactoryOptions { export interface ProjectFactoryOptions { name?: string; disabled?: boolean; - trackingEnabled?: boolean; + tracking?: TrackingMode; billingLimitWorkflows?: number | null; billingLimitCampaigns?: number | null; billingLimitTransactional?: number | null; @@ -127,7 +128,7 @@ export class TestFactories { public: `pk_${uniqueId()}`, secret: `sk_${uniqueId()}`, disabled: options.disabled || false, - trackingEnabled: options.trackingEnabled ?? true, + tracking: options.tracking ?? TrackingMode.ENABLED, billingLimitWorkflows: options.billingLimitWorkflows, billingLimitCampaigns: options.billingLimitCampaigns, billingLimitTransactional: options.billingLimitTransactional,