mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 16:02:07 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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+`"`) {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}, …"
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user