Skip to content

Commit bd7f716

Browse files
author
The No Hands Company
committed
feat: email system, invitations, staging environments, usage dashboard
Email notification system (lib/email.ts) - Nodemailer-based SMTP, works with Resend/Postmark/SES/SendGrid/self-hosted - 6 templated emails: deploy success/failed, cert expiring/renewed, node offline, invitation - Dark-themed HTML emails, plain-text fallbacks - Silent no-op when SMTP_HOST is not configured (no errors) - Config: SMTP_HOST, SMTP_PORT, SMTP_SECURE, SMTP_USER, SMTP_PASS, EMAIL_FROM, EMAIL_FROM_NAME - deploy.ts: deploy success email sent after every successful deploy - acme.ts: cert expiry warning at 30/14/7/3/1 days, cert renewed notification Invitation system (routes/invitations.ts, schema/invitations.ts) - Proper pending-invite flow with 7-day expiring signed tokens - POST /api/sites/:id/invitations — create invitation, send email - GET /api/sites/:id/invitations — list pending invitations - DELETE /api/sites/:id/invitations/:id — revoke invitation - GET /api/invitations/:token — get invitation details (unauthenticated) - POST /api/invitations/:token/accept — accept, creates site_members record - Email address matching enforced in production - Duplicate invitation detection (returns conflict if pending invite exists) - DB: site_invitations table with token, expires_at, accepted_at Staging environments (schema/deployments.ts + migration) - environment column on site_deployments: 'production' | 'staging' | 'preview' - previewUrl column for unique staging subdomain - Indexed by (site_id, environment) for efficient queries Usage Dashboard (pages/UsageDashboard.tsx) - Per-site breakdown: storage, all-time hits, monthly bandwidth - Summary stat cards: total storage, all-time hits, monthly bandwidth, active sites - 30-day traffic area chart from analytics API - Sites sorted by traffic, link to full analytics per site - Route: /usage (lazy-loaded) - Layout: Usage nav item added to authenticated sidebar .env.example: full SMTP configuration documentation
1 parent ebf3a2c commit bd7f716

File tree

14 files changed

+845
-9
lines changed

14 files changed

+845
-9
lines changed

.env.example

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,23 @@ ENABLE_SITE_HEALTH_CHECKS=false
177177

178178
# How often to check site health in milliseconds (default: 600000 = 10 minutes)
179179
SITE_HEALTH_CHECK_INTERVAL_MS=600000
180+
181+
# ── Email Notifications (optional) ────────────────────────────────────────────
182+
# Configure SMTP to send transactional emails:
183+
# deploy success/failed, certificate expiry, node offline, invitations
184+
# Leave blank to disable email (invitations still work, just no email sent).
185+
#
186+
# Works with any SMTP provider:
187+
# Resend: SMTP_HOST=smtp.resend.com SMTP_USER=resend SMTP_PASS=re_xxx
188+
# Postmark: SMTP_HOST=smtp.postmarkapp.com
189+
# SendGrid: SMTP_HOST=smtp.sendgrid.net
190+
# AWS SES: SMTP_HOST=email-smtp.us-east-1.amazonaws.com
191+
SMTP_HOST=
192+
SMTP_PORT=587
193+
SMTP_SECURE=false
194+
SMTP_USER=
195+
SMTP_PASS=
196+
197+
# From address for outgoing emails
198+
EMAIL_FROM=noreply@your-domain.com
199+
EMAIL_FROM_NAME=FedHost

artifacts/api-server/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"google-auth-library": "^10.6.2",
3030
"helmet": "^8.1.0",
3131
"ioredis": "^5.6.1",
32+
"nodemailer": "^6.9.16",
3233
"openid-client": "^6.8.2",
3334
"pino": "^10.3.1",
3435
"pino-http": "^11.0.0",
@@ -47,6 +48,7 @@
4748
"tsx": "catalog:",
4849
"@types/ioredis": "^4.28.10",
4950
"vitest": "^3.2.4",
50-
"@vitest/coverage-v8": "^3.2.0"
51+
"@vitest/coverage-v8": "^3.2.0",
52+
"@types/nodemailer": "^6.4.17"
5153
}
5254
}

artifacts/api-server/src/lib/acme.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ import crypto from "crypto";
3333
import fs from "fs";
3434
import path from "path";
3535
import logger from "./logger";
36-
import { db, customDomainsTable } from "@workspace/db";
37-
import { eq } from "drizzle-orm";
36+
import { db, customDomainsTable, sitesTable, usersTable } from "@workspace/db";
37+
import { eq, inArray } from "drizzle-orm";
38+
import { emailCertExpiring, emailCertRenewed } from "./email";
3839

