From bce968646f2dbaa9b8449d5c4557e86b5d3ffd1d Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 1 Oct 2026 22:41:11 -0700 Subject: [PATCH] feat: thread campaign follow-ups on the A/B arm each contact was actually sent by recording every send's rendered subject on campaign_tasks.subject (migration 000243) and reusing it verbatim in ThreadParentForLead, and list the opening step's variant subjects in the follow-up composer --- docs/content/docs/api/reference/campaigns.mdx | 2 +- docs/content/docs/guides/sequences.mdx | 5 +- .../000243_campaign_task_subject.down.sql | 2 + .../000243_campaign_task_subject.up.sql | 5 + .../campaign_thread_parent_live_test.go | 62 +++++++++++ internal/repository/pg_campaign_progress.go | 26 ++++- internal/repository/pg_task.go | 14 ++- internal/tasks/campaign_task.go | 10 ++ internal/tasks/campaign_thread_live_test.go | 103 +++++++++++++++++- .../app/campaigns/sequences/CampaignFlow.tsx | 3 +- .../sequences/EmailContentEditor.tsx | 27 ++++- .../app/campaigns/sequences/SequenceView.tsx | 5 + .../app/campaigns/sequences/StepEmailArms.tsx | 14 +++ .../app/campaigns/sequences/threading.test.ts | 60 ++++++++++ .../app/campaigns/sequences/threading.ts | 54 +++++++-- 15 files changed, 364 insertions(+), 28 deletions(-) create mode 100644 internal/infrastructure/db/migrations/000243_campaign_task_subject.down.sql create mode 100644 internal/infrastructure/db/migrations/000243_campaign_task_subject.up.sql create mode 100644 web/src/components/app/campaigns/sequences/threading.test.ts diff --git a/docs/content/docs/api/reference/campaigns.mdx b/docs/content/docs/api/reference/campaigns.mdx index 4f229862d..2091ebe60 100644 --- a/docs/content/docs/api/reference/campaigns.mdx +++ b/docs/content/docs/api/reference/campaigns.mdx @@ -500,7 +500,7 @@ Add a variant to the campaign (or to one step via `step_id`). **Scope** `WRITE_C | `name` | string | yes | Variant label. | | `step_id` | uuid | no | Step to scope the variant to, omit for campaign-level. | | `weight` | integer | no | Relative selection weight (1-100). Shares are these weights normalized across the active arms, so two arms at equal weight split evenly. | -| `subject` | string | no | Variant subject template. | +| `subject` | string | no | Variant subject template. Blank reuses the step's. Ignored when the step replies in the contact's thread (`thread_reply`), which carries the conversation's subject. When the step opens a conversation, the follow-ups threading on it reply under the subject of whichever arm each contact was sent. | | `body_html` | string | no | Variant HTML body. | | `body_plain` | string | no | Variant plain-text body. | | `is_control` | boolean | no | Mark this as the step's control arm. For a step-scoped test, create one `is_control` row to set the Original's share; its `weight` is the Original's share and its content is ignored (the step's own email is sent when the control wins). | diff --git a/docs/content/docs/guides/sequences.mdx b/docs/content/docs/guides/sequences.mdx index 97aee8205..ae9858561 100644 --- a/docs/content/docs/guides/sequences.mdx +++ b/docs/content/docs/guides/sequences.mdx @@ -166,7 +166,8 @@ A follow-up is the next email step, sent as a **reply on the conversation the co Each email step has a **Reply in thread** switch, on by default. Turn it off for a step that should deliberately start a fresh conversation. -- A step that replies in the thread has no subject of its own: a reply carries the conversation's subject, which is the one the step that opened the thread used. The composer shows that subject instead of a subject box. +- A step that replies in the thread has no subject of its own: a reply carries the conversation's subject, which is the one the email that opened the thread was sent with. The composer shows that subject instead of a subject box. +- The conversation follows what each contact actually received. When the step that opened the thread is A/B tested, a contact who got Variant B is replied to under Variant B's subject and a contact who got the Original under the Original's, on every follow-up. The composer lists each version's subject. A follow-up repeats the subject the opening email was actually sent with, word for word, so editing the opening step or the contact later does not change the subject of a conversation it already started. - A step with **Reply in thread** off writes its own subject and opens a new conversation. Every threading step after it then replies on *that* conversation. - The switch does nothing on a contact's first email. There is nothing to reply to, so it opens the thread. - A/B variants on a threading step vary the body only, for the same reason. @@ -182,7 +183,7 @@ Open a step's editor and click **A/B test this step**. - The step's own subject and body are the **Original** control arm; adding a variant makes it one weighted arm among several. - Up to 5 variants (B through F) on top of the Original. - The **traffic-split bar** shows one segment per arm. Drag a divider or type an exact percent; shares always total 100%, and **Even split** balances every active arm. -- Select an arm to edit its name, subject, and body. Leave a variant's subject or body blank to reuse the step's own. On a step that replies in the thread, a variant's subject is ignored: the conversation owns it. +- Select an arm to edit its name, subject, and body. Leave a variant's subject or body blank to reuse the step's own. On a step that replies in the thread, a variant's subject is ignored: the conversation owns it. On a step that opens a conversation, the variant's subject becomes the conversation's for every contact it is sent to, so the follow-ups threading on it reply under that subject. - A first variant starts at 50/50. Marking one **inactive** drops it from the split without deleting it; deleting the last one returns the step to a single send. Once variants have sent, each card shows Sent, Open rate, Reply rate, and Bounce rate. When Warmbly finds a **winner**, a banner names it with the rule and confidence used, and the winning card gets a trophy. diff --git a/internal/infrastructure/db/migrations/000243_campaign_task_subject.down.sql b/internal/infrastructure/db/migrations/000243_campaign_task_subject.down.sql new file mode 100644 index 000000000..1273a6147 --- /dev/null +++ b/internal/infrastructure/db/migrations/000243_campaign_task_subject.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE campaign_tasks + DROP COLUMN IF EXISTS subject; diff --git a/internal/infrastructure/db/migrations/000243_campaign_task_subject.up.sql b/internal/infrastructure/db/migrations/000243_campaign_task_subject.up.sql new file mode 100644 index 000000000..be7096743 --- /dev/null +++ b/internal/infrastructure/db/migrations/000243_campaign_task_subject.up.sql @@ -0,0 +1,5 @@ +-- The subject a campaign send carried, rendered: a follow-up threads on what the +-- contact was actually sent, A/B arm included. NULL on older rows, which fall +-- back to walking the steps. +ALTER TABLE campaign_tasks + ADD COLUMN IF NOT EXISTS subject text; diff --git a/internal/repository/campaign_thread_parent_live_test.go b/internal/repository/campaign_thread_parent_live_test.go index a3d7f208b..e02f99bee 100644 --- a/internal/repository/campaign_thread_parent_live_test.go +++ b/internal/repository/campaign_thread_parent_live_test.go @@ -250,3 +250,65 @@ func TestLiveThreadParentStopsAtADeletedStep(t *testing.T) { t.Errorf("conversation subject = %q, want none: the step that sent it is gone", p.Subject) } } + +// An A/B arm can replace the opening step's subject, so the conversation is +// whichever arm the contact got, read off the send rather than the step +// (issue #774): a contact sent Variant B is replied to under Variant B's. +func TestLiveThreadParentFollowsTheArmTheContactWasSent(t *testing.T) { + _, pool := liveContactDB(t) + f := newThreadParentFixture(t, pool) + first := f.step(0, "Original subject", false) + second := f.step(1, "", true) + opener := f.send(first, f.mailbox, "", "thr-1", 120) + f.exec(`UPDATE campaign_tasks SET subject = 'Variant B subject' WHERE task_id = $1`, opener) + + if p := f.parent(t); p == nil || p.Subject != "Variant B subject" { + t.Fatalf("conversation subject = %+v, want the arm the opener sent", p) + } + + // The reply recorded the subject it inherited, so the step after it + // threads on the same arm even once the opener's step is gone. + reply := f.send(second, f.mailbox, "", "thr-1", 60) + f.exec(`UPDATE campaign_tasks SET subject = 'Variant B subject' WHERE task_id = $1`, reply) + f.exec(`DELETE FROM sequences WHERE id = $1`, first) + p := f.parent(t) + if p == nil || p.MessageID != "" { + t.Fatalf("parent = %+v, want the reply", p) + } + if p.Subject != "Variant B subject" { + t.Errorf("conversation subject = %q, want the arm carried through the reply", p.Subject) + } +} + +// A send from before subjects were recorded is walked through to the step +// that opened the thread, and a recorded opener behind it still wins. +func TestLiveThreadParentWalksUnrecordedSendsToARecordedOpener(t *testing.T) { + _, pool := liveContactDB(t) + f := newThreadParentFixture(t, pool) + first := f.step(0, "Original subject", false) + second := f.step(1, "", true) + opener := f.send(first, f.mailbox, "", "thr-1", 120) + f.exec(`UPDATE campaign_tasks SET subject = 'Variant B subject' WHERE task_id = $1`, opener) + f.send(second, f.mailbox, "", "thr-1", 60) + + if p := f.parent(t); p == nil || p.Subject != "Variant B subject" { + t.Fatalf("conversation subject = %+v, want the opener's recorded arm", p) + } +} + +// A blank recorded subject records nothing, so the walk still reaches the +// step that opened the conversation. +func TestLiveThreadParentPassesABlankRecordedSubject(t *testing.T) { + _, pool := liveContactDB(t) + f := newThreadParentFixture(t, pool) + first := f.step(0, "Quick question", false) + second := f.step(1, "", true) + f.send(first, f.mailbox, "", "thr-1", 120) + reply := f.send(second, f.mailbox, "", "thr-1", 60) + f.exec(`UPDATE campaign_tasks SET subject = '' WHERE task_id = $1`, reply) + + p := f.parent(t) + if p == nil || p.Subject != "Quick question" || p.SubjectSent { + t.Fatalf("parent = %+v, want the opener's template walked to", p) + } +} diff --git a/internal/repository/pg_campaign_progress.go b/internal/repository/pg_campaign_progress.go index bdc91c4b5..c3831e22b 100644 --- a/internal/repository/pg_campaign_progress.go +++ b/internal/repository/pg_campaign_progress.go @@ -115,10 +115,14 @@ type ThreadParent struct { // only means something inside the mailbox that owns it, so a follow-up // leaving from a different address must not carry it. SenderID uuid.UUID - // Subject is the conversation's subject, unrendered: the template of the - // step that opened the thread, not the parent's own. A reply carries it, - // and Gmail refuses to file a message in a thread it does not match. + // Subject is the conversation's subject: the one the opening send carried + // (its A/B arm's when one replaced the step's), not the parent's own. A + // reply carries it, and Gmail refuses to file a message in a thread it + // does not match. Subject string + // SubjectSent marks Subject as recorded off a send, already rendered for + // this contact; otherwise it is the opening step's template. + SubjectSent bool } // CampaignProgressRepository defines methods for campaign progress tracking @@ -680,9 +684,12 @@ const threadParentScan = 50 // Action and wait nodes never reach the tracking stamp that writes a // campaign_tasks row, and never carry a Message-ID, so they cannot be picked // as a parent. +// +// The conversation's subject is read off the sends themselves where they +// recorded it, and walked from the steps only for sends from before they did. func (r *campaignProgressRepository) ThreadParentForLead(ctx context.Context, campaignID, contactID uuid.UUID) (*ThreadParent, error) { rows, err := r.db.Query(ctx, ` - SELECT t.message_id, t.thread_id, t.email_account_id, + SELECT t.message_id, t.thread_id, t.email_account_id, ct.subject, s.id IS NOT NULL, COALESCE(s.subject, ''), COALESCE(s.thread_reply, true) FROM campaign_tasks ct JOIN tasks t ON t.id = ct.task_id @@ -700,18 +707,25 @@ func (r *campaignProgressRepository) ThreadParentForLead(ctx context.Context, ca var parent *ThreadParent var subject string + var recorded bool for rows.Next() { var ( messageID, threadID, stepSubject string + sentSubject *string senderID uuid.UUID stepKnown, threadReply bool ) - if err := rows.Scan(&messageID, &threadID, &senderID, &stepKnown, &stepSubject, &threadReply); err != nil { + if err := rows.Scan(&messageID, &threadID, &senderID, &sentSubject, &stepKnown, &stepSubject, &threadReply); err != nil { return nil, err } if parent == nil { parent = &ThreadParent{MessageID: messageID, ThreadID: threadID, SenderID: senderID} } + // A recorded subject is the conversation's as sent, whichever A/B arm it came from. + if sentSubject != nil && *sentSubject != "" { + subject, recorded = *sentSubject, true + break + } // A step that has since been deleted (campaign_tasks.sequence_id is // ON DELETE SET NULL) says nothing about whether it opened a thread or // joined one, so the walk cannot pass it. Stopping with no subject is @@ -737,7 +751,7 @@ func (r *campaignProgressRepository) ThreadParentForLead(ctx context.Context, ca if parent == nil { return nil, nil } - parent.Subject = subject + parent.Subject, parent.SubjectSent = subject, recorded return parent, nil } diff --git a/internal/repository/pg_task.go b/internal/repository/pg_task.go index 076cce54c..c2e0a6a23 100644 --- a/internal/repository/pg_task.go +++ b/internal/repository/pg_task.go @@ -182,6 +182,9 @@ type TaskRepository interface { // Update campaign task with contact/sequence IDs (for tracking) UpdateCampaignTaskTracking(ctx context.Context, taskID, contactID, sequenceID uuid.UUID) error + // UpdateCampaignTaskSubject records the subject a campaign send carries, + // which is what a follow-up threads on. Blank records nothing. + UpdateCampaignTaskSubject(ctx context.Context, taskID uuid.UUID, subject string) error // ListScheduledInOrg returns every pending email task scheduled // from the organization's mailboxes, ordered by next-to-fire. Used @@ -1156,11 +1159,12 @@ func (r *taskRepository) UpdateTaskReplyTo(ctx context.Context, taskID uuid.UUID // UpdateCampaignTaskTracking updates the campaign task with contact_id and sequence_id // This is called when the task is processed and we know which contact/sequence to send to -// These IDs are needed for tracking pixel/click events to record progress +// These IDs are needed for tracking pixel/click events to record progress. +// A retry may send a different pair, so the recorded subject is cleared with it. func (r *taskRepository) UpdateCampaignTaskTracking(ctx context.Context, taskID, contactID, sequenceID uuid.UUID) error { query := ` UPDATE campaign_tasks - SET contact_id = $2, sequence_id = $3 + SET contact_id = $2, sequence_id = $3, subject = NULL WHERE task_id = $1 ` @@ -1168,6 +1172,12 @@ func (r *taskRepository) UpdateCampaignTaskTracking(ctx context.Context, taskID, return err } +// UpdateCampaignTaskSubject records the rendered subject a campaign send carries. +func (r *taskRepository) UpdateCampaignTaskSubject(ctx context.Context, taskID uuid.UUID, subject string) error { + _, err := r.db.Exec(ctx, `UPDATE campaign_tasks SET subject = CASE WHEN btrim($2) = '' THEN NULL ELSE $2 END WHERE task_id = $1`, taskID, subject) + return err +} + // ListScheduledInOrg returns the organization's user-initiated email tasks // still in 'pending' state, ordered by scheduled_at. Joins tasks → email_tasks // → email_accounts so callers don't need three lookups per row. diff --git a/internal/tasks/campaign_task.go b/internal/tasks/campaign_task.go index d60d2c1f8..5b4ef1ea0 100644 --- a/internal/tasks/campaign_task.go +++ b/internal/tasks/campaign_task.go @@ -608,6 +608,10 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx subject := expandSpintax(RenderTemplateWith(rawSubject, *contact, extra)) bodyHTML := expandSpintax(RenderTemplateWith(rawBodyHTML, *contact, extra)) bodyPlain := expandSpintax(RenderTemplateWith(rawBodyPlain, *contact, extra)) + // A recorded conversation subject is already rendered: reuse it verbatim. + if threadParent != nil && threadParent.SubjectSent && threadSubject != "" { + subject = threadSubject + } // If no plain text provided, extract from HTML if bodyPlain == "" && bodyHTML != "" { @@ -668,6 +672,12 @@ func (s *tasksService) HandleCampaignTask(task *proto.ProcessTask) (result *errx } } + // Follow-ups thread on exactly this subject; a failed write falls back to the step's. + if err := s.taskRepo.UpdateCampaignTaskSubject(ctx, taskID, subject); err != nil { + log.Warn().Err(err).Str("campaign_id", campaign.ID.String()).Str("task_id", taskID.String()). + Msg("Could not record the subject this send carries") + } + // STEP 10.6: A plain-text campaign ships no HTML part at all. Tracking // below only rewrites HTML, so dropping it here is what makes the // setting's "disables tracking" promise true. diff --git a/internal/tasks/campaign_thread_live_test.go b/internal/tasks/campaign_thread_live_test.go index 9c75ac3bc..4db5e7aef 100644 --- a/internal/tasks/campaign_thread_live_test.go +++ b/internal/tasks/campaign_thread_live_test.go @@ -239,12 +239,16 @@ func TestLiveThreadHandleIsDroppedWhenTheConversationSubjectIsGone(t *testing.T) f.tick(t, svc) f.confirmSend(t, handle.Pool, "", "gmail-thread-1") - // The step that opened the conversation is gone, so its subject cannot be - // read off anything. + // The opener was sent before sends recorded their subject, and its step's + // has since been blanked, so the subject cannot be read off anything. if _, err := handle.Pool.Exec(context.Background(), `UPDATE sequences SET subject = '' WHERE id = $1`, f.step); err != nil { t.Fatalf("blank the opener's subject: %v", err) } + if _, err := handle.Pool.Exec(context.Background(), + `UPDATE campaign_tasks SET subject = NULL WHERE campaign_id = $1`, f.campaign); err != nil { + t.Fatalf("forget the opener's recorded subject: %v", err) + } f.tick(t, svc) followUp := sender.message(t, 1) @@ -255,3 +259,98 @@ func TestLiveThreadHandleIsDroppedWhenTheConversationSubjectIsGone(t *testing.T) t.Errorf("follow-up thread = %q, want none: the subject cannot be matched", followUp.ThreadID) } } + +// The subject a send recorded outlives an edit to the step that wrote it: the +// recipient already has the old one, so the reply keeps it and the thread. +func TestLiveThreadFollowUpKeepsTheSubjectTheOpenerWasSentWith(t *testing.T) { + handle := liveCampaignDB(t) + sender := &recordingSender{} + svc := liveCampaignService(t, handle, sender) + f := newCampaignSendFixture(t, handle.Pool) + f.addFollowUp(t, handle.Pool, "", true) + + f.tick(t, svc) + f.confirmSend(t, handle.Pool, "", "gmail-thread-1") + if _, err := handle.Pool.Exec(context.Background(), + `UPDATE sequences SET subject = 'Edited later' WHERE id = $1`, f.step); err != nil { + t.Fatalf("edit the opener's subject: %v", err) + } + + f.tick(t, svc) + followUp := sender.message(t, 1) + if followUp.Subject != "Hi" { + t.Errorf("follow-up subject = %q, want the one the contact was sent", followUp.Subject) + } + if followUp.ThreadID != "gmail-thread-1" { + t.Errorf("follow-up thread = %q, want the conversation the opener landed in", followUp.ThreadID) + } +} + +// A contact sent the opener's Variant B is replied to under Variant B's +// subject, not the step's own (issue #774). The Original's weight is 1 against +// a million so the deterministic split puts the fixture's contact on B. +func TestLiveThreadFollowUpRepliesUnderTheArmTheContactGot(t *testing.T) { + handle := liveCampaignDB(t) + sender := &recordingSender{} + svc := liveCampaignService(t, handle, sender) + f := newCampaignSendFixture(t, handle.Pool) + f.addFollowUp(t, handle.Pool, "", true) + if _, err := handle.Pool.Exec(context.Background(), ` + INSERT INTO campaign_ab_variants (campaign_id, sequence_id, name, weight, subject, body_html, body_plain, is_control, is_active) + VALUES ($1, $2, 'Original', 1, '', '', '', true, true), + ($1, $2, 'Variant B', 1000000, 'Variant B subject', '

B

', 'B', false, true)`, + f.campaign, f.step); err != nil { + t.Fatalf("variants: %v", err) + } + + f.tick(t, svc) + if got := sender.message(t, 0).Subject; got != "Variant B subject" { + t.Fatalf("opener subject = %q, want Variant B's", got) + } + f.confirmSend(t, handle.Pool, "", "gmail-thread-1") + + f.tick(t, svc) + followUp := sender.message(t, 1) + if followUp.Subject != "Variant B subject" { + t.Errorf("follow-up subject = %q, want the arm the contact was sent", followUp.Subject) + } + if followUp.InReplyTo != "" { + t.Errorf("follow-up in_reply_to = %q, want the opener's Message-ID", followUp.InReplyTo) + } + if followUp.ThreadID != "gmail-thread-1" { + t.Errorf("follow-up thread = %q, want the conversation the opener landed in", followUp.ThreadID) + } +} + +// A follow-up repeats the opener's subject as it was rendered, so a merge field +// that changed since (or a spintax group re-rolled) cannot break the match +// Gmail threads on. +func TestLiveThreadFollowUpRepeatsTheRenderedSubject(t *testing.T) { + handle := liveCampaignDB(t) + sender := &recordingSender{} + svc := liveCampaignService(t, handle, sender) + f := newCampaignSendFixture(t, handle.Pool) + f.addFollowUp(t, handle.Pool, "", true) + ctx := context.Background() + if _, err := handle.Pool.Exec(ctx, `UPDATE sequences SET subject = 'Hi {{.FirstName}}' WHERE id = $1`, f.step); err != nil { + t.Fatalf("templated subject: %v", err) + } + + f.tick(t, svc) + if got := sender.message(t, 0).Subject; got != "Hi Live" { + t.Fatalf("opener subject = %q, want it rendered", got) + } + f.confirmSend(t, handle.Pool, "", "gmail-thread-1") + if _, err := handle.Pool.Exec(ctx, `UPDATE contacts SET first_name = 'Renamed' WHERE id = $1`, f.contact); err != nil { + t.Fatalf("rename contact: %v", err) + } + + f.tick(t, svc) + followUp := sender.message(t, 1) + if followUp.Subject != "Hi Live" { + t.Errorf("follow-up subject = %q, want the opener's as it was sent", followUp.Subject) + } + if followUp.ThreadID != "gmail-thread-1" { + t.Errorf("follow-up thread = %q, want the conversation the opener landed in", followUp.ThreadID) + } +} diff --git a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx index d5d310c83..c98b29114 100644 --- a/web/src/components/app/campaigns/sequences/CampaignFlow.tsx +++ b/web/src/components/app/campaigns/sequences/CampaignFlow.tsx @@ -98,7 +98,7 @@ import buildError from "@/lib/helper/buildError"; import EntryDelayPicker from "@/components/app/campaigns/schedule/EntryDelayPicker"; import { entryDelayLabel } from "@/components/app/campaigns/schedule/entryDelay"; import StepEmailArms from "./StepEmailArms"; -import { conversationSubjectFor } from "./threading"; +import { conversationOpenerFor, conversationSubjectFor } from "./threading"; import CategoryPicker from "@/components/app/contacts/CategoryPicker"; import { SegmentMultiPicker } from "@/components/app/segments/SegmentPickers"; import type { ActionKV, AITagRef, SequenceAction, SequenceActionType } from "@/lib/api/models/app/campaigns/sequences/Action"; @@ -2223,6 +2223,7 @@ export default function CampaignFlow({ campaignId }: { campaignId: string }) { sequence={editStep} index={editIndex} conversationSubject={conversationSubjectFor(sequences, editIndex)} + conversationOpener={conversationOpenerFor(sequences, editIndex)} /> )} diff --git a/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx b/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx index 6c635cf45..ebf67a394 100644 --- a/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx +++ b/web/src/components/app/campaigns/sequences/EmailContentEditor.tsx @@ -44,6 +44,7 @@ import { VARIABLES, htmlToPlain, linkifyUnsubscribe, promptToHtml, renderPreview import { 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"; export default function EmailContentEditor({ subject, @@ -72,8 +73,10 @@ export default function EmailContentEditor({ subjectPlaceholder?: string; // A step that replies in the contact's thread has no subject of its own: // a reply carries the conversation's. Pass the conversation's subject to - // show it read-only in place of the field. - subjectLocked?: { subject: string; note: string }; + // show it read-only in place of the field. `alternates` are the subjects + // the opening email's A/B variants carry, which a contact sent one of is + // replied to under instead. + subjectLocked?: { subject: string; note: string; alternates?: ArmSubject[] }; bodyPlaceholder?: string; // When set, the preview renders for a chosen lead and mailbox and shows the // campaign's opt-out footer, signature and attachments. @@ -283,10 +286,26 @@ export default function EmailContentEditor({ {subjectLocked ? ( <> -
+
+ {!!subjectLocked.alternates?.length && ( + Original + )} {subjectLocked.subject || "No subject"}
-

{subjectLocked.note}

+ {subjectLocked.alternates?.map((arm) => ( +
+ {arm.name} + {arm.subject} +
+ ))} +

+ {subjectLocked.note} + {!!subjectLocked.alternates?.length && + " The email that opened the thread is A/B tested, so each contact is replied to under the subject of the version they got."} +

) : ( diff --git a/web/src/components/app/campaigns/sequences/SequenceView.tsx b/web/src/components/app/campaigns/sequences/SequenceView.tsx index 106988572..99ff6823b 100644 --- a/web/src/components/app/campaigns/sequences/SequenceView.tsx +++ b/web/src/components/app/campaigns/sequences/SequenceView.tsx @@ -10,6 +10,7 @@ import toast from "react-hot-toast"; import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; import EmailContentEditor from "./EmailContentEditor"; import StepAttachments from "./StepAttachments"; +import type { ArmSubject } from "./threading"; import { Label, TextInput } from "@/components/ui/field"; import { SettingRow, Toggle } from "@/components/app/campaigns/preferences/components/CampaignPreferenceBoolBox"; import useUpdateSequence from "@/lib/api/hooks/app/campaigns/sequences/useUpdateSequence"; @@ -38,6 +39,7 @@ export default function SequenceView({ sequence, index, conversationSubject = null, + conversationArms, embedded = false, headerExtra, }: { @@ -49,6 +51,8 @@ export default function SequenceView({ // replies in the thread, because that is what the recipient reads. `null` // means there is no earlier email to reply to, so the switch is hidden. conversationSubject?: string | null; + // The other subjects the opener's A/B variants carry; see openerArmSubjects. + conversationArms?: ArmSubject[]; // When embedded inside the tabbed arms editor, drop the outer card chrome // and the "Step N" eyebrow (the tab already provides that context). embedded?: boolean; @@ -175,6 +179,7 @@ export default function SequenceView({ ? { subject: conversationSubject ?? "", note: "A reply carries the conversation's subject. Turn off Reply in thread to write your own.", + alternates: conversationArms, } : undefined } diff --git a/web/src/components/app/campaigns/sequences/StepEmailArms.tsx b/web/src/components/app/campaigns/sequences/StepEmailArms.tsx index 72587437e..c89832ead 100644 --- a/web/src/components/app/campaigns/sequences/StepEmailArms.tsx +++ b/web/src/components/app/campaigns/sequences/StepEmailArms.tsx @@ -18,6 +18,7 @@ import EmailContentEditor from "./EmailContentEditor"; import { htmlToPlain } from "./emailPreview"; import SequenceView from "./SequenceView"; import StepSplitAllocator, { type SplitArm } from "./StepSplitAllocator"; +import { openerArmSubjects, type ArmSubject } from "./threading"; import { useCampaignABVariants, useCampaignABAnalysis, @@ -42,14 +43,22 @@ export default function StepEmailArms({ sequence, index, conversationSubject = null, + conversationOpener = null, }: { campaignId: string; sequence: Sequence; index: number; // The subject of the conversation this step replies on; see SequenceView. conversationSubject?: string | null; + // The step that opened that conversation, whose A/B variants can carry + // other subjects a contact is replied to under. + conversationOpener?: Sequence | null; }) { const { data: all } = useCampaignABVariants(campaignId); + const conversationArms = React.useMemo( + () => openerArmSubjects(conversationOpener, all ?? []), + [conversationOpener, all], + ); const stepRows = (all ?? []).filter((v) => v.step_id === sequence.id); const controlRow = stepRows.find((v) => v.is_control) ?? null; const variants = stepRows.filter((v) => !v.is_control); @@ -198,6 +207,7 @@ export default function StepEmailArms({ sequence={sequence} index={index} conversationSubject={conversationSubject} + conversationArms={conversationArms} headerExtra={ variants.length === 0 ? (
@@ -242,6 +253,7 @@ function VariantEditor({ onTogglePause, onDelete, inheritedSubject, + inheritedArms, }: { campaignId: string; variant: ABVariant; @@ -254,6 +266,7 @@ function VariantEditor({ // to the conversation, so no arm can carry one of its own and the send // path ignores the column. Null when the step writes its own subject. inheritedSubject?: string | null; + inheritedArms?: ArmSubject[]; }) { const update = useUpdateABVariant(campaignId); @@ -345,6 +358,7 @@ function VariantEditor({ ? { subject: inheritedSubject, note: "This step replies in the contact's thread, so every arm carries the conversation's subject and varies the body only.", + alternates: inheritedArms, } : undefined } diff --git a/web/src/components/app/campaigns/sequences/threading.test.ts b/web/src/components/app/campaigns/sequences/threading.test.ts new file mode 100644 index 000000000..fac7e106d --- /dev/null +++ b/web/src/components/app/campaigns/sequences/threading.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; +import type ABVariant from "@/lib/api/models/app/campaigns/ABVariant"; +import { conversationOpenerFor, conversationSubjectFor, openerArmSubjects } from "./threading"; + +const step = (id: string, subject: string, thread_reply = true, kind: Sequence["kind"] = "email") => + ({ id, subject, thread_reply, kind }) as Sequence; + +const variant = (step_id: string | null, name: string, subject: string, extra: Partial = {}) => + ({ id: name, step_id, name, subject, is_active: true, is_control: false, ...extra }) as ABVariant; + +describe("conversationOpenerFor", () => { + const steps = [step("a", "Opener", false), step("w", "", true, "wait"), step("b", ""), step("c", "Fresh", false), step("d", "")]; + + it("walks back past replies and control nodes to the step that opened the thread", () => { + expect(conversationOpenerFor(steps, 2)?.id).toBe("a"); + expect(conversationOpenerFor(steps, 4)?.id).toBe("c"); + expect(conversationSubjectFor(steps, 2)).toBe("Opener"); + }); + + it("has no opener for the first email", () => { + expect(conversationOpenerFor(steps, 0)).toBeNull(); + expect(conversationSubjectFor(steps, 0)).toBeNull(); + }); +}); + +describe("openerArmSubjects", () => { + const opener = step("a", "Opener", false); + + it("lists the subjects the opener's own variants send instead of its own", () => { + const arms = openerArmSubjects(opener, [ + variant("a", "Variant B", "Other angle"), + variant("a", "Variant C", ""), + variant("a", "Variant D", "Opener"), + variant("a", "Paused", "Paused angle", { is_active: false }), + variant("a", "Original", "Ignored", { is_control: true }), + variant("b", "Elsewhere", "Another step"), + variant(null, "Campaign-wide", "Shadowed by the step's own"), + ]); + expect(arms).toEqual([{ name: "Variant B", subject: "Other angle" }]); + }); + + it("lets the opener's own control row shadow campaign-wide variants", () => { + const arms = openerArmSubjects(opener, [ + variant("a", "Original", "", { is_control: true }), + variant(null, "Variant B", "Campaign angle"), + ]); + expect(arms).toEqual([]); + }); + + it("falls back to campaign-wide variants when the opener has none", () => { + expect(openerArmSubjects(opener, [variant(null, "Variant B", "Campaign angle")])).toEqual([ + { name: "Variant B", subject: "Campaign angle" }, + ]); + }); + + it("is empty without an opener", () => { + expect(openerArmSubjects(null, [variant("a", "Variant B", "Other angle")])).toEqual([]); + }); +}); diff --git a/web/src/components/app/campaigns/sequences/threading.ts b/web/src/components/app/campaigns/sequences/threading.ts index 5250fcf34..e27bd523e 100644 --- a/web/src/components/app/campaigns/sequences/threading.ts +++ b/web/src/components/app/campaigns/sequences/threading.ts @@ -1,14 +1,13 @@ import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; +import type ABVariant from "@/lib/api/models/app/campaigns/ABVariant"; /** - * conversationSubjectFor is the subject a step at `index` would send when it - * replies in the contact's thread: the subject of the step that opened that - * conversation. Walking back, every step that also replies passes the question - * on; the first that does not is the one that opened the thread. + * conversationOpenerFor is the step that opened the conversation a step at + * `index` would reply on. Walking back, every step that also replies passes + * the question on; the first that does not is the one that opened the thread. * * `null` means there is no earlier email step, so this one opens the - * conversation whatever its switch says. An empty string means the opener - * itself has no subject yet. + * conversation whatever its switch says. * * It mirrors the walk the send path does over the contact's actual sends, so * the composer shows what the recipient will read. It follows the canvas order @@ -17,13 +16,48 @@ import type Sequence from "@/lib/api/models/app/campaigns/sequences/Sequence"; * send path resolves it per contact from what they were actually sent, so this * is a preview, and for a linear sequence the two always agree. */ -export function conversationSubjectFor(steps: Sequence[], index: number): string | null { - let subject: string | null = null; +export function conversationOpenerFor(steps: Sequence[], index: number): Sequence | null { + let opener: Sequence | null = null; for (let i = index - 1; i >= 0; i--) { const step = steps[i]; if (step.kind !== "email") continue; - subject = step.subject; + opener = step; if (!step.thread_reply) break; } - return subject; + return opener; +} + +/** + * conversationSubjectFor is the subject a step at `index` would send when it + * replies in the contact's thread: the opener's own. `null` when there is no + * earlier email step; an empty string when the opener has no subject yet. + */ +export function conversationSubjectFor(steps: Sequence[], index: number): string | null { + return conversationOpenerFor(steps, index)?.subject ?? null; +} + +export interface ArmSubject { + name: string; + subject: string; +} + +/** + * openerArmSubjects lists the subjects the opener's A/B variants send in place + * of its own, which each contact sent one is replied to under. Mirrors + * SelectVariant: any active row on the opener (its control included) shadows + * the campaign-wide variants, and a blank variant subject reuses the step's. + */ +export function openerArmSubjects(opener: Sequence | null, variants: ABVariant[]): ArmSubject[] { + if (!opener) return []; + const active = variants.filter((v) => v.is_active); + const own = active.filter((v) => v.step_id === opener.id); + const arms = (own.length > 0 ? own : active.filter((v) => !v.step_id)).filter((v) => !v.is_control); + const seen = new Set([opener.subject]); + const out: ArmSubject[] = []; + for (const v of arms) { + if (!v.subject || seen.has(v.subject)) continue; + seen.add(v.subject); + out.push({ name: v.name, subject: v.subject }); + } + return out; }