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
31 changes: 28 additions & 3 deletions cli-node/src/oclif/commands/functions-init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
};
}

Expand All @@ -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 <alice@example.com>"). 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
Expand All @@ -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.",
});
Expand Down
57 changes: 57 additions & 0 deletions cli-node/tests/oclif/functions-init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <alice@example.com>").
// 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");
Expand Down
Loading