Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

email.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

email.tsBlame132 lines · 1 contributor
24cf2caClaude1/**
2 * Email sending — provider-pluggable, never-throws.
3 *
4 * Providers:
5 * log — writes a formatted message to stderr (default, dev-safe)
6 * resend — POSTs to api.resend.com using RESEND_API_KEY
7 *
8 * Configured via:
9 * EMAIL_PROVIDER=log|resend
10 * EMAIL_FROM="gluecron <no-reply@gluecron.app>"
11 * RESEND_API_KEY=...
12 * APP_BASE_URL=https://gluecron.com
13 *
14 * Contract: sendEmail() must never reject. Failures are logged and swallowed
15 * so a downed email provider never breaks the primary request path. Callers
16 * await the returned promise to preserve ordering but may ignore the result.
17 */
18
19import { config } from "./config";
20
21export interface EmailMessage {
22 to: string;
23 subject: string;
24 text: string;
25 html?: string;
26}
27
28export interface EmailResult {
29 ok: boolean;
30 provider: "log" | "resend" | "none";
31 skipped?: string;
32 error?: string;
33 id?: string;
34}
35
36function looksLikeEmail(s: string): boolean {
37 return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
38}
39
40function renderPlainFallback(text: string): string {
41 // Minimal HTML fallback when caller didn't supply one.
42 const escaped = text
43 .replace(/&/g, "&amp;")
44 .replace(/</g, "&lt;")
45 .replace(/>/g, "&gt;");
46 return `<pre style="font-family:ui-monospace,SF-Mono,Menlo,monospace;font-size:13px;white-space:pre-wrap;color:#c9d1d9;background:#0d1117;padding:16px;border-radius:6px">${escaped}</pre>`;
47}
48
49async function sendViaResend(msg: EmailMessage): Promise<EmailResult> {
50 if (!config.resendApiKey) {
51 return { ok: false, provider: "resend", skipped: "RESEND_API_KEY unset" };
52 }
53 try {
54 const res = await fetch("https://api.resend.com/emails", {
55 method: "POST",
56 headers: {
57 authorization: `Bearer ${config.resendApiKey}`,
58 "content-type": "application/json",
59 },
60 body: JSON.stringify({
61 from: config.emailFrom,
62 to: [msg.to],
63 subject: msg.subject,
64 text: msg.text,
65 html: msg.html || renderPlainFallback(msg.text),
66 }),
67 });
68 if (!res.ok) {
69 const body = await res.text();
70 return {
71 ok: false,
72 provider: "resend",
73 error: `resend ${res.status}: ${body.slice(0, 200)}`,
74 };
75 }
76 const body = (await res.json().catch(() => ({}))) as { id?: string };
77 return { ok: true, provider: "resend", id: body.id };
78 } catch (err) {
79 return {
80 ok: false,
81 provider: "resend",
82 error: String((err as Error)?.message || err),
83 };
84 }
85}
86
87function sendViaLog(msg: EmailMessage): EmailResult {
88 // Structured, grep-able log. Written to stderr so prod log collectors pick it up.
89 console.error(
90 `[email:log] to=${msg.to} subject=${JSON.stringify(msg.subject)}\n` +
91 msg.text.split("\n").map((l) => " " + l).join("\n")
92 );
93 return { ok: true, provider: "log" };
94}
95
96/**
97 * Send an email. Always resolves — never throws, never rejects.
98 * Returns { ok, provider, ... } so callers can surface errors in admin UIs
99 * without having to wrap in try/catch.
100 */
101export async function sendEmail(msg: EmailMessage): Promise<EmailResult> {
102 if (!msg.to || !looksLikeEmail(msg.to)) {
103 return { ok: false, provider: "none", skipped: "invalid recipient" };
104 }
105 if (!msg.subject || !msg.text) {
106 return { ok: false, provider: "none", skipped: "missing subject or body" };
107 }
108 try {
109 if (config.emailProvider === "resend") {
110 return await sendViaResend(msg);
111 }
112 return sendViaLog(msg);
113 } catch (err) {
114 // Defence-in-depth — provider handlers already swallow, but just in case.
115 return {
116 ok: false,
117 provider: config.emailProvider,
118 error: String((err as Error)?.message || err),
119 };
120 }
121}
122
123/**
124 * Build a fully-qualified URL from a path, using APP_BASE_URL.
125 * Safe with relative or absolute inputs.
126 */
127export function absoluteUrl(pathOrUrl: string | undefined | null): string {
128 if (!pathOrUrl) return config.appBaseUrl;
129 if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
130 const suffix = pathOrUrl.startsWith("/") ? pathOrUrl : "/" + pathOrUrl;
131 return config.appBaseUrl + suffix;
132}