mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-08 16:02:18 +00:00
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
This commit is contained in:
@@ -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). |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE campaign_tasks
|
||||
DROP COLUMN IF EXISTS subject;
|
||||
@@ -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;
|
||||
@@ -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, "<one@test.local>", "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, "<two@test.local>", "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 != "<two@test.local>" {
|
||||
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, "<one@test.local>", "thr-1", 120)
|
||||
f.exec(`UPDATE campaign_tasks SET subject = 'Variant B subject' WHERE task_id = $1`, opener)
|
||||
f.send(second, f.mailbox, "<two@test.local>", "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, "<one@test.local>", "thr-1", 120)
|
||||
reply := f.send(second, f.mailbox, "<two@test.local>", "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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -239,12 +239,16 @@ func TestLiveThreadHandleIsDroppedWhenTheConversationSubjectIsGone(t *testing.T)
|
||||
|
||||
f.tick(t, svc)
|
||||
f.confirmSend(t, handle.Pool, "<opener@test.local>", "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, "<opener@test.local>", "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', '<p>B</p>', '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, "<opener@test.local>", "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 != "<opener@test.local>" {
|
||||
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, "<opener@test.local>", "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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
{subjectLocked ? (
|
||||
<>
|
||||
<div className="h-9 px-3 flex items-center rounded-md border border-slate-200 bg-slate-50 text-[13.5px] text-slate-500">
|
||||
<div className="h-9 px-3 flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 text-[13.5px] text-slate-500">
|
||||
{!!subjectLocked.alternates?.length && (
|
||||
<span className="shrink-0 text-[11px] font-medium text-slate-400">Original</span>
|
||||
)}
|
||||
<span className="truncate">{subjectLocked.subject || "No subject"}</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[10.5px] text-slate-400">{subjectLocked.note}</p>
|
||||
{subjectLocked.alternates?.map((arm) => (
|
||||
<div
|
||||
key={arm.subject}
|
||||
className="mt-1 h-9 px-3 flex items-center gap-2 rounded-md border border-slate-200 bg-slate-50 text-[13.5px] text-slate-500"
|
||||
>
|
||||
<span className="shrink-0 text-[11px] font-medium text-slate-400">{arm.name}</span>
|
||||
<span className="truncate">{arm.subject}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="mt-1.5 text-[10.5px] text-slate-400">
|
||||
{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."}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<TextInput value={subject} onChange={onSubjectChange} placeholder={subjectPlaceholder} className="w-full h-9 px-3 text-[13.5px]" />
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
<button
|
||||
@@ -227,6 +237,7 @@ export default function StepEmailArms({
|
||||
onTogglePause={(active) => togglePause(selectedVariant.id, active)}
|
||||
onDelete={() => deleteArm(selectedVariant.id)}
|
||||
inheritedSubject={sequence.thread_reply ? conversationSubject || null : null}
|
||||
inheritedArms={conversationArms}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<ABVariant> = {}) =>
|
||||
({ 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user