diff --git a/docs/content/docs/guides/campaigns.mdx b/docs/content/docs/guides/campaigns.mdx
index 6787af792..f547a7a77 100644
--- a/docs/content/docs/guides/campaigns.mdx
+++ b/docs/content/docs/guides/campaigns.mdx
@@ -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).
+
You need at least one active mailbox and a contact list. Warm new mailboxes before running cold campaigns from them. See [Deliverability](/guides/deliverability/).
diff --git a/docs/content/docs/guides/expressions.mdx b/docs/content/docs/guides/expressions.mdx
index bab996673..4e1d9c20f 100644
--- a/docs/content/docs/guides/expressions.mdx
+++ b/docs/content/docs/guides/expressions.mdx
@@ -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.
+
+
+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.
+
+
### 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:
diff --git a/internal/tasks/ai_variables.go b/internal/tasks/ai_variables.go
index b33d3dde2..12066bd2f 100644
--- a/internal/tasks/ai_variables.go
+++ b/internal/tasks/ai_variables.go
@@ -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 == "" {
diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go
index f87376373..c69d98836 100644
--- a/internal/tasks/campaign_task.go
+++ b/internal/tasks/campaign_task.go
@@ -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
diff --git a/internal/tasks/optout_link_test.go b/internal/tasks/optout_link_test.go
index f30cd415b..4da2e9061 100644
--- a/internal/tasks/optout_link_test.go
+++ b/internal/tasks/optout_link_test.go
@@ -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+`"`) {
diff --git a/internal/tasks/placement_task.go b/internal/tasks/placement_task.go
index 94d2601e8..bcf01b668 100644
--- a/internal/tasks/placement_task.go
+++ b/internal/tasks/placement_task.go
@@ -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))
diff --git a/internal/tasks/preview.go b/internal/tasks/preview.go
index 561837da8..4993bda33 100644
--- a/internal/tasks/preview.go
+++ b/internal/tasks/preview.go
@@ -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)
diff --git a/internal/tasks/preview_test.go b/internal/tasks/preview_test.go
index d8bfe9093..19db210f5 100644
--- a/internal/tasks/preview_test.go
+++ b/internal/tasks/preview_test.go
@@ -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: "
`);
+ });
+
+ it("renders sender samples alongside existing contact variables", () => {
+ expect(renderPreview("{{.FirstName}}: {{.Sender.Name}} <{{.Sender.Email}}>"))
+ .toBe("Alex: Jamie Morgan ");
+ });
+
+ 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", () => {
diff --git a/web/src/lib/templateVars.ts b/web/src/lib/templateVars.ts
index 7b4888a57..c0933b7ef 100644
--- a/web/src/lib/templateVars.ts
+++ b/web/src/lib/templateVars.ts
@@ -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 = 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 = {
- ...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