Apps

Templates

Native app for reusable email + SMS messages — folder tree, merge fields with fallbacks, per-channel frames, and attachments (with inline images) shared by email compose, the dial SMS tab, and the SMS app.

Templates is a native app for the reusable messages your team sends over and over — welcome emails, follow-up SMS, license PDFs, brochures, business cards. Author them once, drop them into any send surface (email compose, the dial SMS tab, the SMS app).

Templates opens as a native shell surface — full-bleed, no React Flow canvas underneath — mounted by TemplatesSurface and registered in native-apps/registry.js with kind: 'native'. Opening Templates from the sidebar mounts the workspace directly; there's no canvas placement step. See Canvases and widgets → native apps vs canvases.

The app doesn't ship a canvas widget — it's a library, not a dashboard tile. To use a template on a canvas, drop the surface that consumes it (compose, SMS inbox, dial widget) and pick the template from that surface's picker.

Layout

A three-column shape mirroring Pages:

  • Left — folder tree (TemplateTreeSidebar): full-height rail with an All / Email / SMS smart-filter strip on top, then a drag-to-reorder tree of folders and templates. Deleting a folder sends an explicit descendant list so the server enforces per-row permissions on every child (rather than leaning on the FK cascade, which would run with the parent's permission and could remove rows RLS should refuse).
  • Center — gallery (TemplateGallery): a card grid for the active folder / filter, with the same channel frames from the editor scaled down. Duplicate, rename, and delete live on the row menu.
  • Right — editor (TemplateEditor): channel-specific frame (see below) with Preview / Edit toggle. Templates open in preview — Edit is a deliberate switch, so the day-to-day case of "grab a template, insert it" doesn't require any commit gesture.

Channel frames

Each channel is framed as the message it becomes, not stretched to the pane:

  • SMS — a 380 px phone with a bottom-anchored composer. The bubbles you see in Edit are exactly the bubbles the recipient sees.
  • Email — a 720 px sheet approximating a Gmail reader card, with subject, body, and inline images in place.

Edit and Preview share the frame, so toggling changes the content — merge fields resolving, attachments rendering — not the layout. No sudden reflow when you flip to see how the merged message reads.

Merge fields

Templates support {{contact.first_name|there}}-style merge tags with inline fallbacks — because CRM data is ragged and "Hi ," is worse than "Hi there,".

  • contact.* keys line up 1:1 with the dialer's DIAL_SLOTS, so the dialer's active lead drops straight in without a shape translation. Pick the merge tag from the picker or type it by hand — both paths flow through the same resolveMergeTags helper.
  • An unresolvable tag with no fallback stays visible in the inserted text rather than leaving an invisible hole in a real outbound message. A {{contact.email}} on a lead with no email renders as the literal {{contact.email}} on paste — obvious enough to catch before send.
  • The picker counts what won't fill in before you insert, so you can spot a mismatch (this template needs contact.company but the target contact has none) up front.

Where templates get used

One TemplatePickerPopover primitive behind three call sites:

  • Email compose — body inserts at the cursor, subject fills only when empty. Neither overwrites the work you've already typed. See Inbox → template insert.
  • The dial SMS tab — the per-lead composer picks up the picker and merges against the active lead. See Dial → SMS tab.
  • The SMS app — the shared thread composer picks up the picker and merges against the conversation's contact.

Both SMS surfaces run on the same SmsComposer component, so a picker fix or a merge-field addition lands in both immediately.

Attachments

Business cards, licenses, brochures — anything you'd normally drag onto a compose window. Attachments live in a new template-attachments storage bucket (org-scoped path), tracked by a message_template_attachments child table with rich metadata (size, mime, placement, order).

Server-side copy on insert

The load-bearing decision: the shared email-api refuses any storagePath that doesn't start with the sending user's id (index.ts:547) — the only defense against a caller naming someone else's file, since the send runs as service role.

Rather than loosen that check, inserting a template into a compose server-side copies each attachment into that draft's own storage prefix. No bytes cross the browser, the send path stays untouched, and the resulting draft is self-contained: editing the template later can't alter an email already queued or sent.

Delete ordering

Storage deletes are authorized via the metadata row, so bytes obey the same creator-or-admin rule as the row describing them. That makes ordering load-bearing: object first, then row. Deleting a template strips its objects before the rows cascade — otherwise the row would vanish and the bytes would be stranded forever with no metadata to authorize their removal.

Inline images in email

Images under 1 MB can go inline in an email body, dropped exactly where you want them. Tiptap's posAtCoordsinsertContentAt puts the image at the drop point in the editor, so the layout you build in the template is the layout the recipient sees.

SVG is excluded from inline. SVG can carry script, and several mail clients strip it — it would look right in the editor and arrive blank in the recipient's inbox. SVGs are still allowed as regular file attachments.

Images as SMS bubbles

On SMS, the pictures are the message — they render as bubbles in the phone frame, next to the text bubble.

Copy message carries images as base64 data URIs in the clipboard's text/html flavour (templateClipboard.js) — Gmail, Outlook, and Notes honour that flavour, so pasting the full template into an email reproduces it exactly.

Copy picture is the SMS bridge. Messages, WhatsApp, and every native SMS app read text/plain and drop the picture from the HTML flavour — so each image in the SMS preview also carries its own button that puts the bytes on the clipboard under image/png, the one raster type a browser will let you write and the flavour those apps take. One button per picture, because a clipboard holds one image at a time.

  • Non-PNG (JPEG / WebP / GIF) is re-encoded through a canvas first; an animated GIF copies as its first frame.
  • The fetch is handed to ClipboardItem unresolved, so Safari keeps the write inside the click that started it.
  • Every failure — undecodable format, dead download, no clipboard support — reports in the pane's status line rather than a button that silently did nothing.

Paste to attach. Pasting a screenshot into the SMS composer attaches it, matching the drop path, instead of being ignored by the textarea. clipboardData.items is read first because Safari populates that and leaves .files empty for a copied screenshot; ordinary text pastes are untouched. On SMS, drop attaches too — it previously embedded the image in the body as a data URI (the email path), which typed several hundred KB of base64 into the message text.

Sending SMS attachments today

The picker will let you attach files to a template's SMS side and they render in the phone preview and the Copy-message payload — but the current PitchPrfct client's send is {toNumber, content, fromNumber} with no media field. Until MMS support is confirmed on the provider, the composer surfaces "N files — not sent with a text" rather than dropping them silently. Copy picture (per-image, above) is the practical bridge for delivering an SMS template's pictures — Copy message still carries the full text-plus-HTML payload for surfaces that accept it.

Permissions

Read / write any template: any org member. Delete a template: creator or org admin. This is chosen deliberately between two worse defaults:

  • Admin only (the pages rule) — a rep can't clean up their own drafts without a manager.
  • Any org member — one careless drag takes out the team's whole library.

Folder-tree operations respect the same rule per row: a folder delete sends an explicit descendant id list so the server evaluates permissions per template, and rows the server refuses are reported back, not swallowed. You see which templates the delete couldn't touch.

Under the hood

  • Migration 162_message_templates — the tree table (message_templates with parent_id, sort_order, channel), RLS policies (org-member read/write, creator-or-admin delete), Realtime publication, Data API grants.
  • Migration 163_message_template_attachments — the template-attachments bucket + message_template_attachments child table + storage policies (creator-or-admin delete via metadata row).
  • Migration 164_message_template_attachment_placement — the placement column driving where an attachment sits relative to the text (inline vs bubble vs plain file).
  • lib/treeSortOrder — sort-order bisection + renumber-on-collapse, extracted from store/pages.jsx and now shared by Templates and Pages. The 51 existing pages-folder tests kept passing unchanged through the refactor, which is what makes it safe.
  • lib/attachmentLimits — the 25 MB caps and formatBytes helper, shared with the email composeStore so a single number lives in one file.
  • SmsComposer component — used by both the dial SMS tab and the SMS app, so picker fixes land in both surfaces at once.

Not included

  • A shared asset library. The same license PDF on a dozen templates is stored a dozen times today. Fine at megabytes; worth revisiting if collateral grows.
  • Recursive folder duplication. Duplicate is offered on templates only, not on folders.
  • Real MMS send on the dial / SMS composer. The picker attaches and previews media, but the outbound path is text-only until the PitchPrfct field lands.

Where to next

On this page