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
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ 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

# SpaceTech challenge notification (docs/emails/README.md). Empty values defer the flow.
SPACE_TECH_CHALLENGE_RECIPIENTS=
SPACE_TECH_CHALLENGE_TEMPLATE_ID=
11 changes: 10 additions & 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 Expand Up @@ -198,6 +198,15 @@ and register it in `src/main.ts` for a new action type. Rule validation lives in
only validates and persists events. No browser redirects occur in this async worker;
a future member signup redirect belongs in the website's form response flow.

## SpaceTech challenge notification

Flow `space-tech-challenge-email` (forms-api-v6 migration `20261102010000_space_tech_challenge_email`,
seeded disabled) emails every `space_tech_challenge` submission to `SPACE_TECH_CHALLENGE_RECIPIENTS`
(comma-separated, same rules as `LETS_TALK_RECIPIENTS`) using `SPACE_TECH_CHALLENGE_TEMPLATE_ID`,
with the submitter's `email` answer as reply-to. Both come from processor SSM SecureStrings
(`src/space-tech-flows.ts`) and replace the flow's database settings; unset values defer delivery.
Setup: [docs/emails/README.md](docs/emails/README.md#spacetech-challenge-notification).

## Reliability and operations

* Kafka commits only after PostgreSQL persists the event. Duplicate submission IDs
Expand Down
1 change: 1 addition & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ parameters and `kms:Decrypt` when a customer-managed key is used.
| `AI_API_URL`, `AI_AUTH0_AUDIENCE` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; tc-ai-api base ending in `/v6/ai`, e.g. `https://api.topcoder.com/v6/ai`; the audience defaults to `AUTH0_AUDIENCE`) |
| `LETS_TALK_OPPORTUNITY_TEMPLATE_ID`, `LETS_TALK_SKILLS_TEMPLATE_ID`, `LETS_TALK_GENERAL_TEMPLATE_ID`, `LETS_TALK_WIPRO_TEMPLATE_ID`, `LETS_TALK_SALES_REPLY_TEMPLATE_ID`, `LETS_TALK_SUPPORT_TEMPLATE_ID` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; one SendGrid template per label flow, see `docs/emails/README.md`) |
| `LETS_TALK_SUPPORT_RECIPIENTS`, `LETS_TALK_SALES_REPLY_TO`, `LETS_TALK_REPLY_CAP_PER_DAY`, `LETS_TALK_CLASSIFY_MAX_ATTEMPTS`, `LETS_TALK_CLASSIFY_MAX_AGE_MINUTES` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; defaults `support@topcoder.com`, `support@topcoder.com,test1@topcoder.com`, `1`, `5`, `30`) |
| `SPACE_TECH_CHALLENGE_RECIPIENTS`, `SPACE_TECH_CHALLENGE_TEMPLATE_ID` | `/config/forms-processor-v6/appvar/` (optional SecureStrings; comma-separated addresses and the active `d-` template ID for the SpaceTech challenge notification; unset defers it) |
| `DATABASE_URL`, `AUTH0_CLIENT_ID`, `AUTH0_CLIENT_SECRET` | `/config/forms-api-v6/appvar/` |
| `AUTH0_URL`, `AUTH0_AUDIENCE`, optional `AUTH0_PROXY_SERVER_URL` | Forms API appvars when present, otherwise `/config/common/global-appvar/` |
| `KAFKA_URL`, optional `KAFKA_SSL_ENABLED` | `/config/common/global-appvar/` |
Expand Down
6 changes: 4 additions & 2 deletions deploy/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@ def read_service(ecs):
return service


# Optional processor-owned settings for AI classification and label actions (docs/adr/0001).
# Absent values defer the matching flows; the code defaults apply where documented.
# Optional processor-owned settings for AI classification and label actions (docs/adr/0001),
# and for the SpaceTech challenge notification. Absent values defer the matching flows;
# the code defaults apply where documented.
LABEL_ACTION_SETTINGS = [
'AI_API_URL', 'AI_AUTH0_AUDIENCE',
'LETS_TALK_CLASSIFY_MAX_ATTEMPTS', 'LETS_TALK_CLASSIFY_MAX_AGE_MINUTES',
'LETS_TALK_OPPORTUNITY_TEMPLATE_ID', 'LETS_TALK_SKILLS_TEMPLATE_ID',
'LETS_TALK_GENERAL_TEMPLATE_ID', 'LETS_TALK_WIPRO_TEMPLATE_ID',
'LETS_TALK_SALES_REPLY_TEMPLATE_ID', 'LETS_TALK_SUPPORT_TEMPLATE_ID',
'LETS_TALK_SUPPORT_RECIPIENTS', 'LETS_TALK_SALES_REPLY_TO', 'LETS_TALK_REPLY_CAP_PER_DAY',
'SPACE_TECH_CHALLENGE_RECIPIENTS', 'SPACE_TECH_CHALLENGE_TEMPLATE_ID',
]


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
21 changes: 20 additions & 1 deletion docs/emails/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,23 @@ 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.

