CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(email): the estate rail — EMAIL_PROVIDER=http delivers via owned infrastructure #5539

Merged⚡ AI-generatedXSccantynz wants to mergefeat/email-estate-railmainopened 6d ago
4 changed files+130−4
Modified.env.example+7−0View fileUnifiedSplit
359359# paging). Replaces per-repo scheduled GitHub workflow "heartbeats".
360360# Production: https://vapron.ai/,https://alecrae.com/,https://davenroe.com/,https://www.zoobicon.com/,https://gatetest.ai/
361361ESTATE_WATCH_URLS=
362
363# ── The estate email rail (EMAIL_PROVIDER=http, 2026-08-26) ──────────────
364# POST endpoint on owned infrastructure (Vapron's MTA in production) that
365# accepts {from,to,subject,text,html} with EMAIL_HTTP_TOKEN as bearer and
366# delivers. Owner directive: every notification travels our own rails.
367EMAIL_HTTP_URL=
368EMAIL_HTTP_TOKEN=
Addedsrc/__tests__/email-estate-rail.test.ts+48−0View fileUnifiedSplit
1/**
2 * The estate email rail (EMAIL_PROVIDER=http) — owner directive
3 * 2026-08-26: every notification travels our own infrastructure.
4 */
5import { describe, expect, test } from "bun:test";
6import { sendViaHttp } from "../lib/email";
7
8function capture(status = 200, body = '{"id":"est_1"}') {
9 const calls: Array<{ url: string; init: RequestInit }> = [];
10 const fetchImpl = (async (url: RequestInfo | URL, init?: RequestInit) => {
11 calls.push({ url: String(url), init: init! });
12 return new Response(body, { status });
13 }) as typeof fetch;
14 return { calls, fetchImpl };
15}
16
17const MSG = { to: "dev@example.com", subject: "hi", text: "body" };
18
19describe("sendViaHttp", () => {
20 test("unset URL skips honestly, never throws", async () => {
21 const r = await sendViaHttp(MSG, { url: "" });
22 expect(r).toEqual({ ok: false, provider: "http", skipped: "EMAIL_HTTP_URL unset" });
23 });
24
25 test("POSTs the contract shape with bearer auth", async () => {
26 const { calls, fetchImpl } = capture();
27 const r = await sendViaHttp(MSG, { url: "https://rail.test/send", token: "tok", fetchImpl });
28 expect(r.ok).toBe(true);
29 expect(r.provider).toBe("http");
30 expect(r.id).toBe("est_1");
31 const sent = JSON.parse(String(calls[0].init.body));
32 expect(sent.to).toBe("dev@example.com");
33 expect(sent.subject).toBe("hi");
34 expect(sent.html).toContain("body"); // plain fallback generated
35 expect((calls[0].init.headers as Record<string, string>).authorization).toBe("Bearer tok");
36 });
37
38 test("non-2xx and network failures return ok:false with detail — never throw", async () => {
39 const { fetchImpl } = capture(503, "down");
40 const r = await sendViaHttp(MSG, { url: "https://rail.test/send", fetchImpl });
41 expect(r.ok).toBe(false);
42 expect(r.error).toContain("503");
43 const boom = (async () => { throw new Error("net down"); }) as unknown as typeof fetch;
44 const r2 = await sendViaHttp(MSG, { url: "https://rail.test/send", fetchImpl: boom });
45 expect(r2.ok).toBe(false);
46 expect(r2.error).toContain("net down");
47 });
48});
Modifiedsrc/lib/config.ts+16−2View fileUnifiedSplit
9292 get anthropicApiKey() {
9393 return process.env.ANTHROPIC_API_KEY || "";
9494 },
95 /** Email provider: "log" (dev, writes to stderr) or "resend" (HTTPS). */
95 /** Email provider: "log" (dev), "resend", or "http" (the estate rail). */
9696 get emailProvider() {
9797 const v = (process.env.EMAIL_PROVIDER || "log").toLowerCase();
98 return v === "resend" ? "resend" : "log";
98 if (v === "resend") return "resend" as const;
99 if (v === "http") return "http" as const;
100 return "log" as const;
99101 },
100102 /** "From" address for outbound email. */
101103 get emailFrom() {
105107 get resendApiKey() {
106108 return process.env.RESEND_API_KEY || "";
107109 },
110 /**
111 * The estate rail (EMAIL_PROVIDER=http): a POST endpoint on owned
112 * infrastructure — Vapron's MTA in production — that accepts
113 * {from,to,subject,text,html} and delivers. Owner directive 2026-08-26:
114 * every notification the platform sends travels our own rails.
115 */
116 get emailHttpUrl() {
117 return process.env.EMAIL_HTTP_URL || "";
118 },
119 get emailHttpToken() {
120 return process.env.EMAIL_HTTP_TOKEN || "";
121 },
108122 /** Canonical base URL for outbound links in emails + webhooks. */
109123 /** SSH server port. 0 disables SSH (default 2222 in dev, 22 in prod via SSH_PORT). */
110124 get sshPort() {
Modifiedsrc/lib/email.ts+59−2View fileUnifiedSplit
44 * Providers:
55 * log — writes a formatted message to stderr (default, dev-safe)
66 * resend — POSTs to api.resend.com using RESEND_API_KEY
7 * http — POSTs to EMAIL_HTTP_URL with EMAIL_HTTP_TOKEN as bearer.
8 * Built 2026-08-26 for the estate rail (owner directive: all
9 * notifications travel our own infrastructure — Vapron's MTA
10 * exposes the receiving endpoint). Contract:
11 * POST { from, to, subject, text, html } → 2xx = accepted.
12 * Any host implementing that shape works; nothing here is
13 * Vapron-specific.
714 *
815 * Configured via:
9 * EMAIL_PROVIDER=log|resend
16 * EMAIL_PROVIDER=log|resend|http
1017 * EMAIL_FROM="gluecron <no-reply@gluecron.app>"
1118 * RESEND_API_KEY=...
19 * EMAIL_HTTP_URL=... EMAIL_HTTP_TOKEN=...
1220 * APP_BASE_URL=https://gluecron.com
1321 *
1422 * Contract: sendEmail() must never reject. Failures are logged and swallowed
2735
2836export interface EmailResult {
2937 ok: boolean;
30 provider: "log" | "resend" | "none";
38 provider: "log" | "resend" | "http" | "none";
3139 skipped?: string;
3240 error?: string;
3341 id?: string;
96104 return { ok: true, provider: "log" };
97105}
98106
107/**
108 * The estate rail (EMAIL_PROVIDER=http). Same never-throw contract and
109 * timeout discipline as resend; DI seam for tests via deps.
110 */
111export async function sendViaHttp(
112 msg: EmailMessage,
113 deps: { url?: string; token?: string; fetchImpl?: typeof fetch } = {}
114): Promise<EmailResult> {
115 const url = deps.url ?? config.emailHttpUrl;
116 const token = deps.token ?? config.emailHttpToken;
117 if (!url) return { ok: false, provider: "http", skipped: "EMAIL_HTTP_URL unset" };
118 try {
119 const res = await (deps.fetchImpl ?? fetch)(url, {
120 method: "POST",
121 headers: {
122 "content-type": "application/json",
123 ...(token ? { authorization: `Bearer ${token}` } : {}),
124 },
125 signal: AbortSignal.timeout(15_000),
126 body: JSON.stringify({
127 from: config.emailFrom,
128 to: msg.to,
129 subject: msg.subject,
130 text: msg.text,
131 html: msg.html || renderPlainFallback(msg.text),
132 }),
133 });
134 if (!res.ok) {
135 const body = await res.text().catch(() => "");
136 return {
137 ok: false,
138 provider: "http",
139 error: `estate rail ${res.status}: ${body.slice(0, 200)}`,
140 };
141 }
142 const body = (await res.json().catch(() => ({}))) as { id?: string };
143 return { ok: true, provider: "http", id: body.id };
144 } catch (err) {
145 return {
146 ok: false,
147 provider: "http",
148 error: String((err as Error)?.message || err),
149 };
150 }
151}
152
99153/**
100154 * Send an email. Always resolves — never throws, never rejects.
101155 * Returns { ok, provider, ... } so callers can surface errors in admin UIs
112166 if (config.emailProvider === "resend") {
113167 return await sendViaResend(msg);
114168 }
169 if (config.emailProvider === "http") {
170 return await sendViaHttp(msg);
171 }
115172 return sendViaLog(msg);
116173 } catch (err) {
117174 // Defence-in-depth — provider handlers already swallow, but just in case.
118175
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts