Skip to main content

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

1

Design Email

Use the Tiptap editor in the Branding tab to compose your confirmation email template.
2

Add Variables

Insert dynamic variables like {{name}}, {{event_name}}, and {{portal_url}}.
3

Upload Images

Add logos, banners, and other images directly in the email body.
4

Test Email

Send a test email to yourself to preview the final result.
5

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());
  },
});

Toolbar Controls

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>
<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");
};

Image Button

<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:
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)`);
};

Email Sending UI

Bulk Send Button

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>

Individual Send 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.

Build docs developers (and LLMs) love