diff --git a/cli-node/src/oclif/commands/functions-init.ts b/cli-node/src/oclif/commands/functions-init.ts index 185e76f..98b2b7b 100644 --- a/cli-node/src/oclif/commands/functions-init.ts +++ b/cli-node/src/oclif/commands/functions-init.ts @@ -59,10 +59,16 @@ export function renderHandler(): string { return `// env.PRIMITIVE_API_KEY is auto-injected by the Primitive Functions runtime. import { createPrimitiveClient } from "@primitivedotdev/sdk/api"; +// TODO: replace with your verified sender address. Must be a domain +// you own or your managed *.primitive.email subdomain. The +// self-reply guard below compares incoming mail against this value +// to avoid the handler replying to its own outbound traffic. +const REPLY_FROM = "you@your-domain.primitive.email"; + interface EmailReceivedEvent { event: string; email: { - headers: { from?: string; subject?: string }; + headers: { from?: string; to?: string; subject?: string }; }; } @@ -82,6 +88,20 @@ export default { return Response.json({ ok: true, skipped: event.event }); } + // Self-reply guard: do not act on mail that this function itself + // sent. Without this, an outbound reply that gets forwarded back + // into the inbox would trigger another reply, and so on. + // + // event.email.headers.from is the raw RFC 2822 header value, so + // it may be either a bare address ("alice@example.com") or a + // display-name form ("Alice "). A substring + // check matches both. Tighten this predicate (e.g. parse the + // bracketed address) if you legitimately want to act on mail + // from your own domain. + if (event.email.headers.from?.includes(REPLY_FROM)) { + return Response.json({ ok: true, skipped: "self-reply" }); + } + const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY }); // Recipient gate @@ -92,9 +112,14 @@ export default { // external addresses return 403 recipient_not_allowed with a // structured gates[] array until the recipient has authenticated // to you or support has enabled the gate. + + // Recipient routing: a single function can handle multiple inbound + // addresses by branching on event.email.headers.to. For example, + // route "support@" to a ticketing flow and "sales@" to a lead + // capture flow before calling client.send. const reply = await client.send({ - from: "you@your-domain.primitive.email", - to: event.email.headers.from ?? "you@your-domain.primitive.email", + from: REPLY_FROM, + to: event.email.headers.from ?? REPLY_FROM, subject: \`Re: \${event.email.headers.subject ?? ""}\`, bodyText: "Got your message.", }); diff --git a/cli-node/tests/oclif/functions-init.test.ts b/cli-node/tests/oclif/functions-init.test.ts index 047f4b3..0a6b35b 100644 --- a/cli-node/tests/oclif/functions-init.test.ts +++ b/cli-node/tests/oclif/functions-init.test.ts @@ -113,6 +113,63 @@ describe("renderHandler", () => { }); }); +describe("renderHandler self-reply guard + REPLY_FROM constant", () => { + it("declares a REPLY_FROM constant with a TODO marker in the preceding comment", () => { + // AGX feedback: the placeholder sender was buried inline as a + // string literal in two places. Lifting it into a named constant + // with a TODO marker makes the "replace me before deploying" step + // impossible to miss. + const handler = renderHandler(); + expect(handler).toContain("const REPLY_FROM ="); + // The TODO marker must appear in the comment block above the + // const so the scaffolded author sees it before the value. + const beforeConst = handler.slice(0, handler.indexOf("const REPLY_FROM")); + expect(beforeConst).toContain("TODO"); + }); + + it("includes a self-reply guard predicate that returns skipped: self-reply", () => { + // AGX feedback: without this guard, an outbound reply that gets + // forwarded back into the inbox triggers another reply, and so on. + // The scaffold defaults users into the safe shape. + // + // The predicate must be substring-based, not strict equality: + // event.email.headers.from is the raw RFC 2822 header value and + // commonly carries a display name ("Alice "). + // A strict === check would silently miss those payloads. + const handler = renderHandler(); + expect(handler).toContain("event.email.headers.from?.includes(REPLY_FROM)"); + expect(handler).not.toContain("event.email.headers.from === REPLY_FROM"); + expect(handler).toMatch(/skipped:\s*["']self-reply["']/); + // The comment block above the guard must call out the RFC 2822 + // shape so future authors know why .includes is used. + expect(handler).toMatch(/RFC 2822/); + }); + + it("includes a recipient-routing comment block pointing at event.email.headers.to", () => { + // The recipient-routing pattern lets a single function fan out + // per-address logic (e.g. support@ vs sales@). A short comment in + // the scaffold surfaces the pattern without forcing the author to + // discover it in the docs. + const handler = renderHandler(); + expect(handler).toContain("Recipient routing:"); + expect(handler).toContain("event.email.headers.to"); + }); + + it("uses the REPLY_FROM constant in both the from: and fallback to: slots, with no duplicated string literal", () => { + // Regression guard: the placeholder address must appear exactly + // once in the rendered handler (inside the REPLY_FROM const), not + // duplicated in the send() call. + const handler = renderHandler(); + const occurrences = ( + handler.match(/you@your-domain\.primitive\.email/g) ?? [] + ).length; + expect(occurrences).toBe(1); + // And the send call references the constant, not the literal. + expect(handler).toContain("from: REPLY_FROM"); + expect(handler).toContain("to: event.email.headers.from ?? REPLY_FROM"); + }); +}); + describe("renderPackageJson", () => { it("is valid JSON and substitutes the function name into the package name", () => { const raw = renderPackageJson("test-fn");