Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs/emails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
20 changes: 14 additions & 6 deletions src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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));
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
Expand Down
10 changes: 5 additions & 5 deletions src/lets-talk-flows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export function letsTalkFlowSettings(config: Config): Map<string, unknown> {
templateId,
replyCapPerDay: config.LETS_TALK_REPLY_CAP_PER_DAY,
});
const [salesReplyTo, ...salesCopies] = config.LETS_TALK_SALES_REPLY_TO;
return new Map<string, unknown>([
[
LETS_TALK_FLOWS.legacySalesEmail,
Expand Down Expand Up @@ -65,10 +64,11 @@ export function letsTalkFlowSettings(config: Config): Map<string, unknown> {
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 }
: {}),
},
],
[
Expand Down
35 changes: 34 additions & 1 deletion test/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()),
Expand Down
12 changes: 7 additions & 5 deletions test/lets-talk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
],
});
});

Expand All @@ -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"],
Expand Down
Loading