Merge pull request #845 from warmbly/devin/1791212676-structured-sender-variables

feat: add native Sender mailbox personalization to campaign emails
This commit is contained in:
Matthew Meszaros
2026-10-05 16:37:09 +00:00
committed by GitHub
20 changed files with 517 additions and 103 deletions
+2
View File
@@ -5,6 +5,8 @@ description: "Create and send cold outreach campaigns safely."
A campaign sends a sequence of emails to a list of contacts, spread across your connected mailboxes. Warmbly handles timing, volume limits, and mailbox rotation so sending looks natural and stays safe.
Personalize from the mailbox sending each message with `{{.Sender.Name}}` and `{{.Sender.Email}}`. Sender values follow mailbox rotation, and the email field uses the active send-as alias when one is selected. The editor's **Sender fields** picker exposes the mailbox's safe details; choose a mailbox in Preview to see its values. See [Sender fields](/guides/expressions/#sender-fields-emails).
<Callout type="info" title="Before you start">
You need at least one active mailbox and a contact list. Warm new mailboxes before running cold campaigns from them. See [Deliverability](/guides/deliverability/).
</Callout>
+43
View File
@@ -10,6 +10,8 @@ Write one email and have it come out personalized for every contact, using merge
| You want to | Write |
| --- | --- |
| Insert a first name | `{{.FirstName}}` |
| Insert the sending mailbox's name | `{{.Sender.Name}}` |
| Insert the actual From address | `{{.Sender.Email}}` |
| Back up an empty field | `{{.FirstName \| default "there"}}` |
| Branch on a condition | `{{if eq .Company "Acme"}}...{{else}}...{{end}}` |
| Randomize wording (campaigns only) | `{a\|b\|c}` |
@@ -31,6 +33,47 @@ Hi {{.FirstName}}, I noticed {{.Company}} is hiring and wanted to reach out.
**Custom fields** work the same way with a dot: `{{.industry}}`, `{{.account_owner}}`. Names containing spaces or dashes also work as written (`{{if .job title}}...{{end}}`) and resolve everywhere, including inside conditions and helpers, because Warmbly rewrites them to an `index` lookup for you.
### Sender fields (emails)
`Sender` is a structured mailbox object. Use Go template field access, such as `{{.Sender.Name}}`, in the subject, HTML body or plain-text body:
```
Hi {{.FirstName}}, I'm {{.Sender.Name}} from our team.
You can reach me at {{.Sender.Email}}.
```
Values come from the **mailbox actually sending that message**, including campaign sender rotation and A/B variants. Campaign test emails, placement tests and server previews use their selected mailbox. The campaign preview's **From** line uses the same identity. A local sample preview uses example data, not a live mailbox.
`{{.Sender.Email}}` is the recipient-visible From address, including a configured send-as alias. `{{.Sender.MailboxEmail}}` is the underlying connected mailbox address. `{{.Sender.ReplyTo}}` is the explicit Reply-To header; it is empty when replies simply go to the sender.
Every field is case-sensitive. Prefix each name below with `.Sender.`, for example `{{.Sender.Provider}}`:
| Details | Fields |
| --- | --- |
| Identity | `Name`, `Email`, `MailboxEmail`, `SendAsEmail`, `ReplyTo`, `AvatarURL` |
| Signature | `SignaturePlain`, `SignatureHTML`, `SignatureSync`, `SignatureCode` |
| Connection | `Provider`, `Status`, `MailHost`, `AuthMethod`, `Vendor` |
| Sending settings | `Timezone`, `CampaignLimit`, `MinWaitTime`, `SaveToSent`, `RelayFolderMoves`, `Tags` |
| Tracking | `TrackingDomain`, `TrackingDomainVerified`, `TrackingDomainVerifiedAt`, `TrackDirectMail` |
| Domain authentication | `AuthState`, `AuthSPF`, `AuthDKIM`, `AuthDMARC`, `AuthDMARCPolicy`, `AuthReason`, `AuthCheckedAt`, `AuthFailingSince` |
| Warmup | `Warmup`, `WarmupPausedAt`, `WarmupBase`, `WarmupMax`, `WarmupIncrease`, `WarmupReplyRate`, `WarmupTag`, `WarmupPoolType`, `WarmupStartTime`, `WarmupEndTime`, `WarmupDays`, `WarmupPlacement`, `WarmupFolder`, `WarmupRetentionDays` |
| Lifecycle | `LastSyncedAt`, `CreatedAt`, `UpdatedAt` |
Limits, gaps, volumes, reply rate, days and retention are numbers. `MinWaitTime` is in seconds, `WarmupDays` is the configured sending-days bitmask, and `WarmupRetentionDays` is in days (zero uses the instance default). Flags such as `SignatureSync`, `SaveToSent` and `AuthSPF` are booleans, so use `{{if .Sender.SignatureSync}}...{{end}}`. `AuthDKIM` false means unverified, not that a DKIM record is missing. `Timezone` falls back to the workspace timezone; an empty timezone means UTC. Warmup/tracking settings are the stored mailbox settings, not inferred campaign overrides.
Timestamps, including `Warmup` (when warmup was enabled), render as UTC RFC 3339 strings such as `2026-10-05T15:00:00Z`, or empty when unset. `Tags` is a list and supports native iteration:
```
{{range .Sender.Tags}}{{.}} {{end}}
{{.Sender.Name | default "our team"}}
```
Without a selected mailbox, sender strings are empty, flags are false, numbers are zero and tags are empty. These fields are available in campaign and placement email rendering, not automation trigger data, deal names or inbox reply-template rendering. Unknown sender fields remain unresolved so previews can flag them. `Sender` is reserved and cannot be replaced by a contact custom field.
<Callout title="Safe mailbox details only">
Credentials, OAuth tokens, passwords, internal IDs, worker routing and sync cursors are never exposed. The object has no mailbox methods. If you insert `SignatureHTML` or `SignaturePlain` yourself, disable automatic signatures for that mailbox to avoid a duplicate sign-off.
</Callout>
### Trigger variables (automations)
Automations carry the trigger's data rather than a contact's saved fields, keyed in lowercase and referenced with the required leading dot:
+1 -1
View File
@@ -76,7 +76,7 @@ func aiVarAvailableVars(contact *models.Contact) []string {
vars := make([]string, 0, len(generation.StandardMergeVars)+len(contact.CustomFields))
vars = append(vars, generation.StandardMergeVars...)
seen := make(map[string]bool, len(vars)+len(contact.CustomFields))
standard := map[string]bool{"firstname": true, "lastname": true, "email": true, "company": true, "phone": true}
standard := map[string]bool{"firstname": true, "lastname": true, "email": true, "company": true, "phone": true, "sender": true, "unsubscribelink": true}
for k := range contact.CustomFields {
key := strings.TrimSpace(k)
if key == "" {
+1 -1
View File
@@ -600,7 +600,7 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx
// in the email rather than naming the platform.
unsubscribeURL = s.mintUnsubscribeLink(ctx, resolveOptOutOrigin(account, campaign), orgID, campaign.ID, contact.ID)
}
extra := map[string]string{UnsubscribeLinkVar: unsubscribeURL}
extra := templateContext(account, unsubscribeURL)
// STEP 10: Render email template with contact variables, then expand any
// {a|b|c} spintax per-recipient (only real |-groups; literal braces/CSS are
+1 -1
View File
@@ -153,7 +153,7 @@ func TestHTMLEmailNeverShowsTheOptOutAddressAsText(t *testing.T) {
},
} {
t.Run(tc.name, func(t *testing.T) {
rendered := previewTemplatesWith("Quick question", tc.body, "", models.Contact{}, url)
rendered := previewTemplatesWith("Quick question", tc.body, "", models.Contact{}, TemplateContext{UnsubscribeLink: url})
bodyHTML, bodyPlain := finishBody(rendered.BodyHTML, rendered.BodyPlain, false, account, &link, url)
if !strings.Contains(bodyHTML, `href="`+url+`"`) {
+1 -1
View File
@@ -271,7 +271,7 @@ func (s *tasksService) renderPlacementBase(ctx context.Context, test *models.Pla
// No contact: clicking it can never suppress anyone.
unsubscribeURL = s.mintUnsubscribeLink(ctx, resolveOptOutOrigin(account, campaign), orgID, campaign.ID, uuid.Nil)
}
extra := map[string]string{UnsubscribeLinkVar: unsubscribeURL}
extra := templateContext(account, unsubscribeURL)
subject := expandSpintax(RenderTemplateWith(rawSubject, contact, extra))
bodyHTML := expandSpintax(RenderTemplateWith(rawHTML, contact, extra))
bodyPlain := expandSpintax(RenderTemplateWith(rawPlain, contact, extra))
+3 -2
View File
@@ -70,7 +70,8 @@ func (s *tasksService) PreviewEmail(ctx context.Context, orgID uuid.UUID, in Ema
textOnly = in.Campaign.TextOnly
}
out := &EmailPreview{TemplatePreview: previewTemplatesWith(in.Subject, in.BodyHTML, in.BodyPlain, in.Contact, unsubURL)}
context := templateContext(in.Account, unsubURL)
out := &EmailPreview{TemplatePreview: previewTemplatesWith(in.Subject, in.BodyHTML, in.BodyPlain, in.Contact, context)}
out.BodyHTML, out.BodyPlain = finishBody(out.BodyHTML, out.BodyPlain, textOnly, in.Account, optOut, unsubURL)
// Linted on what the author wrote, sized on what ships: the findings have
// to name the markup they can go and fix, but Gmail measures the wire. A
@@ -81,7 +82,7 @@ func (s *tasksService) PreviewEmail(ctx context.Context, orgID uuid.UUID, in Ema
}
if in.Account != nil {
out.From = &EmailPreviewFrom{Name: strings.TrimSpace(in.Account.Name), Email: in.Account.Email}
out.From = &EmailPreviewFrom{Name: context.Sender.Name, Email: context.Sender.Email}
}
if in.Campaign != nil && s.attachmentRepo != nil {
atts, err := s.attachmentRepo.ListForStep(ctx, in.Campaign.ID, in.SequenceID)
+26 -1
View File
@@ -1,12 +1,37 @@
package tasks
import (
"context"
"strings"
"testing"
"github.com/google/uuid"
"github.com/warmbly/warmbly/internal/models"
)
func TestPreviewEmailUsesSelectedSender(t *testing.T) {
service := &tasksService{}
for _, account := range []*models.Email{
{Name: "Tareque M.", Email: "tareque@example.com", SendAsEmail: "hello@example.com"},
{Name: "John S.", Email: "john@example.com"},
} {
preview := service.PreviewEmail(context.Background(), uuid.Nil, EmailPreviewInput{
Subject: "From {{.Sender.Name}}", BodyHTML: "<p>{{.FirstName}}: {{.Sender.Email}}</p>",
BodyPlain: "{{.Sender.Provider}}|{{.Sender.Email}}", Contact: models.Contact{FirstName: "Alex"}, Account: account,
})
if preview.From == nil || preview.From.Email != account.SendFrom() || preview.Subject != "From "+account.Name || preview.BodyHTML != "<p>Alex: "+account.SendFrom()+"</p>" || preview.BodyPlain != "|"+account.SendFrom() {
t.Fatalf("preview does not match selected sender: %+v", preview)
}
if len(preview.Errors) != 0 || len(preview.Unresolved) != 0 {
t.Fatalf("sender variables left errors: %+v", preview)
}
}
preview := service.PreviewEmail(context.Background(), uuid.Nil, EmailPreviewInput{Subject: "{{.Sender.Email}}"})
if preview.Subject != "" || preview.From != nil {
t.Fatalf("preview without a mailbox invented a sender: %+v", preview)
}
}
// finishBody is what the preview and the test send share with the campaign
// send: the parts land in send order (body, signature, opt-out) and a
// plain-text campaign loses its HTML part before the signature is added.
@@ -45,7 +70,7 @@ func TestFinishBodyMatchesSendOrder(t *testing.T) {
}
func TestPreviewTemplatesWithUsesTheGivenLink(t *testing.T) {
p := previewTemplatesWith("s", "<a href=\"{{.UnsubscribeLink}}\">x</a>", "", models.Contact{}, "https://api.example.com/unsubscribe/tok")
p := previewTemplatesWith("s", "<a href=\"{{.UnsubscribeLink}}\">x</a>", "", models.Contact{}, TemplateContext{UnsubscribeLink: "https://api.example.com/unsubscribe/tok"})
if !strings.Contains(p.BodyHTML, "https://api.example.com/unsubscribe/tok") {
t.Fatalf("link variable did not resolve to the given link: %q", p.BodyHTML)
}
+41 -56
View File
@@ -24,14 +24,10 @@ type Conversation struct {
Messages []string
}
// TemplateVariables contains variables for template rendering
type TemplateVariables struct {
FirstName string
LastName string
Email string
Company string
Phone string
Custom map[string]string
// TemplateContext holds typed per-send values, separate from contact fields.
type TemplateContext struct {
Sender TemplateSender
UnsubscribeLink string
}
// templateAction matches a single {{ ... }} action (no nested braces).
@@ -52,12 +48,9 @@ var spacedFieldRefAt = regexp.MustCompile(`^\.[A-Za-z0-9_]+(?:[ \-]+[A-Za-z0-9_]
// cached instance is reused across the whole send loop.
var tmplCache tmplfuncs.Cache
// buildTemplateData flattens the contact into the single map[string]string root
// the template engine executes against. Standard fields use their established
// dot-names so {{.FirstName}} keeps working; custom fields are merged in, with
// standard fields winning a name collision.
func buildTemplateData(contact models.Contact) map[string]string {
data := make(map[string]string, len(contact.CustomFields)+5)
// Standard fields and the typed Sender namespace take precedence over custom fields.
func buildTemplateData(contact models.Contact, context TemplateContext) map[string]any {
data := make(map[string]any, len(contact.CustomFields)+7)
for k, v := range contact.CustomFields {
data[k] = v
}
@@ -66,6 +59,8 @@ func buildTemplateData(contact models.Contact) map[string]string {
data["Email"] = contact.Email
data["Company"] = contact.Company
data["Phone"] = contact.Phone
data["Sender"] = context.Sender
data[UnsubscribeLinkVar] = context.UnsubscribeLink
return data
}
@@ -107,7 +102,9 @@ func rewriteSpacedInAction(action string) string {
b.WriteByte(c)
i++
case c == '.':
if m := spacedFieldRefAt.FindString(action[i:]); m != "" {
// A nested struct selector must not consume the following function argument.
fieldStart := i == 0 || strings.ContainsRune("{( \t\r\n", rune(action[i-1]))
if m := spacedFieldRefAt.FindString(action[i:]); fieldStart && m != "" {
b.WriteString(`(index . "` + m[1:] + `")`)
i += len(m)
continue
@@ -123,8 +120,8 @@ func rewriteSpacedInAction(action string) string {
}
// compiledTemplate returns a parsed, cached template for tmpl, or nil if the
// body is known-bad (caller falls back to naiveRenderTemplate). missingkey=zero
// makes absent map keys render as "" and test false in {{if .X}}. text/template
// body is known-bad (caller falls back to standalone actions). missingkey=zero
// makes absent map keys test false in {{if .X}}. text/template
// (not html/template) performs no escaping, so the author's HTML body is emitted
// verbatim.
func compiledTemplate(tmpl string) *template.Template {
@@ -157,57 +154,47 @@ func TemplateError(tmpl string) error {
// RenderTemplate renders a sequence template against a contact, supporting Go
// text/template conditionals ({{if}}/{{else}}/{{eq}}), standard variables, and
// custom fields. It NEVER hard-fails: any parse or execution error falls back to
// the naive replacement path so a send always produces a body. Spintax is
// standalone actions so a send always produces a body. Spintax is
// intentionally left untouched here (single-brace {a|b} survives the template
// pass) and expanded later in the pipeline where applicable.
func RenderTemplate(tmpl string, contact models.Contact) string {
return RenderTemplateWith(tmpl, contact, nil)
return RenderTemplateWith(tmpl, contact, TemplateContext{})
}
// RenderTemplateWith is RenderTemplate with per-send values that are not
// contact fields (today: the recipient's unsubscribe link). They win a name
// collision with a custom field, like the standard fields do.
func RenderTemplateWith(tmpl string, contact models.Contact, extra map[string]string) string {
// RenderTemplateWith uses Go's native struct access for the sending mailbox.
func RenderTemplateWith(tmpl string, contact models.Contact, context TemplateContext) string {
if tmpl == "" {
return tmpl
}
data := buildTemplateData(contact)
for k, v := range extra {
data[k] = v
}
data := buildTemplateData(contact, context)
prepared := rewriteSpacedFieldRefs(tmpl)
t := compiledTemplate(prepared)
if t == nil {
return naiveRenderTemplate(tmpl, contact, extra) // known-bad -> legacy path
return fallbackRenderTemplate(tmpl, data)
}
out, err := tmplfuncs.Execute(t, data)
if err != nil {
return naiveRenderTemplate(tmpl, contact, extra)
return fallbackRenderTemplate(tmpl, data)
}
return out
return strings.ReplaceAll(out, "<no value>", "")
}
// naiveRenderTemplate is the legacy renderer: a literal {{.Key}} -> value
// substitution for the standard fields and every custom field. It is the
// graceful fallback when text/template parsing or execution fails, so a body
// always renders even for malformed conditional syntax.
func naiveRenderTemplate(tmpl string, contact models.Contact, extra map[string]string) string {
result := tmpl
for k, v := range extra {
result = strings.ReplaceAll(result, fmt.Sprintf("{{.%s}}", k), v)
}
result = strings.ReplaceAll(result, "{{.FirstName}}", contact.FirstName)
result = strings.ReplaceAll(result, "{{.LastName}}", contact.LastName)
result = strings.ReplaceAll(result, "{{.Email}}", contact.Email)
result = strings.ReplaceAll(result, "{{.Company}}", contact.Company)
result = strings.ReplaceAll(result, "{{.Phone}}", contact.Phone)
for k, v := range contact.CustomFields {
result = strings.ReplaceAll(result, fmt.Sprintf("{{.%s}}", k), v)
}
return result
// Recover standalone actions with the same engine, leaving broken control syntax literal.
func fallbackRenderTemplate(tmpl string, data map[string]any) string {
return templateAction.ReplaceAllStringFunc(tmpl, func(action string) string {
t := compiledTemplate(rewriteSpacedFieldRefs(action))
if t == nil {
return action
}
out, err := tmplfuncs.Execute(t, data)
if err != nil {
return action
}
return strings.ReplaceAll(out, "<no value>", "")
})
}
// TemplatePreview is the result of rendering a campaign template against one
@@ -238,17 +225,15 @@ var unresolvedToken = regexp.MustCompile(`\{\{[^{}]*\}\}`)
// send path does (template render + spintax), and reports parse errors plus any
// tokens that did not resolve.
func PreviewTemplates(subject, bodyHTML, bodyPlain string, contact models.Contact) TemplatePreview {
return previewTemplatesWith(subject, bodyHTML, bodyPlain, contact, PreviewUnsubscribeLink)
return previewTemplatesWith(subject, bodyHTML, bodyPlain, contact, TemplateContext{UnsubscribeLink: PreviewUnsubscribeLink})
}
// previewTemplatesWith is PreviewTemplates with the unsubscribe link the
// {{unsubscribe_link}} variable resolves to.
func previewTemplatesWith(subject, bodyHTML, bodyPlain string, contact models.Contact, unsubscribeURL string) TemplatePreview {
extra := map[string]string{UnsubscribeLinkVar: unsubscribeURL}
// previewTemplatesWith renders with the same sender context as a real send.
func previewTemplatesWith(subject, bodyHTML, bodyPlain string, contact models.Contact, context TemplateContext) TemplatePreview {
p := TemplatePreview{
Subject: expandSpintax(RenderTemplateWith(subject, contact, extra)),
BodyHTML: expandSpintax(RenderTemplateWith(bodyHTML, contact, extra)),
BodyPlain: expandSpintax(RenderTemplateWith(bodyPlain, contact, extra)),
Subject: expandSpintax(RenderTemplateWith(subject, contact, context)),
BodyHTML: expandSpintax(RenderTemplateWith(bodyHTML, contact, context)),
BodyPlain: expandSpintax(RenderTemplateWith(bodyPlain, contact, context)),
}
for _, f := range []struct{ name, raw string }{{"subject", subject}, {"body", bodyHTML}, {"plain text", bodyPlain}} {
if err := TemplateError(f.raw); err != nil {
+129
View File
@@ -0,0 +1,129 @@
package tasks
import (
"strings"
"time"
"github.com/warmbly/warmbly/internal/models"
)
// TemplateSender is an explicit allowlist: never embed the mailbox model or its methods.
type TemplateSender struct {
Name string
Email string
MailboxEmail string
SendAsEmail string
ReplyTo string
SignaturePlain string
SignatureHTML string
SignatureSync bool
SignatureCode bool
Provider string
Status string
MailHost string
AuthMethod string
Vendor string
AvatarURL string
Tags []string
Timezone string
CampaignLimit int
MinWaitTime int
SaveToSent bool
RelayFolderMoves bool
TrackingDomain string
TrackingDomainVerified bool
TrackingDomainVerifiedAt string
TrackDirectMail bool
AuthState string
AuthSPF bool
AuthDKIM bool
AuthDMARC bool
AuthDMARCPolicy string
AuthReason string
AuthCheckedAt string
AuthFailingSince string
Warmup string
WarmupPausedAt string
WarmupBase int
WarmupMax int
WarmupIncrease int
WarmupReplyRate int
WarmupTag string
WarmupPoolType string
WarmupStartTime string
WarmupEndTime string
WarmupDays int
WarmupPlacement string
WarmupFolder string
WarmupRetentionDays int
LastSyncedAt string
CreatedAt string
UpdatedAt string
}
func templateContext(account *models.Email, unsubscribeURL string) TemplateContext {
context := TemplateContext{UnsubscribeLink: unsubscribeURL}
if account == nil {
return context
}
context.Sender = TemplateSender{
Name: strings.TrimSpace(account.Name),
Email: account.SendFrom(),
MailboxEmail: account.Email,
SendAsEmail: strings.TrimSpace(account.SendAsEmail),
ReplyTo: account.ReplyToHeader(),
SignaturePlain: account.SignaturePlain,
SignatureHTML: account.SignatureHTML,
SignatureSync: account.SignatureSync,
SignatureCode: account.SignatureCode,
Provider: account.Provider,
Status: account.Status,
MailHost: account.MailHost,
AuthMethod: account.AuthMethod,
Vendor: account.Vendor,
AvatarURL: account.AvatarURL,
Tags: append([]string(nil), account.Tags...),
Timezone: account.ClockTimezone(),
CampaignLimit: account.CampaignLimit,
MinWaitTime: account.MinWaitTime,
SaveToSent: account.SaveToSent,
RelayFolderMoves: account.RelayFolderMoves,
TrackingDomain: account.TrackingDomain,
TrackingDomainVerified: account.TrackingDomainVerified,
TrackingDomainVerifiedAt: templateTime(account.TrackingDomainVerifiedAt),
TrackDirectMail: account.TrackDirectMail,
AuthState: account.AuthState,
AuthSPF: account.AuthSPF,
AuthDKIM: account.AuthDKIM,
AuthDMARC: account.AuthDMARC,
AuthDMARCPolicy: account.AuthDMARCPolicy,
AuthReason: account.AuthReason,
AuthCheckedAt: templateTime(account.AuthCheckedAt),
AuthFailingSince: templateTime(account.AuthFailingSince),
Warmup: templateTime(account.Warmup),
WarmupPausedAt: templateTime(account.WarmupPausedAt),
WarmupBase: account.WarmupBase,
WarmupMax: account.WarmupMax,
WarmupIncrease: account.WarmupIncrease,
WarmupReplyRate: account.WarmupReplyRate,
WarmupTag: account.WarmupTag,
WarmupPoolType: account.WarmupPoolType,
WarmupStartTime: account.WarmupStartTime,
WarmupEndTime: account.WarmupEndTime,
WarmupDays: account.WarmupDays,
WarmupPlacement: account.WarmupPlacement,
WarmupFolder: account.WarmupFolder,
WarmupRetentionDays: account.WarmupRetentionDays,
LastSyncedAt: templateTime(&account.LastSyncedAt),
CreatedAt: templateTime(&account.CreatedAt),
UpdatedAt: templateTime(&account.UpdatedAt),
}
return context
}
func templateTime(value *time.Time) string {
if value == nil || value.IsZero() {
return ""
}
return value.UTC().Format(time.RFC3339)
}
+133
View File
@@ -1,6 +1,7 @@
package tasks
import (
"reflect"
"strings"
"testing"
"time"
@@ -8,6 +9,138 @@ import (
"github.com/warmbly/warmbly/internal/models"
)
func TestRenderTemplate_Sender(t *testing.T) {
stamp := time.Date(2026, 10, 5, 15, 0, 0, 0, time.FixedZone("local", 3600))
account := &models.Email{
Name: " Tareque M. ", Email: "tareque@example.com", SendAsEmail: "hello@example.com",
ReplyTo: "replies@example.com", Provider: "gmail", Timezone: "Europe/Budapest",
SignaturePlain: "Best, Tareque", SignatureHTML: "<b>Tareque</b>", SignatureSync: true,
CampaignLimit: 25, Tags: []string{"sales", "team"}, CreatedAt: stamp, Warmup: &stamp,
}
contact := models.Contact{FirstName: "Alex", Email: "alex@example.org", CustomFields: map[string]string{
"role": "CTO", "Job Title": "Founder", "Sender": "not the sender", "FirstName": "not Alex",
}}
context := templateContext(account, "https://example.com/unsubscribe/tok")
cases := []struct{ name, template, want string }{
{"identity", `Hi {{.FirstName}}, I'm {{.Sender.Name}} <{{.Sender.Email}}>`, "Hi Alex, I'm Tareque M. <hello@example.com>"},
{"addresses", `{{.Email}}|{{.Sender.MailboxEmail}}|{{.Sender.SendAsEmail}}|{{.Sender.ReplyTo}}`, "alex@example.org|tareque@example.com|hello@example.com|replies@example.com"},
{"contact and link", `{{.role}}|{{.Job Title}}|{{.UnsubscribeLink}}`, "CTO|Founder|https://example.com/unsubscribe/tok"},
{"native types", `{{if .Sender.SignatureSync}}{{.Sender.SignaturePlain}}{{end}}|{{if gt .Sender.CampaignLimit 20}}high{{end}}`, "Best, Tareque|high"},
{"with and range", `{{with .Sender}}{{.Name}}:{{range .Tags}}{{.}};{{end}}{{end}}`, "Tareque M.:sales;team;"},
{"helpers", `{{.Sender.Name | upper}}|{{.Sender.Vendor | default "direct"}}`, "TAREQUE M.|direct"},
{"timestamps", `{{.Sender.CreatedAt}}|{{.Sender.Warmup}}|{{.Sender.WarmupPausedAt}}`, "2026-10-05T14:00:00Z|2026-10-05T14:00:00Z|"},
{"html", `{{.Sender.SignatureHTML}}`, "<b>Tareque</b>"},
{"malformed fallback", `{{if .Company}}Hi {{.FirstName}}, {{.Sender.Name}} at {{.Sender.Email}}`, "{{if .Company}}Hi Alex, Tareque M. at hello@example.com"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := RenderTemplateWith(tc.template, contact, context); got != tc.want {
t.Fatalf("got %q, want %q", got, tc.want)
}
})
}
account.Tags[0] = "changed"
if context.Sender.Tags[0] != "sales" {
t.Fatal("sender tags share the mailbox's mutable slice")
}
}
func TestTemplateSender_EmptyAndEffectiveIdentity(t *testing.T) {
if got := RenderTemplate(`{{.Sender.Name}}|{{.Sender.Email}}|{{.Sender.Name | default "our team"}}|{{if .Sender.SignatureSync}}yes{{else}}no{{end}}`, models.Contact{}); got != "||our team|no" {
t.Fatalf("empty sender: %q", got)
}
account := &models.Email{Name: "John S.", Email: "john@example.com", OrgTimezone: "America/New_York"}
sender := templateContext(account, "").Sender
if sender.Email != "john@example.com" || sender.MailboxEmail != sender.Email || sender.SendAsEmail != "" || sender.ReplyTo != "" || sender.Timezone != "America/New_York" {
t.Fatalf("incorrect effective sender: %+v", sender)
}
account.ReplyTo = "john@example.com"
if templateContext(account, "").Sender.ReplyTo != "" {
t.Fatal("redundant Reply-To did not match the outbound header")
}
}
func TestAIVarAvailableVarsSkipsReservedSenderNamespace(t *testing.T) {
vars := aiVarAvailableVars(&models.Contact{CustomFields: map[string]string{"Sender": "not a mailbox", "UnsubscribeLink": "not a link", "role": "CTO"}})
for _, token := range vars {
if token == "{{.Sender}}" || token == "{{.UnsubscribeLink}}" {
t.Fatalf("AI advertised a reserved contact field: %s", token)
}
}
if !strings.Contains(strings.Join(vars, " "), "{{.role}}") {
t.Fatal("valid custom field was excluded")
}
}
func TestTemplateSender_ExcludesInternalFieldsAndMethods(t *testing.T) {
typ := reflect.TypeOf(TemplateSender{})
for _, field := range []string{"ID", "UserID", "OrganizationID", "WorkerID", "DomainGrantID", "VendorConnectionID", "LastID", "OrgTimezone", "Password", "AccessToken", "RefreshToken"} {
if _, ok := typ.FieldByName(field); ok {
t.Errorf("unsafe sender field %s", field)
}
}
if typ.NumMethod() != 0 || reflect.PointerTo(typ).NumMethod() != 0 {
t.Fatal("sender exposes mailbox methods")
}
for _, field := range []string{"UserID", "WorkerID", "AccessToken", "SendFrom"} {
tmpl := "{{.Sender." + field + "}}"
preview := previewTemplatesWith(tmpl, "", "", models.Contact{}, templateContext(&models.Email{UserID: "private"}, ""))
if preview.Subject != tmpl || len(preview.Unresolved) != 1 {
t.Errorf("unknown sender field %s should remain unresolved: %+v", field, preview)
}
}
}
func TestTemplateSender_MapsEveryAllowedMailboxField(t *testing.T) {
account := &models.Email{}
mailbox := reflect.ValueOf(account).Elem()
stamp := time.Date(2026, 10, 5, 15, 0, 0, 0, time.UTC)
for i := 0; i < mailbox.NumField(); i++ {
field := mailbox.Field(i)
switch field.Kind() {
case reflect.String:
field.SetString(mailbox.Type().Field(i).Name + "-value")
case reflect.Bool:
field.SetBool(true)
case reflect.Int:
field.SetInt(37)
default:
switch field.Type() {
case reflect.TypeOf(stamp):
field.Set(reflect.ValueOf(stamp))
case reflect.TypeOf(&stamp):
field.Set(reflect.ValueOf(&stamp))
case reflect.TypeOf([]string{}):
field.Set(reflect.ValueOf([]string{"sales"}))
}
}
}
sender := reflect.ValueOf(templateContext(account, "").Sender)
for i := 0; i < sender.NumField(); i++ {
name := sender.Type().Field(i).Name
mailboxName := name
if name == "MailboxEmail" {
mailboxName = "Email"
}
var want any = mailbox.FieldByName(mailboxName).Interface()
switch v := want.(type) {
case time.Time:
want = templateTime(&v)
case *time.Time:
want = templateTime(v)
}
if name == "Email" {
want = account.SendFrom()
}
if name == "ReplyTo" {
want = account.ReplyToHeader()
}
if !reflect.DeepEqual(sender.Field(i).Interface(), want) {
t.Errorf("Sender.%s = %v, want %v", name, sender.Field(i).Interface(), want)
}
}
}
func TestRenderTemplate_RangeOverNumberRefused(t *testing.T) {
tmpl := "Hi {{.FirstName}}{{range 100000000000}}x{{end}}"
if err := TemplateError(tmpl); err == nil {
+1 -1
View File
@@ -53,7 +53,7 @@ func (s *tasksService) SendTestEmail(ctx context.Context, orgID uuid.UUID, accou
unsubscribeURL = s.mintUnsubscribeLink(ctx, resolveOptOutOrigin(account, campaign), orgID, campaign.ID, uuid.Nil)
}
rendered := previewTemplatesWith(sequence.Subject, sequence.BodyHTML, sequence.BodyPlain, renderFor, unsubscribeURL)
rendered := previewTemplatesWith(sequence.Subject, sequence.BodyHTML, sequence.BodyPlain, renderFor, templateContext(account, unsubscribeURL))
bodyHTML, bodyPlain := finishBody(rendered.BodyHTML, rendered.BodyPlain, campaign.TextOnly, account, &optOut, unsubscribeURL)
subject := "[TEST] " + rendered.Subject
@@ -40,8 +40,8 @@ import useCreateTemplate from "@/lib/api/hooks/app/templates/useCreateTemplate";
import { useConfirm } from "@/hooks/context/confirm";
import type { AppError } from "@/lib/api/client/normalizeError";
import buildError from "@/lib/helper/buildError";
import { VARIABLES, htmlToPlain, linkifyUnsubscribe, promptToHtml, renderPreview, templateIssue } from "./emailPreview";
import { LINK_VARIABLES, UNSUBSCRIBE_TOKEN } from "@/lib/templateVars";
import { htmlToPlain, linkifyUnsubscribe, promptToHtml, renderPreview, templateIssue } from "./emailPreview";
import { EMAIL_VARIABLES, LINK_VARIABLES, UNSUBSCRIBE_TOKEN } from "@/lib/templateVars";
import useCampaign from "@/lib/api/hooks/app/campaigns/useCampaign";
import { isDocumentBody } from "@/lib/email/pastedEmail";
import type { ArmSubject } from "./threading";
@@ -281,7 +281,7 @@ export default function EmailContentEditor({
<div className="flex items-center justify-between gap-2 mb-1.5">
<Label className="mb-0">Subject</Label>
{!subjectLocked && (
<VariableMenu variables={VARIABLES} onPick={(v) => onSubjectChange(subject + v)} />
<VariableMenu variables={EMAIL_VARIABLES} onPick={(v) => onSubjectChange(subject + v)} />
)}
</div>
{subjectLocked ? (
@@ -336,7 +336,7 @@ export default function EmailContentEditor({
onChange={(html) => onBodyChange(html, code ? "" : htmlToPlain(html))}
code={code}
onCodeChange={setCode}
variables={VARIABLES}
variables={EMAIL_VARIABLES}
links={LINK_VARIABLES}
placeholder={bodyPlaceholder}
/>
@@ -323,7 +323,7 @@ export default function RichTextEditor({
)}
</div>
{/* Type `{{` → variable type-ahead at the caret. */}
<EditorSuggest editor={editor} links={links} />
<EditorSuggest editor={editor} variables={variables} links={links} />
</div>
);
}
@@ -360,7 +360,7 @@ export default function RichTextEditor({
{/* Collapsed caret → sparkle companion + ⌘J to write with AI. */}
<RichTextAICaret editor={editor} />
{/* Type `{{` → variable type-ahead at the caret. */}
<EditorSuggest editor={editor} links={links} />
<EditorSuggest editor={editor} variables={variables} links={links} />
</>
)}
</div>
@@ -740,17 +740,20 @@ export function VariableMenu({
<div className="px-3 py-2 border-b border-slate-100">
<p className="text-[12px] font-medium text-slate-800">Personalization</p>
<p className="text-[10.5px] text-slate-400 mt-0.5">
Replaced per contact on send · click to insert · hover for what each does
Replaced on send · click to insert · hover for what each does
</p>
</div>
{/* Contact fields — compact 2-column grid (description on hover). */}
<div className="px-2 pt-2">
{[
{ label: "Contact fields", tokens: variables.filter((v) => !v.includes(".Sender.")) },
{ label: "Sender fields", tokens: variables.filter((v) => v.includes(".Sender.")) },
].filter((group) => group.tokens.length > 0).map((group) => (
<div key={group.label} className="px-2 pt-2">
<div className="px-1 pb-1 text-[10px] uppercase tracking-[0.14em] text-slate-400">
Contact fields
{group.label}
</div>
<div className="grid grid-cols-2 gap-1">
{variables.map((v) => {
{group.tokens.map((v) => {
const meta = TOKEN_META[v];
return (
<button
@@ -775,6 +778,7 @@ export function VariableMenu({
})}
</div>
</div>
))}
{links.length > 0 && (
<div className="px-2 pt-2">
@@ -109,16 +109,16 @@ function evalCond(expr: string, ctx: PreviewCtx): boolean {
const notM = expr.match(/^not\s+([\s\S]+)$/);
if (notM) return !evalCond(notM[1].replace(/^\(|\)$/g, ""), ctx);
// Fields may contain spaces/dashes (custom fields), so widen the class.
let m = expr.match(/^eq\s+\.([A-Za-z0-9_ -]+?)\s+"([^"]*)"$/);
let m = expr.match(/^eq\s+\.([A-Za-z0-9_ .-]+?)\s+"([^"]*)"$/);
if (m) return (ctx[m[1]] ?? "") === m[2];
m = expr.match(/^ne\s+\.([A-Za-z0-9_ -]+?)\s+"([^"]*)"$/);
m = expr.match(/^ne\s+\.([A-Za-z0-9_ .-]+?)\s+"([^"]*)"$/);
if (m) return (ctx[m[1]] ?? "") !== m[2];
const logical = expr.match(/^(and|or)\s+(.*)$/s);
if (logical) {
const vals = splitGroups(logical[2]).map((p) => evalCond(p.replace(/^\(|\)$/g, ""), ctx));
return logical[1] === "and" ? vals.every(Boolean) : vals.some(Boolean);
}
m = expr.match(/^\.([A-Za-z0-9_ -]+)$/);
m = expr.match(/^\.([A-Za-z0-9_ .-]+)$/);
if (m) return truthy(ctx[m[1]]);
return false;
}
@@ -169,7 +169,10 @@ function renderConditionals(s: string, ctx: PreviewCtx): string {
export function renderPreview(s: string, ctx: PreviewCtx = SAMPLE): string {
let out = renderConditionals(s, ctx);
out = out.replace(/\{\{\s*\.([A-Za-z0-9_]+)\s*\}\}/g, (_, k: string) => ctx[k] ?? "");
out = out.replace(
/\{\{\s*\.([A-Za-z0-9_ .-]+?)\s*(?:\|\s*default\s+"([^"]*)")?\s*\}\}/g,
(_, k: string, fallback?: string) => ctx[k] || fallback || "",
);
out = out.replace(/\{([^{}|]+(?:\|[^{}]+)+)\}/g, (_, g: string) => g.split("|")[0]);
return out;
}
@@ -12,10 +12,10 @@ import { AnimatePresence, motion } from "framer-motion";
import { BracesIcon, GitBranchIcon, FunctionSquareIcon, ClipboardListIcon, LinkIcon } from "lucide-react";
import useCustomFieldKeys from "@/lib/api/hooks/app/contacts/useCustomFieldKeys";
import { useForms } from "@/lib/api/hooks/app/forms";
import { STANDARD_VARS, LINK_VARS, buildToken, buildFormLinkToken, cleanFieldName, isStandardKey } from "@/lib/templateVars";
import { STANDARD_VARS, SENDER_VARS, VARIABLES, LINK_VARS, buildToken, cleanFieldName, isStandardKey } from "@/lib/templateVars";
import { useAnchoredFloating, caretReference } from "@/hooks/useAnchoredFloating";
type Group = "Fields" | "Links" | "Forms" | "Logic" | "Functions";
type Group = "Fields" | "Sender" | "Links" | "Forms" | "Logic" | "Functions";
// How picking an item mutates the doc, after the typed `{{…` trigger is removed.
type Insert =
@@ -35,6 +35,7 @@ interface Item {
const GROUP_ICON: Record<Group, typeof BracesIcon> = {
Fields: BracesIcon,
Sender: BracesIcon,
Links: LinkIcon,
Forms: ClipboardListIcon,
Logic: GitBranchIcon,
@@ -98,7 +99,7 @@ const HELPERS: Item[] = [
// links are the per-send link tokens this editor may offer (the recipient's
// unsubscribe link); only campaign email bodies resolve them at send time, so
// an AI prompt editor passes none.
export default function EditorSuggest({ editor, links = [] }: { editor: Editor; links?: string[] }) {
export default function EditorSuggest({ editor, variables = VARIABLES, links = [] }: { editor: Editor; variables?: string[]; links?: string[] }) {
const { data: customKeys = [] } = useCustomFieldKeys();
const { data: forms = [] } = useForms();
const [trigger, setTrigger] = React.useState<{ from: number; query: string } | null>(null);
@@ -125,6 +126,14 @@ export default function EditorSuggest({ editor, links = [] }: { editor: Editor;
insert: { type: "chip" as const, token: buildToken(k) },
})),
];
const senderFields: Item[] = SENDER_VARS.filter((v) => variables.includes(v.token)).map((v) => ({
id: `s:${v.key}`,
group: "Sender",
label: v.label,
hint: v.token,
search: `${v.key} ${v.label}`.toLowerCase(),
insert: { type: "chip", token: v.token },
}));
// Published forms only: an unpublished form's link resolves to nothing
// at send time, so it is never offered. No forms, no group.
const formLinks: Item[] = forms
@@ -145,8 +154,8 @@ export default function EditorSuggest({ editor, links = [] }: { editor: Editor;
search: `${v.key} ${v.label} unsubscribe opt out`.toLowerCase(),
insert: { type: "chip" as const, token: v.token },
}));
return [...fields, ...linkItems, ...formLinks, ...HELPERS];
}, [customKeys, forms, links]);
return [...fields, ...senderFields, ...linkItems, ...formLinks, ...HELPERS];
}, [customKeys, forms, links, variables]);
const items = React.useMemo<Item[]>(() => {
if (!trigger) return [];
@@ -172,7 +181,7 @@ export default function EditorSuggest({ editor, links = [] }: { editor: Editor;
}
const $from = sel.$from;
const before = $from.parent.textBetween(0, $from.parentOffset, "", "");
const m = before.match(/\{\{\s*\.?([A-Za-z0-9_ ]*)$/);
const m = before.match(/\{\{\s*\.?([A-Za-z0-9_ .]*)$/);
if (!m) {
setTrigger(null);
return;
@@ -85,7 +85,7 @@ export const VariableNode = TiptapNode.create({
addInputRules() {
return [
nodeInputRule({
find: /\{\{\s*\.([A-Za-z0-9_ -]+?)\s*\}\}$/,
find: /\{\{\s*\.([A-Za-z0-9_ -]+(?:\.[A-Za-z0-9_]+)*?)\s*\}\}$/,
type: this.type,
getAttributes: (match) => ({ token: buildToken(match[1]) }),
}),
@@ -16,9 +16,9 @@ import {
import { SelectMenu } from "@/components/ui/select-menu";
import { OptionSelect, Segmented } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox";
import RichTextEditor from "@/components/app/campaigns/sequences/RichTextEditor";
import { VARIABLES, htmlToPlain } from "@/components/app/campaigns/sequences/emailPreview";
import { htmlToPlain } from "@/components/app/campaigns/sequences/emailPreview";
import { contactLabel } from "@/components/app/campaigns/sequences/previewContext";
import { LINK_VARIABLES } from "@/lib/templateVars";
import { EMAIL_VARIABLES, LINK_VARIABLES } from "@/lib/templateVars";
import useDebouncedValue from "@/hooks/useDebouncedValue";
import useCampaigns from "@/lib/api/hooks/app/campaigns/useCampaigns";
import useSearchContacts from "@/lib/api/hooks/app/contacts/useSearchContacts";
@@ -124,7 +124,7 @@ export function CopySourceFields({
onChange={(html) => patch({ bodyHtml: html, bodyPlain: value.bodyCode ? "" : htmlToPlain(html) })}
code={value.bodyCode}
onCodeChange={(c) => patch({ bodyCode: c })}
variables={VARIABLES}
variables={EMAIL_VARIABLES}
links={LINK_VARIABLES}
placeholder="Hi {{.FirstName}}, …"
/>
+33 -2
View File
@@ -1,6 +1,37 @@
import { describe, expect, it } from "vitest";
import { UNSUBSCRIBE_TOKEN, upgradeVariableTokens } from "./templateVars";
import { linkifyUnsubscribe } from "@/components/app/campaigns/sequences/emailPreview";
import { EMAIL_VARIABLES, SENDER_VARS, UNSUBSCRIBE_TOKEN, buildToken, parseToken, tokenLabel, upgradeVariableTokens } from "./templateVars";
import { linkifyUnsubscribe, renderPreview } from "@/components/app/campaigns/sequences/emailPreview";
describe("structured sender fields", () => {
it("round-trips every mailbox token as a chip", () => {
for (const v of SENDER_VARS) {
expect(EMAIL_VARIABLES).toContain(v.token);
expect(parseToken(v.token)).toEqual({ key: v.key, fallback: null });
expect(buildToken(v.key)).toBe(v.token);
expect(tokenLabel(v.token)).toBe(v.label);
expect(upgradeVariableTokens(`<p>${v.token}</p>`)).toBe(`<p><span data-var="">${v.token}</span></p>`);
}
});
it("retains nested fields when editing their fallback", () => {
const token = buildToken("Sender.Name", "our team");
expect(parseToken(token)).toEqual({ key: "Sender.Name", fallback: "our team" });
expect(upgradeVariableTokens(`<p>${token}</p>`)).toBe(`<p><span data-var="">${token}</span></p>`);
});
it("renders sender samples alongside existing contact variables", () => {
expect(renderPreview("{{.FirstName}}: {{.Sender.Name}} <{{.Sender.Email}}>"))
.toBe("Alex: Jamie Morgan <jamie@example.com>");
});
it("resolves sender fields and conditions against the provided preview context", () => {
const ctx = { "Sender.Name": "John S.", "Sender.Email": "john@example.com" };
expect(renderPreview('{{if eq .Sender.Name "John S."}}{{.Sender.Email}}{{else}}wrong{{end}}', ctx))
.toBe("john@example.com");
expect(renderPreview('{{if .Sender.Name}}{{.Sender.Name}}{{end}}', ctx)).toBe("John S.");
expect(renderPreview('{{.Sender.Name | default "our team"}}|{{.Sender.Email}}', {})).toBe("our team|");
});
});
describe("upgradeVariableTokens", () => {
it("chips a token in text", () => {
+61 -12
View File
@@ -1,10 +1,4 @@
// Single source of truth for the standard contact merge fields available in
// every Go-template surface (campaign copy, templates, deal names, automation
// values). The backend renderer (internal/tasks/template.go buildTemplateData)
// exposes exactly these five standard fields plus arbitrary custom fields; keep
// this list in sync with that function. Historically this list was duplicated
// across emailPreview.ts, RichTextEditor TOKEN_META, templates/page and
// CampaignFlow — those consume this module instead.
// Contact fields are shared across template surfaces; Sender is email-only.
export interface TemplateVar {
token: string; // literal token inserted into content, e.g. "{{.Company}}"
@@ -22,6 +16,60 @@ export const STANDARD_VARS: TemplateVar[] = [
{ token: "{{.Phone}}", key: "Phone", label: "Phone", desc: "The contact's phone number", sample: "+1 555-0100" },
];
// Keep in sync with the explicit allowlist in internal/tasks/template_sender.go.
export const SENDER_VARS: TemplateVar[] = [
["Name", "Sender name", "The sending mailbox's configured display name", "Jamie Morgan"],
["Email", "Sender email", "The actual From address, including the chosen send-as alias", "jamie@example.com"],
["MailboxEmail", "Mailbox email", "The connected mailbox's own address, before any send-as alias", "jamie@example.com"],
["SendAsEmail", "Send-as alias", "The chosen alias, empty when none is selected"],
["ReplyTo", "Reply-to", "The explicit Reply-To header, empty when replies go to the sender"],
["SignaturePlain", "Plain signature", "The mailbox's plain-text signature"],
["SignatureHTML", "HTML signature", "The mailbox's HTML signature. Disable automatic signatures if placing it yourself"],
["SignatureSync", "Signature enabled", "Whether this mailbox automatically appends its signature (boolean)"],
["SignatureCode", "Signature HTML mode", "Whether the signature is edited as raw HTML (boolean)"],
["Provider", "Provider", "Connection provider: gmail, outlook or smtp_imap"],
["Status", "Mailbox status", "The mailbox's current connection status"],
["MailHost", "Mail host", "The detected hosting provider, such as google_workspace or microsoft365"],
["AuthMethod", "Authentication method", "The connection method, not credentials: password, app_password, oauth or delegated"],
["Vendor", "Mailbox vendor", "The inbox vendor the mailbox was imported from, when known"],
["AvatarURL", "Profile image URL", "The mailbox's profile image URL"],
["Tags", "Mailbox tags", "The mailbox's tag list. Iterate with {{range .Sender.Tags}}{{.}} {{end}}"],
["Timezone", "Timezone", "The mailbox timezone, falling back to the workspace timezone"],
["CampaignLimit", "Daily campaign cap", "The mailbox's daily cold-email cap (number)"],
["MinWaitTime", "Minimum send gap", "The mailbox's minimum gap between sends, in seconds (number)"],
["SaveToSent", "Save to Sent", "Whether SMTP/IMAP sends are saved to Sent (boolean)"],
["RelayFolderMoves", "Relay folder moves", "Whether inbox folder actions are relayed to the provider (boolean)"],
["TrackingDomain", "Tracking domain", "The mailbox's configured tracking domain"],
["TrackingDomainVerified", "Tracking verified", "Whether the tracking domain is verified (boolean)"],
["TrackingDomainVerifiedAt", "Tracking verified at", "When the tracking domain was verified, in UTC RFC 3339 format"],
["TrackDirectMail", "Direct-mail tracking", "Whether tracking is enabled for hand-written mail (boolean)"],
["AuthState", "Domain auth status", "Sending-domain authentication status: unknown, passing or failing"],
["AuthSPF", "SPF signal", "Whether the SPF check passed (boolean)"],
["AuthDKIM", "DKIM signal", "Whether a DKIM record was found. False means unverified, not missing"],
["AuthDMARC", "DMARC signal", "Whether a DMARC record was found (boolean)"],
["AuthDMARCPolicy", "DMARC policy", "The detected DMARC policy"],
["AuthReason", "Domain auth reason", "The sending-domain authentication diagnostic"],
["AuthCheckedAt", "Domain checked at", "When domain authentication was checked, in UTC RFC 3339 format"],
["AuthFailingSince", "Domain failing since", "When domain authentication began failing, in UTC RFC 3339 format"],
["Warmup", "Warmup enabled at", "The warmup start timestamp, in UTC RFC 3339 format"],
["WarmupPausedAt", "Warmup paused at", "The warmup pause timestamp, in UTC RFC 3339 format"],
["WarmupBase", "Warmup base", "The starting warmup volume (number)"],
["WarmupMax", "Warmup maximum", "The maximum warmup volume (number)"],
["WarmupIncrease", "Warmup increase", "The daily warmup volume increase (number)"],
["WarmupReplyRate", "Warmup reply rate", "The configured warmup reply rate (number)"],
["WarmupTag", "Warmup tag", "The configured warmup tag"],
["WarmupPoolType", "Warmup pool", "The configured warmup pool type"],
["WarmupStartTime", "Warmup start time", "The warmup sending window start"],
["WarmupEndTime", "Warmup end time", "The warmup sending window end"],
["WarmupDays", "Warmup days", "The warmup sending-days bitmask (number)"],
["WarmupPlacement", "Warmup filing", "Where warmup messages are filed"],
["WarmupFolder", "Warmup folder", "The configured warmup folder"],
["WarmupRetentionDays", "Warmup retention", "The configured retention in days; zero uses the instance default"],
["LastSyncedAt", "Last synced at", "When the mailbox last synced, in UTC RFC 3339 format"],
["CreatedAt", "Mailbox created at", "When the mailbox was added, in UTC RFC 3339 format"],
["UpdatedAt", "Mailbox updated at", "When mailbox settings last changed, in UTC RFC 3339 format"],
].map(([field, label, desc, sample = ""]) => ({ token: `{{.Sender.${field}}}`, key: `Sender.${field}`, label, desc, sample }));
// The recipient's opt-out link. Named because the editor treats it specially:
// applied to a text selection it becomes that text's href, so the copy can say
// what it likes and the signed URL never shows.
@@ -42,22 +90,23 @@ export const LINK_VARS: TemplateVar[] = [
// The token list many surfaces already consume as `string[]`.
export const VARIABLES: string[] = STANDARD_VARS.map((v) => v.token);
export const EMAIL_VARIABLES: string[] = [...VARIABLES, ...SENDER_VARS.map((v) => v.token)];
export const LINK_VARIABLES: string[] = LINK_VARS.map((v) => v.token);
// Friendly metadata keyed by token, for pickers that render label + description.
export const TOKEN_META: Record<string, { label: string; desc: string }> = Object.fromEntries(
[...STANDARD_VARS, ...LINK_VARS].map((v) => [v.token, { label: v.label, desc: v.desc }]),
[...STANDARD_VARS, ...SENDER_VARS, ...LINK_VARS].map((v) => [v.token, { label: v.label, desc: v.desc }]),
);
// Client-side preview sample context: standard fields plus a couple of common
// custom-field examples so a {{.role}} in a preview resolves to something.
export const SAMPLE: Record<string, string> = {
...Object.fromEntries([...STANDARD_VARS, ...LINK_VARS].map((v) => [v.key, v.sample])),
...Object.fromEntries([...STANDARD_VARS, ...SENDER_VARS, ...LINK_VARS].map((v) => [v.key, v.sample])),
role: "Engineer",
city: "Berlin",
};
const STANDARD_KEYS = new Set(STANDARD_VARS.map((v) => v.key.toLowerCase()));
const STANDARD_KEYS = new Set(["sender", ...STANDARD_VARS.map((v) => v.key.toLowerCase())]);
// isStandardKey reports whether a (case-insensitive) key collides with a
// standard field. The backend lets a standard field win a name collision
@@ -92,7 +141,7 @@ export function buildToken(key: string, fallback?: string | null): string {
// editing. Returns null when the string is not a plain field-access token (e.g.
// a conditional or a token with helpers we do not model as a chip).
export function parseToken(token: string): { key: string; fallback: string | null } | null {
const m = token.match(/^\{\{\s*\.([A-Za-z0-9_ -]+?)\s*(?:\|\s*default\s+"([^"]*)")?\s*\}\}$/);
const m = token.match(/^\{\{\s*\.([A-Za-z0-9_ -]+(?:\.[A-Za-z0-9_]+)*?)\s*(?:\|\s*default\s+"([^"]*)")?\s*\}\}$/);
if (!m) return null;
return { key: m[1].trim(), fallback: m[2] ?? null };
}
@@ -125,7 +174,7 @@ export function parseFormLinkToken(token: string): string | null {
// FIELD_TOKEN_RE matches a bare merge-field token (optionally with a default
// fallback) but NOT control tokens like {{if .X}} / {{end}} / {{eq ...}}, so
// legacy plain content can be upgraded to chips without disturbing conditionals.
export const FIELD_TOKEN_RE = /\{\{\s*\.[A-Za-z0-9_ -]+?(?:\s*\|\s*default\s+"[^"]*")?\s*\}\}/g;
export const FIELD_TOKEN_RE = /\{\{\s*\.[A-Za-z0-9_ -]+(?:\.[A-Za-z0-9_]+)*?(?:\s*\|\s*default\s+"[^"]*")?\s*\}\}/g;
// upgradeVariableTokens wraps bare merge-field and form-link tokens in the
// editor HTML with their chip spans (span[data-var] / span[data-form-link]) so