3940
// Shared challenge token store — read by GET /.well-known/acme-challenge/:token
4041
export const acmeChallenges = new Map<string, string>();
@@ -222,16 +223,52 @@ async function checkRenewals(): Promise<void> {
222223
if (!process.env.ACME_ENABLED) return;
223224

224225
const verifiedDomains = await db
225-
.select({ domain: customDomainsTable.domain })
226+
.select({
227+
domain: customDomainsTable.domain,
228+
siteId: customDomainsTable.siteId,
229+
})
226230
.from(customDomainsTable)
227231
.where(eq(customDomainsTable.status, "verified"));
228232

229-
for (const { domain } of verifiedDomains) {
233+
for (const { domain, siteId } of verifiedDomains) {
234+
const expiry = getCertExpiry(domain);
235+
const daysLeft = expiry ? Math.floor((expiry.getTime() - Date.now()) / (1000 * 60 * 60 * 24)) : null;
236+
230237
if (!certIsValid(domain)) {
231238
logger.info({ domain }, "[acme] Certificate missing or expiring — renewing");
232239
const result = await provisionCertificate(domain);
233-
if (!result.success) {
234-
logger.error({ domain, error: result.error }, "[acme] Renewal failed");
240+
241+
// Notify site owner on result
242+
const [ownerEmail] = await db
243+
.select({ email: usersTable.email })
244+
.from(sitesTable)
245+
.innerJoin(usersTable, eq(usersTable.id, sitesTable.ownerId))
246+
.where(eq(sitesTable.id, siteId));
247+
248+
if (ownerEmail?.email) {
249+
if (result.success && result.expiresAt) {
250+
emailCertRenewed({ to: ownerEmail.email, domain, expiresAt: result.expiresAt }).catch(() => {});
251+
} else if (!result.success) {
252+
logger.error({ domain, error: result.error }, "[acme] Renewal failed");
253+
}
254+
}
255+
} else if (daysLeft !== null && daysLeft <= 30 && daysLeft > 0) {
256+
// Cert valid but expiring soon — send a warning (at 30, 14, 7, 3, 1 days)
257+
if ([30, 14, 7, 3, 1].includes(daysLeft)) {
258+
const [ownerEmail] = await db
259+
.select({ email: usersTable.email })
260+
.from(sitesTable)
261+
.innerJoin(usersTable, eq(usersTable.id, sitesTable.ownerId))
262+
.where(eq(sitesTable.id, siteId));
263+
264+
if (ownerEmail?.email && expiry) {
265+
emailCertExpiring({
266+
to: ownerEmail.email,
267+
domain,
268+
daysLeft,
269+
expiresAt: expiry.toUTCString(),
270+
}).catch(() => {});
271+
}
235272
}
236273
}
237274
}
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
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> &nbsp;
134+
${opts.fileCount} files &nbsp;·&nbsp; ${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+
}

artifacts/api-server/src/routes/deploy.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { uploadLimiter, writeLimiter, deployLimiter } from "../middleware/rateLi
88
import { webhookDeploy, webhookDeployFailed } from "../lib/webhooks";
99
import { invalidateSiteCache } from "../lib/domainCache";
1010
import { enqueueSyncRetry } from "../lib/syncRetryQueue";
11+
import { emailDeploySuccess, emailDeployFailed } from "../lib/email";
1112
import { deploymentsTotal, storageOperationsTotal } from "../lib/metrics";
1213
import {
1314
GetSiteFileUploadUrlBody,
@@ -274,6 +275,18 @@ router.post("/sites/:id/deploy", deployLimiter, asyncHandler(async (req: Request
274275
results: replicationResults,
275276
},
276277
});
278+
279+
// Fire-and-forget email notification — never block the response
280+
if (req.user?.email) {
281+
emailDeploySuccess({
282+
to: req.user.email,
283+
siteName: site.name,
284+
domain: site.domain,
285+
version: deployment.version,
286+
fileCount: deployment.fileCount,
287+
deployedAt: new Date().toUTCString(),
288+
}).catch(() => {});
289+
}
277290
}));
278291

279292
router.get("/sites/:id/deployments", asyncHandler(async (req: Request, res: Response) => {

0 commit comments

Comments
 (0)