Selectors with description

This commit is contained in:
Dries Augustyns
2025-12-07 14:20:44 +01:00
parent 683356c17c
commit c189175658
13 changed files with 282 additions and 141 deletions
@@ -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;
});
+4 -1
View File
@@ -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,
});
+22 -2
View File
@@ -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)
+11 -2
View File
@@ -29,6 +29,7 @@ import {
Select,
SelectContent,
SelectItem,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
Table,
@@ -346,8 +347,16 @@ export function TeamSettings({projectId, currentUserRole, currentUserId}: TeamSe
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="MEMBER">Member - Can view and use the project</SelectItem>
<SelectItem value="ADMIN">Admin - Can manage settings and members</SelectItem>
<SelectItemWithDescription
value="MEMBER"
title="Member"
description="Can view and use the project"
/>
<SelectItemWithDescription
value="ADMIN"
title="Admin"
description="Can manage settings and members"
/>
</SelectContent>
</Select>
<FormMessage />
+45 -17
View File
@@ -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 && (
<FormField
control={form.control}
name="trackingEnabled"
name="tracking"
render={({field}) => (
<FormItem className="flex flex-row items-center justify-between rounded-lg border border-neutral-200 p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Email Tracking</FormLabel>
<FormDescription>
Enable open and click tracking for emails sent from this project. When disabled,
emails will be sent without tracking pixels.
</FormDescription>
</div>
<FormControl>
<Switch checked={field.value} onCheckedChange={field.onChange} />
</FormControl>
<FormItem>
<FormLabel>Email Tracking</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select tracking mode" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItemWithDescription
value={TrackingMode.ENABLED}
title="Enabled"
description="Track opens and clicks for all emails"
/>
<SelectItemWithDescription
value={TrackingMode.DISABLED}
title="Disabled"
description="No tracking for any emails"
/>
<SelectItemWithDescription
value={TrackingMode.MARKETING_ONLY}
title="Marketing Only"
description="Track only campaigns and workflow emails, not transactional"
/>
</SelectContent>
</Select>
<FormDescription>
Control how email opens and clicks are tracked for this project.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
@@ -659,7 +683,11 @@ export default function Settings() {
{/* Team Tab */}
<TabsContent value="team">
<TeamSettings projectId={activeProject.id} currentUserRole={currentUserRole} currentUserId={user?.id || ''} />
<TeamSettings
projectId={activeProject.id}
currentUserRole={currentUserRole}
currentUserId={user?.id || ''}
/>
</TabsContent>
{/* Domains Tab */}
+11 -3
View File
@@ -10,7 +10,7 @@ import {
Label,
Select,
SelectContent,
SelectItem,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
StickySaveBar,
@@ -232,8 +232,16 @@ export default function TemplateEditorPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MARKETING">Marketing</SelectItem>
<SelectItem value="TRANSACTIONAL">Transactional</SelectItem>
<SelectItemWithDescription
value="MARKETING"
title="Marketing"
description="Includes unsubscribe link, respects opt-out"
/>
<SelectItemWithDescription
value="TRANSACTIONAL"
title="Transactional"
description="For receipts, alerts - sent regardless of opt-out"
/>
</SelectContent>
</Select>
<p className="text-xs text-neutral-500 mt-1">
+106 -96
View File
@@ -9,7 +9,7 @@ import {
Label,
Select,
SelectContent,
SelectItem,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
} from '@plunk/ui';
@@ -74,117 +74,127 @@ export default function CreateTemplatePage() {
return (
<>
<NextSeo title="Create Template" />
<DashboardLayout>
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/templates">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
<DashboardLayout>
<div className="max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="space-y-4">
<div className="flex items-center gap-3 sm:gap-4">
<Link href="/templates">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Create Template</h1>
<p className="text-neutral-500 mt-1 text-sm sm:text-base">
Create a reusable email template for campaigns and workflows
</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSubmit} disabled={saving} className="w-full sm:w-auto">
<Save className="h-4 w-4" />
<span className="hidden sm:inline">{saving ? 'Creating...' : 'Create Template'}</span>
<span className="sm:hidden">{saving ? 'Creating...' : 'Create'}</span>
</Button>
</Link>
<div className="flex-1 min-w-0">
<h1 className="text-2xl sm:text-3xl font-bold text-neutral-900">Create Template</h1>
<p className="text-neutral-500 mt-1 text-sm sm:text-base">Create a reusable email template for campaigns and workflows</p>
</div>
</div>
<div className="flex justify-end">
<Button onClick={handleSubmit} disabled={saving} className="w-full sm:w-auto">
<Save className="h-4 w-4" />
<span className="hidden sm:inline">{saving ? 'Creating...' : 'Create Template'}</span>
<span className="sm:hidden">{saving ? 'Creating...' : 'Create'}</span>
</Button>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Template Settings */}
<Card>
<CardHeader>
<CardTitle>Template Settings</CardTitle>
<CardDescription>Configure your template details and email settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<form onSubmit={handleSubmit} className="space-y-6">
{/* Template Settings */}
<Card>
<CardHeader>
<CardTitle>Template Settings</CardTitle>
<CardDescription>Configure your template details and email settings</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="name">Template Name *</Label>
<Input
id="name"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Welcome Email"
/>
</div>
<div>
<Label htmlFor="type">Template Type *</Label>
<Select value={type} onValueChange={value => setType(value as 'MARKETING' | 'TRANSACTIONAL')}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItemWithDescription
value="MARKETING"
title="Marketing"
description="Includes unsubscribe link, respects opt-out"
/>
<SelectItemWithDescription
value="TRANSACTIONAL"
title="Transactional"
description="For receipts, alerts - sent regardless of opt-out"
/>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="name">Template Name *</Label>
<Label htmlFor="description">Description</Label>
<Input
id="name"
id="description"
type="text"
value={name}
onChange={e => setName(e.target.value)}
required
placeholder="Welcome Email"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Sent to new subscribers"
/>
</div>
<div>
<Label htmlFor="type">Template Type *</Label>
<Select value={type} onValueChange={value => setType(value as 'MARKETING' | 'TRANSACTIONAL')}>
<SelectTrigger id="type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="MARKETING">Marketing</SelectItem>
<SelectItem value="TRANSACTIONAL">Transactional</SelectItem>
</SelectContent>
</Select>
<Label htmlFor="subject">Subject Line *</Label>
<Input
id="subject"
type="text"
value={subject}
onChange={e => setSubject(e.target.value)}
required
placeholder="Welcome to our platform!"
/>
</div>
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
onChange={e => setDescription(e.target.value)}
placeholder="Sent to new subscribers"
<EmailSettings
from={from}
fromName={fromName}
replyTo={replyTo}
onFromChange={setFrom}
onFromNameChange={setFromName}
onReplyToChange={setReplyTo}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
/>
</div>
</CardContent>
</Card>
<div>
<Label htmlFor="subject">Subject Line *</Label>
<Input
id="subject"
type="text"
value={subject}
onChange={e => setSubject(e.target.value)}
required
placeholder="Welcome to our platform!"
{/* Email Body */}
<Card>
<CardHeader>
<CardTitle>Email Body</CardTitle>
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
value={body}
onChange={setBody}
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
/>
</div>
<EmailSettings
from={from}
fromName={fromName}
replyTo={replyTo}
onFromChange={setFrom}
onFromNameChange={setFromName}
onReplyToChange={setReplyTo}
fromNamePlaceholder={activeProject?.name || 'Your Company'}
/>
</CardContent>
</Card>
{/* Email Body */}
<Card>
<CardHeader>
<CardTitle>Email Body</CardTitle>
<CardDescription>Create your email using the visual editor or paste custom HTML</CardDescription>
</CardHeader>
<CardContent>
<EmailEditor
value={body}
onChange={setBody}
placeholder="<h1>Welcome!</h1><p>Thanks for subscribing to our newsletter.</p>"
/>
</CardContent>
</Card>
</form>
</div>
</DashboardLayout>
</CardContent>
</Card>
</form>
</div>
</DashboardLayout>
</>
);
}
+32 -7
View File
@@ -23,6 +23,7 @@ import {
Select,
SelectContent,
SelectItem,
SelectItemWithDescription,
SelectTrigger,
SelectValue,
Switch
@@ -1076,13 +1077,37 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SEND_EMAIL">Send Email</SelectItem>
<SelectItem value="DELAY">Delay - Wait for time</SelectItem>
<SelectItem value="WAIT_FOR_EVENT">Wait for Event</SelectItem>
<SelectItem value="CONDITION">Condition - If/else branching</SelectItem>
<SelectItem value="WEBHOOK">Webhook - Call external API</SelectItem>
<SelectItem value="UPDATE_CONTACT">Update Contact</SelectItem>
<SelectItem value="EXIT">Exit - End workflow</SelectItem>
<SelectItemWithDescription
value="SEND_EMAIL"
title="Send Email"
description="Send an email using a template"
/>
<SelectItemWithDescription
value="DELAY"
title="Delay"
description="Wait for a specified amount of time"
/>
<SelectItemWithDescription
value="WAIT_FOR_EVENT"
title="Wait for Event"
description="Pause until a specific event occurs"
/>
<SelectItemWithDescription
value="CONDITION"
title="Condition"
description="If/else branching based on contact data"
/>
<SelectItemWithDescription
value="WEBHOOK"
title="Webhook"
description="Call an external API endpoint"
/>
<SelectItemWithDescription
value="UPDATE_CONTACT"
title="Update Contact"
description="Modify contact data fields"
/>
<SelectItemWithDescription value="EXIT" title="Exit" description="End the workflow for this contact" />
</SelectContent>
</Select>
<p className="text-xs text-neutral-500 mt-1">
@@ -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,
+7 -1
View File
@@ -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
+3 -7
View File
@@ -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),
@@ -122,6 +122,37 @@ const SelectItem = React.forwardRef<
));
SelectItem.displayName = SelectPrimitive.Item.displayName;
interface SelectItemWithDescriptionProps extends React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> {
title: string;
description?: string;
}
const SelectItemWithDescription = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
SelectItemWithDescriptionProps
>(({className, title, description, ...props}, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default select-none items-start rounded-sm py-2.5 pl-8 pr-3 text-sm outline-none focus:bg-neutral-100 focus:text-neutral-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 top-3 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<div className="flex flex-col gap-0.5">
<SelectPrimitive.ItemText className="font-medium">{title}</SelectPrimitive.ItemText>
{description && <span className="text-xs text-neutral-500 leading-tight">{description}</span>}
</div>
</SelectPrimitive.Item>
));
SelectItemWithDescription.displayName = 'SelectItemWithDescription';
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
@@ -138,6 +169,7 @@ export {
SelectContent,
SelectLabel,
SelectItem,
SelectItemWithDescription,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
+3 -2
View File
@@ -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,