Overview
PassTru’s email system uses a powerful rich text editor (Tiptap) to create branded confirmation emails with images, formatting, and dynamic variables.
Email Workflow
Design Email
Use the Tiptap editor in the Branding tab to compose your confirmation email template.
Add Variables
Insert dynamic variables like {{name}}, {{event_name}}, and {{portal_url}}.
Upload Images
Add logos, banners, and other images directly in the email body.
Test Email
Send a test email to yourself to preview the final result.
Send to Attendees
Trigger emails individually or in bulk when adding attendees.
Tiptap Rich Text Editor
The email editor is built with Tiptap, a headless editor framework:
Supported Features
Text Formatting Bold, Italic, Underline, Strikethrough
Headings H2 and H3 heading styles
Alignment Left, Center, Right text alignment
Lists Bullet lists and numbered lists
Links Clickable hyperlinks
Images Upload and embed images
Editor Implementation
src/components/RichTextEditor.tsx
import { useEditor , EditorContent } from "@tiptap/react" ;
import StarterKit from "@tiptap/starter-kit" ;
import Underline from "@tiptap/extension-underline" ;
import TextAlign from "@tiptap/extension-text-align" ;
import Link from "@tiptap/extension-link" ;
import Image from "@tiptap/extension-image" ;
const editor = useEditor ({
extensions: [
StarterKit ,
Underline ,
TextAlign . configure ({ types: [ "heading" , "paragraph" ] }),
Link . configure ({ openOnClick: false }),
Image . configure ({ inline: false , allowBase64: false }),
],
content ,
onUpdate : ({ editor }) => {
onChange ( editor . getHTML ());
},
});
The editor includes a comprehensive toolbar:
Text Formatting
< Toggle pressed = { editor . isActive ( "bold" ) } onPressedChange = { () => editor . chain (). focus (). toggleBold (). run () } >
< Bold className = "h-4 w-4" />
</ Toggle >
< Toggle pressed = { editor . isActive ( "italic" ) } onPressedChange = { () => editor . chain (). focus (). toggleItalic (). run () } >
< Italic className = "h-4 w-4" />
</ Toggle >
< Toggle pressed = { editor . isActive ( "underline" ) } onPressedChange = { () => editor . chain (). focus (). toggleUnderline (). run () } >
< UnderlineIcon className = "h-4 w-4" />
</ Toggle >
< Toggle pressed = { editor . isActive ( "strike" ) } onPressedChange = { () => editor . chain (). focus (). toggleStrike (). run () } >
< Strikethrough className = "h-4 w-4" />
</ Toggle >
Headings & Alignment
< Toggle pressed = { editor . isActive ( "heading" , { level: 2 }) } onPressedChange = { () => editor . chain (). focus (). toggleHeading ({ level: 2 }). run () } >
< Heading2 className = "h-4 w-4" />
</ Toggle >
< Toggle pressed = { editor . isActive ({ textAlign: "center" }) } onPressedChange = { () => editor . chain (). focus (). setTextAlign ( "center" ). run () } >
< AlignCenter className = "h-4 w-4" />
</ Toggle >
Lists & Links
< Toggle pressed = { editor . isActive ( "bulletList" ) } onPressedChange = { () => editor . chain (). focus (). toggleBulletList (). run () } >
< List className = "h-4 w-4" />
</ Toggle >
< Toggle pressed = { editor . isActive ( "link" ) } onPressedChange = { () => {
const url = prompt ( "Enter URL:" );
if ( url ) editor . chain (). focus (). setLink ({ href: url }). run ();
} } >
< LinkIcon className = "h-4 w-4" />
</ Toggle >
Image Upload
Images are uploaded to Supabase Storage and embedded in emails:
Upload Process
src/components/RichTextEditor.tsx
const handleImageUpload = async ( e : React . ChangeEvent < HTMLInputElement >) => {
const file = e . target . files ?.[ 0 ];
if ( ! file ) return ;
// Validate file type
if ( ! file . type . startsWith ( "image/" )) {
toast . error ( "Please select an image file" );
return ;
}
// Check file size (max 2MB)
if ( file . size > 2 * 1024 * 1024 ) {
toast . error ( "Image must be under 2MB" );
return ;
}
// Upload to storage
const ext = file . name . split ( "." ). pop ();
const path = ` ${ eventId } / ${ Date . now () } . ${ ext } ` ;
const { error } = await supabase . storage
. from ( "branding-assets" )
. upload ( path , file , { upsert: true });
if ( error ) {
toast . error ( "Upload failed: " + error . message );
return ;
}
// Get public URL and insert into editor
const { data : urlData } = supabase . storage
. from ( "branding-assets" )
. getPublicUrl ( path );
editor . chain (). focus (). setImage ({ src: urlData . publicUrl }). run ();
toast . success ( "Image inserted" );
};
< Button
variant = "ghost"
size = "icon"
onClick = { () => fileInputRef . current ?. click () }
title = "Insert image"
>
< ImagePlus className = "h-4 w-4" />
</ Button >
< input
ref = { fileInputRef }
type = "file"
accept = "image/*"
className = "hidden"
onChange = { handleImageUpload }
/>
Image Limits :
Max size: 2MB
Supported formats: All standard image types (PNG, JPG, GIF, WebP)
Base64 encoding disabled for security
Email Template Variables
Variables are replaced with actual attendee data when emails are sent:
Available Variables
src/pages/event/EventBranding.tsx
const TEMPLATE_VARIABLES = [
{ variable: "{{name}}" , description: "Attendee's full name" },
{ variable: "{{email}}" , description: "Attendee's email" },
{ variable: "{{unique_id}}" , description: "8-character check-in ID" },
{ variable: "{{event_name}}" , description: "Event name" },
{ variable: "{{event_date}}" , description: "Formatted event date" },
{ variable: "{{event_venue}}" , description: "Event venue" },
{ variable: "{{portal_url}}" , description: "Attendee portal link" },
{ variable: "{{qr_code_url}}" , description: "QR code image URL" },
];
Variable Reference Panel
Click-to-copy interface for easy variable insertion:
< div className = "flex flex-wrap gap-2" >
{ TEMPLATE_VARIABLES . map (( tv ) => (
< button
key = { tv . variable }
onClick = { () => {
navigator . clipboard . writeText ( tv . variable );
toast . success ( `Copied ${ tv . variable } ` );
} }
className = "inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-xs"
title = { tv . description }
>
< code className = "font-mono font-semibold" > { tv . variable } </ code >
< Copy className = "h-3 w-3" />
</ button >
)) }
</ div >
Test Email Functionality
Send a test email before sending to all attendees:
src/pages/event/EventBranding.tsx
const handleSendTestEmail = async () => {
if ( ! testEmail ) {
toast . error ( "Please enter a test email address" );
return ;
}
// Save draft first
await handleSaveDraft ();
// Get a sample attendee (or use first attendee)
const { data : attendees } = await supabase
. from ( "attendees" )
. select ( "id" )
. eq ( "event_id" , eventId )
. limit ( 1 );
if ( ! attendees || attendees . length === 0 ) {
toast . error ( "Add at least one attendee to send a test email" );
return ;
}
// Send test email
const { data , error } = await supabase . functions . invoke (
"send-confirmation-email" ,
{
body: {
attendee_ids: [ attendees [ 0 ]. id ],
test_email_override: testEmail ,
},
}
);
if ( error ) throw error ;
if ( data ?. results ?.[ 0 ]?. success ) {
toast . success ( `Test email sent to ${ testEmail } ` );
} else {
toast . error ( data ?. results ?.[ 0 ]?. error || "Failed to send test email" );
}
};
Test Email UI
< Card >
< CardHeader >
< CardTitle > Send Test Email </ CardTitle >
< CardDescription > Preview how the email looks by sending a test to yourself. </ CardDescription >
</ CardHeader >
< CardContent >
< div className = "flex gap-2" >
< Input
type = "email"
placeholder = "[email protected] "
value = { testEmail }
onChange = { ( e ) => setTestEmail ( e . target . value ) }
/>
< Button onClick = { handleSendTestEmail } disabled = { sendingTest } >
< Send className = "mr-2 h-4 w-4" />
{ sendingTest ? "Sending..." : "Send Test" }
</ Button >
</ div >
</ CardContent >
</ Card >
Sending Confirmation Emails
Emails can be sent in several ways:
Bulk Send
Individual Send
Auto-Send
Send to all attendees at once from Attendee Management: src/pages/event/AttendeeManagement.tsx
const sendAllEmails = async () => {
const ids = attendees . map (( a ) => a . id );
const { data , error } = await supabase . functions . invoke (
"send-confirmation-email" ,
{ body: { attendee_ids: ids } }
);
toast . success ( `Sent ${ data ?. sent ?? 0 } emails ( ${ data ?. failed ?? 0 } failed)` );
};
Send to specific attendee from Check-In Management: src/pages/event/CheckInManagement.tsx
const sendSingleEmail = async ( attendeeId : string ) => {
const { data , error } = await supabase . functions . invoke (
"send-confirmation-email" ,
{ body: { attendee_ids: [ attendeeId ] } }
);
};
Automatically send when attendees are added (if configured in edge function).
Email Sending UI
src/pages/event/AttendeeManagement.tsx
< Button
variant = "outline"
size = "sm"
disabled = { sendingEmails || attendees . length === 0 }
onClick = {async () => {
setSendingEmails ( true );
try {
const ids = attendees . map (( a ) => a . id );
const { data , error } = await supabase . functions . invoke (
"send-confirmation-email" ,
{ body: { attendee_ids: ids } }
);
if ( error ) throw error ;
toast . success ( `Sent ${ data ?. sent ?? 0 } emails ( ${ data ?. failed ?? 0 } failed)` );
} catch ( err : any ) {
toast . error ( err . message || "Failed to send emails" );
}
setSendingEmails ( false );
} }
>
{ sendingEmails ? (
< Loader2 className = "mr-2 h-4 w-4 animate-spin" />
) : (
< Mail className = "mr-2 h-4 w-4" />
) }
{ sendingEmails ? "Sending..." : "Send All Emails" }
</ Button >
src/pages/event/CheckInManagement.tsx
< Button
variant = "outline"
size = "sm"
disabled = { sendingEmail === attendee . id }
onClick = {async () => {
setSendingEmail ( attendee . id );
const { data } = await supabase . functions . invoke (
"send-confirmation-email" ,
{ body: { attendee_ids: [ attendee . id ] } }
);
if ( data ?. sent > 0 ) {
toast . success ( `Confirmation email sent to ${ attendee . name } ` );
}
setSendingEmail ( null );
} }
>
{ sendingEmail === attendee . id ? (
< Loader2 className = "mr-2 h-4 w-4 animate-spin" />
) : (
< Mail className = "mr-2 h-4 w-4" />
) }
{ attendee . confirmation_email_sent ? "Resend" : "Send Email" }
</ Button >
Email Storage
Email content is stored as HTML in the database:
src/pages/event/EventBranding.tsx
const { error } = await supabase . from ( "events" ). update ({
confirmation_email_content: emailContent || null ,
}). eq ( "id" , eventId );
Email Service
Emails are sent via Resend through an edge function:
Edge Function : send-confirmation-emailHandles:
Variable replacement
QR code generation
Email delivery via Resend
Error handling and tracking
Undo/Redo Support
The editor includes undo/redo functionality:
< Button onClick = { () => editor . chain (). focus (). undo (). run () } >
< Undo className = "h-4 w-4" />
</ Button >
< Button onClick = { () => editor . chain (). focus (). redo (). run () } >
< Redo className = "h-4 w-4" />
</ Button >
Email Tracking
The system tracks email delivery status:
// Database field
confirmation_email_sent : boolean
// Updated when email is successfully sent
const { error } = await supabase
. from ( "attendees" )
. update ({ confirmation_email_sent: true })
. eq ( "id" , attendeeId );
The email tracking field is updated by the edge function after successful delivery, allowing you to see which attendees have received their confirmation emails.