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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
email.ts4.0 KB · 132 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
/**
 * Email sending — provider-pluggable, never-throws.
 *
 * Providers:
 *   log     — writes a formatted message to stderr (default, dev-safe)
 *   resend  — POSTs to api.resend.com using RESEND_API_KEY
 *
 * Configured via:
 *   EMAIL_PROVIDER=log|resend
 *   EMAIL_FROM="gluecron <no-reply@gluecron.app>"
 *   RESEND_API_KEY=...
 *   APP_BASE_URL=https://gluecron.com
 *
 * Contract: sendEmail() must never reject. Failures are logged and swallowed
 * so a downed email provider never breaks the primary request path. Callers
 * await the returned promise to preserve ordering but may ignore the result.
 */

import { config } from "./config";

export interface EmailMessage {
  to: string;
  subject: string;
  text: string;
  html?: string;
}

export interface EmailResult {
  ok: boolean;
  provider: "log" | "resend" | "none";
  skipped?: string;
  error?: string;
  id?: string;
}

function looksLikeEmail(s: string): boolean {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s.trim());
}

function renderPlainFallback(text: string): string {
  // Minimal HTML fallback when caller didn't supply one.
  const escaped = text
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;");
  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>`;
}

async function sendViaResend(msg: EmailMessage): Promise<EmailResult> {
  if (!config.resendApiKey) {
    return { ok: false, provider: "resend", skipped: "RESEND_API_KEY unset" };
  }
  try {
    const res = await fetch("https://api.resend.com/emails", {
      method: "POST",
      headers: {
        authorization: `Bearer ${config.resendApiKey}`,
        "content-type": "application/json",
      },
      body: JSON.stringify({
        from: config.emailFrom,
        to: [msg.to],
        subject: msg.subject,
        text: msg.text,
        html: msg.html || renderPlainFallback(msg.text),
      }),
    });
    if (!res.ok) {
      const body = await res.text();
      return {
        ok: false,
        provider: "resend",
        error: `resend ${res.status}: ${body.slice(0, 200)}`,
      };
    }
    const body = (await res.json().catch(() => ({}))) as { id?: string };
    return { ok: true, provider: "resend", id: body.id };
  } catch (err) {
    return {
      ok: false,
      provider: "resend",
      error: String((err as Error)?.message || err),
    };
  }
}

function sendViaLog(msg: EmailMessage): EmailResult {
  // Structured, grep-able log. Written to stderr so prod log collectors pick it up.
  console.error(
    `[email:log] to=${msg.to} subject=${JSON.stringify(msg.subject)}\n` +
      msg.text.split("\n").map((l) => "  " + l).join("\n")
  );
  return { ok: true, provider: "log" };
}

/**
 * Send an email. Always resolves — never throws, never rejects.
 * Returns { ok, provider, ... } so callers can surface errors in admin UIs
 * without having to wrap in try/catch.
 */
export async function sendEmail(msg: EmailMessage): Promise<EmailResult> {
  if (!msg.to || !looksLikeEmail(msg.to)) {
    return { ok: false, provider: "none", skipped: "invalid recipient" };
  }
  if (!msg.subject || !msg.text) {
    return { ok: false, provider: "none", skipped: "missing subject or body" };
  }
  try {
    if (config.emailProvider === "resend") {
      return await sendViaResend(msg);
    }
    return sendViaLog(msg);
  } catch (err) {
    // Defence-in-depth — provider handlers already swallow, but just in case.
    return {
      ok: false,
      provider: config.emailProvider,
      error: String((err as Error)?.message || err),
    };
  }
}

/**
 * Build a fully-qualified URL from a path, using APP_BASE_URL.
 * Safe with relative or absolute inputs.
 */
export function absoluteUrl(pathOrUrl: string | undefined | null): string {
  if (!pathOrUrl) return config.appBaseUrl;
  if (/^https?:\/\//i.test(pathOrUrl)) return pathOrUrl;
  const suffix = pathOrUrl.startsWith("/") ? pathOrUrl : "/" + pathOrUrl;
  return config.appBaseUrl + suffix;
}