|
| 1 | +/** |
| 2 | + * Email notification system. |
| 3 | + * |
| 4 | + * Sends transactional emails for platform events via any SMTP provider |
| 5 | + * (Resend, Postmark, SendGrid SMTP, AWS SES, self-hosted Postfix, etc.). |
| 6 | + * |
| 7 | + * Configuration (all optional — emails are silently skipped if not configured): |
| 8 | + * SMTP_HOST — SMTP server hostname (e.g. smtp.resend.com) |
| 9 | + * SMTP_PORT — SMTP port (default: 587) |
| 10 | + * SMTP_SECURE — "true" for TLS on port 465 |
| 11 | + * SMTP_USER — SMTP username |
| 12 | + * SMTP_PASS — SMTP password / API key |
| 13 | + * EMAIL_FROM — From address (default: noreply@<PUBLIC_DOMAIN>) |
| 14 | + * EMAIL_FROM_NAME — From display name (default: FedHost) |
| 15 | + * |
| 16 | + * Events: |
| 17 | + * - deploy.success — site deployed successfully |
| 18 | + * - deploy.failed — deployment failed |
| 19 | + * - cert.expiring — TLS certificate expiring in <30 days |
| 20 | + * - cert.renewed — TLS certificate renewed |
| 21 | + * - node.offline — federation node went offline |
| 22 | + * - invitation — user invited to a site |
| 23 | + * - site.deleted — site deleted (confirmation) |
| 24 | + */ |
| 25 | + |
| 26 | +import nodemailer, { type Transporter } from "nodemailer"; |
| 27 | +import logger from "./logger"; |
| 28 | + |
| 29 | +// ── Transport ────────────────────────────────────────────────────────────────── |
| 30 | + |
| 31 | +let transporter: Transporter | null = null; |
| 32 | + |
| 33 | +function getTransporter(): Transporter | null { |
| 34 | + if (transporter) return transporter; |
| 35 | + |
| 36 | + const host = process.env.SMTP_HOST; |
| 37 | + if (!host) return null; // email not configured — all sends are no-ops |
| 38 | + |
| 39 | + const port = parseInt(process.env.SMTP_PORT ?? "587", 10); |
| 40 | + const secure = process.env.SMTP_SECURE === "true"; |
| 41 | + |
| 42 | + transporter = nodemailer.createTransport({ |
| 43 | + host, |
| 44 | + port, |
| 45 | + secure, |
| 46 | + auth: process.env.SMTP_USER ? { |
| 47 | + user: process.env.SMTP_USER, |
| 48 | + pass: process.env.SMTP_PASS ?? "", |
| 49 | + } : undefined, |
| 50 | + pool: true, |
| 51 | + maxConnections: 5, |
| 52 | + maxMessages: 100, |
| 53 | + rateDelta: 1000, |
| 54 | + rateLimit: 10, |
| 55 | + }); |
| 56 | + |
| 57 | + return transporter; |
| 58 | +} |
| 59 | + |
| 60 | +function fromAddress(): string { |
| 61 | + const name = process.env.EMAIL_FROM_NAME ?? "FedHost"; |
| 62 | + const domain = process.env.PUBLIC_DOMAIN ?? "localhost"; |
| 63 | + const addr = process.env.EMAIL_FROM ?? `noreply@${domain}`; |
| 64 | + return `"${name}" <${addr}>`; |
| 65 | +} |
| 66 | + |
| 67 | +async function sendMail(opts: { to: string; subject: string; html: string; text: string }): Promise<boolean> { |
| 68 | + const t = getTransporter(); |
| 69 | + if (!t) return false; // silently skip — SMTP not configured |
| 70 | + |
| 71 | + try { |
| 72 | + await t.sendMail({ from: fromAddress(), ...opts }); |
| 73 | + logger.info({ to: opts.to, subject: opts.subject }, "[email] Sent"); |
| 74 | + return true; |
| 75 | + } catch (err) { |
| 76 | + logger.error({ err, to: opts.to, subject: opts.subject }, "[email] Failed to send"); |
| 77 | + return false; |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | +// ── HTML layout ─────────────────────────────────────────────────────────────── |
| 82 | + |
| 83 | +function layout(content: string, title: string): string { |
| 84 | + return `<!DOCTYPE html> |
| 85 | +<html lang="en"> |
| 86 | +<head> |
| 87 | +<meta charset="UTF-8"> |
| 88 | +<meta name="viewport" content="width=device-width,initial-scale=1"> |
| 89 | +<title>${title}</title> |
| 90 | +<style> |
| 91 | + body { font-family: system-ui, -apple-system, sans-serif; background: #0a0a0f; color: #e4e4f0; margin: 0; padding: 40px 20px; } |
| 92 | + .wrap { max-width: 560px; margin: 0 auto; } |
| 93 | + .header { margin-bottom: 32px; } |
| 94 | + .logo { font-size: 1.4rem; font-weight: 800; color: #00e5ff; letter-spacing: -0.5px; } |
| 95 | + .card { background: #12121a; border: 1px solid rgba(255,255,255,.08); border-radius: 16px; padding: 32px; margin-bottom: 24px; } |
| 96 | + h1 { font-size: 1.3rem; font-weight: 700; margin: 0 0 8px; color: #fff; } |
| 97 | + p { margin: 0 0 16px; color: #9ca3af; line-height: 1.6; font-size: 0.9rem; } |
| 98 | + .btn { display: inline-block; padding: 12px 24px; background: #00e5ff; color: #000; text-decoration: none; border-radius: 8px; font-weight: 700; font-size: 0.9rem; } |
| 99 | + .meta { font-size: 0.8rem; color: #4b5563; margin-top: 8px; } |
| 100 | + .badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: 0.75rem; font-weight: 600; } |
| 101 | + .badge-green { background: rgba(34,197,94,.15); color: #4ade80; } |
| 102 | + .badge-red { background: rgba(239,68,68,.15); color: #f87171; } |
| 103 | + .badge-yellow { background: rgba(234,179,8,.15); color: #facc15; } |
| 104 | + .footer { font-size: 0.78rem; color: #374151; text-align: center; margin-top: 32px; } |
| 105 | +</style> |
| 106 | +</head> |
| 107 | +<body> |
| 108 | +<div class="wrap"> |
| 109 | + <div class="header"><span class="logo">⚡ FedHost</span></div> |
| 110 | + ${content} |
| 111 | + <div class="footer">This email was sent by your FedHost node. If you did not expect this email, you can safely ignore it.</div> |
| 112 | +</div> |
| 113 | +</body> |
| 114 | +</html>`; |
| 115 | +} |
| 116 | + |
| 117 | +// ── Email templates ─────────────────────────────────────────────────────────── |
| 118 | + |
| 119 | +export async function emailDeploySuccess(opts: { |
| 120 | + to: string; |
| 121 | + siteName: string; |
| 122 | + domain: string; |
| 123 | + version: number; |
| 124 | + fileCount: number; |
| 125 | + deployedAt: string; |
| 126 | +}) { |
| 127 | + const subject = `✅ ${opts.siteName} deployed successfully`; |
| 128 | + const html = layout(` |
| 129 | + <div class="card"> |
| 130 | + <h1>Deployment successful</h1> |
| 131 | + <p><strong style="color:#fff">${opts.siteName}</strong> (${opts.domain}) was deployed successfully.</p> |
| 132 | + <p class="meta"> |
| 133 | + <span class="badge badge-green">v${opts.version}</span> |
| 134 | + ${opts.fileCount} files · ${opts.deployedAt} |
| 135 | + </p> |
| 136 | + <br> |
| 137 | + <a href="https://${opts.domain}" class="btn">View Live Site →</a> |
| 138 | + </div> |
| 139 | + `, subject); |
| 140 | + const text = `${opts.siteName} (${opts.domain}) deployed successfully.\nVersion: ${opts.version} · ${opts.fileCount} files\nView: https://${opts.domain}`; |
| 141 | + return sendMail({ to: opts.to, subject, html, text }); |
| 142 | +} |
| 143 | + |
| 144 | +export async function emailDeployFailed(opts: { |
| 145 | + to: string; |
| 146 | + siteName: string; |
| 147 | + domain: string; |
| 148 | + error: string; |
| 149 | +}) { |
| 150 | + const subject = `❌ Deployment failed for ${opts.siteName}`; |
| 151 | + const html = layout(` |
| 152 | + <div class="card"> |
| 153 | + <h1>Deployment failed</h1> |
| 154 | + <p><strong style="color:#fff">${opts.siteName}</strong> (${opts.domain}) failed to deploy.</p> |
| 155 | + <p><strong style="color:#f87171">Error:</strong> <code style="background:#1a1a26;padding:2px 6px;border-radius:4px;font-size:.85rem">${opts.error}</code></p> |
| 156 | + <p>Check your deployment logs and try again. If the problem persists, contact your node operator.</p> |
| 157 | + </div> |
| 158 | + `, subject); |
| 159 | + const text = `${opts.siteName} failed to deploy.\nError: ${opts.error}`; |
| 160 | + return sendMail({ to: opts.to, subject, html, text }); |
| 161 | +} |
| 162 | + |
| 163 | +export async function emailCertExpiring(opts: { |
| 164 | + to: string; |
| 165 | + domain: string; |
| 166 | + daysLeft: number; |
| 167 | + expiresAt: string; |
| 168 | +}) { |
| 169 | + const subject = `⚠️ TLS certificate for ${opts.domain} expires in ${opts.daysLeft} days`; |
| 170 | + const html = layout(` |
| 171 | + <div class="card"> |
| 172 | + <h1>Certificate expiring soon</h1> |
| 173 | + <p>The TLS certificate for <strong style="color:#fff">${opts.domain}</strong> expires in <strong style="color:#facc15">${opts.daysLeft} days</strong> (${opts.expiresAt}).</p> |
| 174 | + <p>If automatic renewal is enabled, it will renew within the next 24 hours. If not, renew manually via your node's admin panel or by running certbot.</p> |
| 175 | + </div> |
| 176 | + `, subject); |
| 177 | + const text = `TLS certificate for ${opts.domain} expires in ${opts.daysLeft} days (${opts.expiresAt}).`; |
| 178 | + return sendMail({ to: opts.to, subject, html, text }); |
| 179 | +} |
| 180 | + |
| 181 | +export async function emailCertRenewed(opts: { |
| 182 | + to: string; |
| 183 | + domain: string; |
| 184 | + expiresAt: string; |
| 185 | +}) { |
| 186 | + const subject = `🔒 TLS certificate renewed for ${opts.domain}`; |
| 187 | + const html = layout(` |
| 188 | + <div class="card"> |
| 189 | + <h1>Certificate renewed</h1> |
| 190 | + <p>The TLS certificate for <strong style="color:#fff">${opts.domain}</strong> was renewed successfully.</p> |
| 191 | + <p class="meta">Valid until: ${opts.expiresAt}</p> |
| 192 | + </div> |
| 193 | + `, subject); |
| 194 | + const text = `TLS certificate for ${opts.domain} renewed. Valid until ${opts.expiresAt}.`; |
| 195 | + return sendMail({ to: opts.to, subject, html, text }); |
| 196 | +} |
| 197 | + |
| 198 | +export async function emailNodeOffline(opts: { |
| 199 | + to: string; |
| 200 | + nodeName: string; |
| 201 | + nodeDomain: string; |
| 202 | + since: string; |
| 203 | +}) { |
| 204 | + const subject = `🔴 Node offline: ${opts.nodeName}`; |
| 205 | + const html = layout(` |
| 206 | + <div class="card"> |
| 207 | + <h1>Federation node offline</h1> |
| 208 | + <p><strong style="color:#fff">${opts.nodeName}</strong> (${opts.nodeDomain}) has been unreachable since <strong style="color:#f87171">${opts.since}</strong>.</p> |
| 209 | + <p>Sites hosted on this node may be unavailable. The node will be automatically removed from the federation after extended downtime.</p> |
| 210 | + </div> |
| 211 | + `, subject); |
| 212 | + const text = `Node ${opts.nodeName} (${opts.nodeDomain}) has been offline since ${opts.since}.`; |
| 213 | + return sendMail({ to: opts.to, subject, html, text }); |
| 214 | +} |
| 215 | + |
| 216 | +export async function emailInvitation(opts: { |
| 217 | + to: string; |
| 218 | + inviterName: string; |
| 219 | + siteName: string; |
| 220 | + domain: string; |
| 221 | + role: string; |
| 222 | + acceptUrl: string; |
| 223 | +}) { |
| 224 | + const subject = `You've been invited to collaborate on ${opts.siteName}`; |
| 225 | + const html = layout(` |
| 226 | + <div class="card"> |
| 227 | + <h1>You've been invited</h1> |
| 228 | + <p><strong style="color:#fff">${opts.inviterName}</strong> has invited you to collaborate on <strong style="color:#fff">${opts.siteName}</strong> (${opts.domain}) as a <strong style="color:#00e5ff">${opts.role}</strong>.</p> |
| 229 | + <br> |
| 230 | + <a href="${opts.acceptUrl}" class="btn">Accept Invitation →</a> |
| 231 | + <p class="meta" style="margin-top:16px">This invitation expires in 7 days. If you don't have a FedHost account, you'll be prompted to create one.</p> |
| 232 | + </div> |
| 233 | + `, subject); |
| 234 | + const text = `${opts.inviterName} invited you to ${opts.siteName} (${opts.domain}) as ${opts.role}.\nAccept: ${opts.acceptUrl}`; |
| 235 | + return sendMail({ to: opts.to, subject, html, text }); |
| 236 | +} |
| 237 | + |
| 238 | +export async function emailSiteDeleted(opts: { |
| 239 | + to: string; |
| 240 | + siteName: string; |
| 241 | + domain: string; |
| 242 | + deletedAt: string; |
| 243 | +}) { |
| 244 | + const subject = `Site deleted: ${opts.domain}`; |
| 245 | + const html = layout(` |
| 246 | + <div class="card"> |
| 247 | + <h1>Site deleted</h1> |
| 248 | + <p><strong style="color:#fff">${opts.siteName}</strong> (${opts.domain}) was permanently deleted on ${opts.deletedAt}.</p> |
| 249 | + <p>All associated files, deployments, and analytics data have been removed. This cannot be undone.</p> |
| 250 | + </div> |
| 251 | + `, subject); |
| 252 | + const text = `${opts.siteName} (${opts.domain}) was deleted on ${opts.deletedAt}.`; |
| 253 | + return sendMail({ to: opts.to, subject, html, text }); |
| 254 | +} |
0 commit comments