## SpaceTech challenge notification

[`space-tech-challenge-email.html`](space-tech-challenge-email.html) is the internal email for the SpaceTech landing page form (`space_tech_challenge`). Flow `space-tech-challenge-email` (forms-api-v6 migration `20261102010000_space_tech_challenge_email`) sends every submission to the addresses in `SPACE_TECH_CHALLENGE_RECIPIENTS`, with the submitter's `email` answer as reply-to. Suggested subject: **New space-data challenge** (keep it fixed).

It uses `answers.full_name`, `answers.email`, `answers.company`, `answers.challenge`, `submittedAt`, `sourcePage` and `submissionId`. All the answers are submitter-controlled: double braces only. For test data, use `{"answers":{"full_name":"Alex Doe","email":"alex@example.com","company":"Orbital Labs","challenge":"Detect debris in\nlow-orbit imagery."},"submittedAt":"2026-11-02T10:00:00.000Z","sourcePage":"/space-tech/","submissionId":"00000000-0000-0000-0000-000000000000"}`.

To go live in an environment:

1. Create the template (steps 1–6 above).
2. Create the SecureStrings `/config/forms-processor-v6/appvar/SPACE_TECH_CHALLENGE_RECIPIENTS` (comma-separated addresses) and `/config/forms-processor-v6/appvar/SPACE_TECH_CHALLENGE_TEMPLATE_ID`, then **redeploy the processor**.
3. Enable the flow:

```sql
UPDATE forms."ProcessorFlow" SET enabled = true, "updatedAt" = now()
WHERE id = 'space-tech-challenge-email' AND "formKey" = 'space_tech_challenge';
```

Submissions made while the flow is disabled are queued and are sent once it is enabled. Changing the list means updating the parameter and redeploying.
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
5 changes: 4 additions & 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",
),
Comment on lines +66 to 69
Expand All @@ -73,6 +73,9 @@ const schema = z.object({
.min(0)
.max(100)
.default(1),
// SpaceTech landing page challenges: internal notification to a fixed list.
SPACE_TECH_CHALLENGE_RECIPIENTS: emailList(""),
SPACE_TECH_CHALLENGE_TEMPLATE_ID: templateId,
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
});
export type Config = z.infer<typeof schema> & {
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
6 changes: 5 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
letsTalkFlowSettings,
} from "./lets-talk-flows.js";
import { LETS_TALK_ANSWERS } from "./lets-talk.js";
import { spaceTechFlowSettings } from "./space-tech-flows.js";
import { Store, log } from "./store.js";

/** Start the database inbox worker, Kafka consumer, and private health endpoint with environment-owned Let's talk settings.
Expand Down Expand Up @@ -43,7 +44,10 @@ async function main(): Promise<void> {
),
],
]),
letsTalkFlowSettings(settings),
new Map([
...letsTalkFlowSettings(settings),
...spaceTechFlowSettings(settings),
]),
{
submitterFlowIds: LETS_TALK_SUBMITTER_FLOWS,
submitterAddressAnswer: LETS_TALK_ANSWERS.email,
Expand Down
24 changes: 24 additions & 0 deletions src/space-tech-flows.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Config } from "./config.js";

/** SpaceTech landing page flow id, as seeded by forms-api-v6 migration 20261102010000_space_tech_challenge_email. */
export const SPACE_TECH_CHALLENGE_FLOW = "space-tech-challenge-email";

/**
* Settings for the SpaceTech challenge notification, from SSM-backed configuration. They replace
* the flow's database settings: every submission goes to SPACE_TECH_CHALLENGE_RECIPIENTS with the
* submitter's email as reply-to. An empty recipient list or template id leaves its deliveries
* deferred with invalid_email_settings until set. Never throws.
*/
export function spaceTechFlowSettings(config: Config): Map<string, unknown> {
return new Map<string, unknown>([
[
SPACE_TECH_CHALLENGE_FLOW,
{
recipients: config.SPACE_TECH_CHALLENGE_RECIPIENTS,
templateId: config.SPACE_TECH_CHALLENGE_TEMPLATE_ID,
// Replies go straight to the submitter.
replyToFromAnswer: "email",
},
],
]);
}
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
Loading