From eb6a566ee1e3cfae955807deb9c09eb2eefd4224 Mon Sep 17 00:00:00 2001 From: Vasilica Olariu Date: Fri, 30 Oct 2026 16:46:07 +0200 Subject: [PATCH] Sales lead: send all recipients as "replyTo" --- .env.example | 2 +- README.md | 2 +- ...alk-ai-classification-and-label-actions.md | 8 +++-- docs/emails/README.md | 2 +- src/actions.ts | 20 +++++++---- src/config.ts | 2 +- src/lets-talk-flows.ts | 10 +++--- test/actions.test.ts | 35 ++++++++++++++++++- test/lets-talk.test.ts | 12 ++++--- 9 files changed, 69 insertions(+), 24 deletions(-) diff --git a/.env.example b/.env.example index dc66093..c24eb0b 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,6 @@ LETS_TALK_WIPRO_TEMPLATE_ID= LETS_TALK_SALES_REPLY_TEMPLATE_ID= LETS_TALK_SUPPORT_TEMPLATE_ID= # LETS_TALK_SUPPORT_RECIPIENTS=support@topcoder.com -# First address is the sales reply's reply-to; the rest are copied. +# The sales reply's reply-to addresses: a prospect's reply goes to all of them. # LETS_TALK_SALES_REPLY_TO=support@topcoder.com,test1@topcoder.com # LETS_TALK_REPLY_CAP_PER_DAY=1 diff --git a/README.md b/README.md index 2a41ddf..466fea3 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ SecureStrings (`src/lets-talk-flows.ts`), not from the database. | `lets-talk-community-skills-reply` | `COMMUNITY_SKILLS` | Reply to the submitter (`LETS_TALK_SKILLS_TEMPLATE_ID`) | | `lets-talk-community-general-reply` | `COMMUNITY_GENERAL` | Reply to the submitter (`LETS_TALK_GENERAL_TEMPLATE_ID`) | | `lets-talk-wipro-queries-reply` | `WIPRO_QUERIES` | Reply to the submitter (`LETS_TALK_WIPRO_TEMPLATE_ID`) | -| `lets-talk-sales-reply` | `SALES_LEAD` | Reply to the submitter (`LETS_TALK_SALES_REPLY_TEMPLATE_ID`); the first `LETS_TALK_SALES_REPLY_TO` address is reply-to, the others are copied | +| `lets-talk-sales-reply` | `SALES_LEAD` | Reply to the submitter (`LETS_TALK_SALES_REPLY_TEMPLATE_ID`); every `LETS_TALK_SALES_REPLY_TO` address is a reply-to (SendGrid `reply_to_list`); none is copied | | `lets-talk-support-forward` | `COMMUNITY_SUPPORT` | Full submission to `LETS_TALK_SUPPORT_RECIPIENTS`, reply-to the submitter (`LETS_TALK_SUPPORT_TEMPLATE_ID`) | | `lets-talk-sales-email` (existing) | after cut-over: `UNKNOWN` only | The original internal email, now the fallback when classification fails | diff --git a/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md b/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md index b4efc9a..248fb0c 100644 --- a/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md +++ b/docs/adr/0001-lets-talk-ai-classification-and-label-actions.md @@ -36,7 +36,7 @@ Constraints from the existing system: - forms-processor-v6 routes a submission to **all** matching flows at once, using rules on answers only. It holds a row lock for the length of each action, and the pool's idle-in-transaction limit is 45 s. - The processor's email action deliberately takes recipients **only from trusted configuration**. -- email-service-v6 accepts **one** `replyTo`. +- email-service-v6 accepted **one** `replyTo` (it now also takes a list, sent as SendGrid `reply_to_list`; Decision 7). - The AI workflow's access policy is scopes-only, so administrator member tokens can't read its runs. - The Let's talk form allows 500-character names and company. The workflow's input schema caps them at 100/100/200 and expects the selector's display labels, while the form sends stable keys. @@ -86,7 +86,7 @@ Flows (seeded disabled by a forms-api-v6 migration): | `lets-talk-community-general-reply` | `sendgrid-email`, to the submitter | `COMMUNITY_GENERAL` | | `lets-talk-wipro-queries-reply` | `sendgrid-email`, to the submitter | `WIPRO_QUERIES` | | `lets-talk-support-forward` | `sendgrid-email`, to `support@topcoder.com`, reply-to the submitter | `COMMUNITY_SUPPORT` | -| `lets-talk-sales-reply` | `sendgrid-email`, to the submitter, reply-to and copies from `LETS_TALK_SALES_REPLY_TO` | `SALES_LEAD` | +| `lets-talk-sales-reply` | `sendgrid-email`, to the submitter, reply-to every `LETS_TALK_SALES_REPLY_TO` address | `SALES_LEAD` | | `lets-talk-sales-email` (existing) | `sendgrid-email`, to `LETS_TALK_RECIPIENTS` | `UNKNOWN` (fallback, Decision 6) | As for the existing flow, SSM values are authoritative for every Let's talk flow: template ids, the support recipients, the reply-to pool, and the AI API URL and audience. Changing them needs a redeploy. @@ -129,7 +129,9 @@ A submission whose classification ends `FAILED` keeps the label `UNKNOWN`. It is `needsReview` doesn't change the action; it is shown in the UI. Holding auto-replies for review would affect at least the 45% of submissions with an empty message (Q1). -### 7. Sales reply: the first listed address gets the replies, the rest are copied +### 7. Sales reply: every listed address is a reply-to + +**Revised 2026-10-29.** Copying the other addresses (`cc`) sent each of them a copy of the reply to the prospect, which was not wanted. email-service-v6 now accepts `replyTo` as a list and sends two or more addresses as SendGrid `reply_to_list`, so **every** `LETS_TALK_SALES_REPLY_TO` address is a reply-to and none is copied. A prospect who presses Reply writes to all of them. This needs email-service-v6 deployed first: an older email-service-v6 skips a message whose `replyTo` is a list as malformed. The original decision follows. Decided 2026-10-22, revising the earlier "one address per submission from a pool". The sales reply should reach a configurable list of people (`LETS_TALK_SALES_REPLY_TO`). email-service-v6 accepts a single `replyTo`, so: diff --git a/docs/emails/README.md b/docs/emails/README.md index 500c808..c4bb5c2 100644 --- a/docs/emails/README.md +++ b/docs/emails/README.md @@ -91,4 +91,4 @@ The Figma designs don't include these links, so check them first: - "sign up here" (general reply, developer getting started): `https://accounts-auth0.topcoder.com/?mode=signUp`. The practice email's "create your free account" links to `https://www.topcoder.com/`, as in its design. - The social links: Facebook, YouTube, LinkedIn, Twitter and Instagram (`https://discord.com/invite/topcoder` comes from the designs). -- The `SALES_LEAD` template has no Figma design; it follows the shared shell. Its signature and `mailto:` are Kiril Kartunov's (hard-coded), while replies go to the first `LETS_TALK_SALES_REPLY_TO` address (default `support@topcoder.com`; the rest, default `test1@topcoder.com`, are copied). Set that list for production so the first address is the signer's. +- The `SALES_LEAD` template has no Figma design; it follows the shared shell. Its signature and `mailto:` are Kiril Kartunov's (hard-coded), while replies go to every `LETS_TALK_SALES_REPLY_TO` address (default `support@topcoder.com`, `test1@topcoder.com`). Set that list for production so it includes the signer. diff --git a/src/actions.ts b/src/actions.ts index 7adc4bc..28f75bb 100644 --- a/src/actions.ts +++ b/src/actions.ts @@ -76,8 +76,11 @@ export const emailSettingsSchema = z templateId: z.string().regex(/^d-[a-f0-9]{32}$/), fromEmail: z.email().nullish(), fromName: z.string().max(200).optional(), - /** A trusted reply-to address, or the address in an answer (e.g. support forwards). */ - replyTo: z.email().optional(), + /** Trusted reply-to address(es), or the address in an answer (e.g. support forwards). + * Several addresses become SendGrid's reply_to_list in email-service-v6. */ + replyTo: z + .union([z.email(), z.array(z.email()).min(1).max(100)]) + .optional(), replyToFromAnswer: z.string().min(1).optional(), /** Trusted copies, e.g. the sales team on a sales reply. */ cc: z.array(z.email()).max(100).optional(), @@ -139,11 +142,16 @@ export class BusEmail implements Action { ...new Set(s.recipients!.map((email) => email.toLowerCase())), ]; } + const replyTos = s.replyTo + ? [...new Set([s.replyTo].flat().map((email) => email.toLowerCase()))] + : []; const replyTo = - s.replyTo?.toLowerCase() ?? - (s.replyToFromAnswer - ? answerAddress(event, s.replyToFromAnswer) - : undefined); + replyTos.length > 1 + ? replyTos + : (replyTos[0] ?? + (s.replyToFromAnswer + ? answerAddress(event, s.replyToFromAnswer) + : undefined)); const cc = [ ...new Set((s.cc ?? []).map((email) => email.toLowerCase())), ].filter((email) => !recipients.includes(email)); diff --git a/src/config.ts b/src/config.ts index 7eec909..456ca1a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -63,7 +63,7 @@ const schema = z.object({ LETS_TALK_SALES_REPLY_TEMPLATE_ID: templateId, LETS_TALK_SUPPORT_TEMPLATE_ID: templateId, LETS_TALK_SUPPORT_RECIPIENTS: emailList("support@topcoder.com"), - /** First address: the sales reply's reply-to; the rest are copied (email-service-v6 takes one reply-to). */ + /** The sales reply's reply-to addresses; all of them receive a prospect's reply. */ LETS_TALK_SALES_REPLY_TO: emailList( "support@topcoder.com,test1@topcoder.com", ), diff --git a/src/lets-talk-flows.ts b/src/lets-talk-flows.ts index 4faab6d..3f49c28 100644 --- a/src/lets-talk-flows.ts +++ b/src/lets-talk-flows.ts @@ -37,7 +37,6 @@ export function letsTalkFlowSettings(config: Config): Map { templateId, replyCapPerDay: config.LETS_TALK_REPLY_CAP_PER_DAY, }); - const [salesReplyTo, ...salesCopies] = config.LETS_TALK_SALES_REPLY_TO; return new Map([ [ LETS_TALK_FLOWS.legacySalesEmail, @@ -65,10 +64,11 @@ export function letsTalkFlowSettings(config: Config): Map { LETS_TALK_FLOWS.salesReply, { ...reply(config.LETS_TALK_SALES_REPLY_TEMPLATE_ID), - // email-service-v6 accepts a single reply-to: the first address gets the - // replies, the rest of the list is copied on every sales reply. - ...(salesReplyTo ? { replyTo: salesReplyTo } : {}), - ...(salesCopies.length ? { cc: salesCopies } : {}), + // Every address is a reply-to: a prospect's reply goes to all of them, + // and none of them gets a copy of the reply itself. + ...(config.LETS_TALK_SALES_REPLY_TO.length + ? { replyTo: config.LETS_TALK_SALES_REPLY_TO } + : {}), }, ], [ diff --git a/test/actions.test.ts b/test/actions.test.ts index b0fac50..cb4a4eb 100644 --- a/test/actions.test.ts +++ b/test/actions.test.ts @@ -145,7 +145,38 @@ test("a submitter reply goes to the submitted address with only the first name a }); }); -test("the sales reply sets the trusted reply-to and copies the rest, never the recipient twice", async () => { +test("several trusted reply-to addresses are published as a deduplicated list", async () => { + const { published, action } = capture(); + await action.execute( + event, + { + ...replySettings, + replyTo: [ + "Support@topcoder.com", + "test1@topcoder.com", + "support@topcoder.com", + ], + }, + context(), + ); + assert.deepEqual(published[0].payload.replyTo, [ + "support@topcoder.com", + "test1@topcoder.com", + ]); + assert.equal(published[0].payload.cc, undefined); +}); + +test("a one-address reply-to list is published as a single reply-to", async () => { + const { published, action } = capture(); + await action.execute( + event, + { ...replySettings, replyTo: ["Support@topcoder.com"] }, + context(), + ); + assert.equal(published[0].payload.replyTo, "support@topcoder.com"); +}); + +test("a trusted reply-to with copies never copies the recipient twice", async () => { const { published, action } = capture(); await action.execute( event, @@ -247,6 +278,8 @@ test("recipient settings must name exactly one source, and one reply-to", async { ...replySettings, replyTo: "a@example.com", replyToFromAnswer: "email" }, { ...replySettings, templateId: "" }, { ...replySettings, cc: ["not-an-email"] }, + { ...replySettings, replyTo: [] }, + { ...replySettings, replyTo: ["a@example.com", "not-an-email"] }, ]) await assert.rejects( action.execute(event, bad, context()), diff --git a/test/lets-talk.test.ts b/test/lets-talk.test.ts index cf27901..cc9f4a3 100644 --- a/test/lets-talk.test.ts +++ b/test/lets-talk.test.ts @@ -101,7 +101,7 @@ const base = { KAFKA_URL: "localhost:9092", }; -test("flow settings: the sales reply's first address is reply-to and the rest are copied", () => { +test("flow settings: every sales reply address is a reply-to and none is copied", () => { const settings = letsTalkFlowSettings( config({ ...base, @@ -114,8 +114,11 @@ test("flow settings: the sales reply's first address is reply-to and the rest ar recipientsFromAnswer: "email", templateId: "d-" + "b".repeat(32), replyCapPerDay: 1, - replyTo: "support@topcoder.com", - cc: ["test1@topcoder.com", "sales@topcoder.com"], + replyTo: [ + "support@topcoder.com", + "test1@topcoder.com", + "sales@topcoder.com", + ], }); }); @@ -125,8 +128,7 @@ test("flow settings: defaults, support forward and classification", () => { recipientsFromAnswer: "email", templateId: "", replyCapPerDay: 1, - replyTo: "support@topcoder.com", - cc: ["test1@topcoder.com"], + replyTo: ["support@topcoder.com", "test1@topcoder.com"], }); assert.deepEqual(settings.get(LETS_TALK_FLOWS.supportForward), { recipients: ["support@topcoder.com"],