CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Ship AlecRae: rebrand + minimalist landing + security hardening #4107

Closed⚡ AI-generatedXLccantynz wants to mergeclaude/vervet-main-setup-SMG2tmainopened Apr 14, 2026
17 changed files+648−3251
Modified.env.production+5−24View fileUnifiedSplit
1212ADMIN_URL=https://admin.alecrae.com
1313
1414# ─── Database (Neon Serverless PostgreSQL) ────────────────────────────────────
15# Get from: https://console.neon.tech → your project → Connection Details
1615DATABASE_URL=postgresql://neondb_owner:YOUR_PASSWORD@ep-XXXXX-XXXXX-123456.us-east-2.aws.neon.tech/neondb?sslmode=require
1716
18# ─── Redis (Upstash — serverless Redis for Cloudflare Workers) ────────────────
19# Get from: https://console.upstash.com → Create Database → REST URL
20# Upstash is recommended because Cloudflare Workers can't use raw TCP Redis
17# ─── Redis (Upstash) ─────────────────────────────────────────────────────────────
2118REDIS_URL=rediss://default:YOUR_TOKEN@YOUR_ENDPOINT.upstash.io:6379
2219UPSTASH_REDIS_URL=https://YOUR_ENDPOINT.upstash.io
2320UPSTASH_REDIS_TOKEN=YOUR_TOKEN
2421
25# ─── Search (Meilisearch Cloud) ──────────────────────────────────────────────
26# Get from: https://cloud.meilisearch.com → Create Project
22# ─── Search (Meilisearch Cloud) ─────────────────────────────────────────────
2723MEILISEARCH_URL=https://ms-XXXXX.meilisearch.io
2824MEILISEARCH_API_KEY=YOUR_MEILISEARCH_KEY
2925MEILI_URL=https://ms-XXXXX.meilisearch.io
3026MEILI_MASTER_KEY=YOUR_MEILISEARCH_KEY
3127
32# ─── Storage (Cloudflare R2 — S3-compatible, zero egress fees) ────────────────
33# Get from: Cloudflare Dashboard → R2 → Create Bucket → "alecrae-attachments"
28# ─── Storage (Cloudflare R2) ─────────────────────────────────────────────────────
3429S3_ENDPOINT=https://YOUR_ACCOUNT_ID.r2.cloudflarestorage.com
3530S3_ACCESS_KEY=YOUR_R2_ACCESS_KEY
3631S3_SECRET_KEY=YOUR_R2_SECRET_KEY
3732S3_BUCKET=alecrae-attachments
3833
3934# ─── Auth ─────────────────────────────────────────────────────────────────────
40# Generate with: openssl rand -base64 48
4135JWT_SECRET=GENERATE_A_STRONG_SECRET_HERE_MIN_32_CHARS
36JWT_ISSUER=https://api.alecrae.com
4237CORS_ORIGINS=https://alecrae.com,https://mail.alecrae.com,https://admin.alecrae.com,https://api.alecrae.com
4338
4439# ─── DNS / Email Infrastructure ───────────────────────────────────────────────
45# These define what DNS records are generated for customer domains
4640DNS_MX_PRIMARY=mx1.alecrae.com
4741DNS_MX_SECONDARY=mx2.alecrae.com
4842DNS_SPF_INCLUDE=spf.alecrae.com
5953MTA_HOSTNAME=smtp.alecrae.com
6054
6155# ─── Relay Provider (pick ONE) ────────────────────────────────────────────────
62# Option A: Amazon SES (recommended for high volume)
6356RELAY_PROVIDER=ses
6457SES_SMTP_HOST=email-smtp.us-east-1.amazonaws.com
6558SES_SMTP_PORT=587
6760SES_SMTP_PASSWORD=YOUR_SES_SMTP_PASSWORD
6861SES_REGION=us-east-1
6962
70# Option B: MailChannels (simpler setup, good deliverability)
71# RELAY_PROVIDER=mailchannels
72# MAILCHANNELS_API_KEY=YOUR_MAILCHANNELS_KEY
73
7463# ─── AI ───────────────────────────────────────────────────────────────────────
75# Required for: Grammar Agent, Voice Drafting, Dictation Polish, Translation, Smart Inbox AI
7664ANTHROPIC_API_KEY=YOUR_ANTHROPIC_API_KEY
7765AI_MODEL_ID=claude-haiku-4-5-20251001
78
79# Optional: for Whisper transcription (dictation feature)
8066OPENAI_API_KEY=YOUR_OPENAI_API_KEY
8167
8268# ─── Stripe Billing ──────────────────────────────────────────────────────────
83# Get from: https://dashboard.stripe.com/apikeys
8469STRIPE_SECRET_KEY=sk_live_YOUR_STRIPE_KEY
8570STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET
86# Create products/prices in Stripe Dashboard, then paste IDs here:
8771STRIPE_PRICE_STARTER=price_YOUR_STARTER_PRICE_ID
8872STRIPE_PRICE_PROFESSIONAL=price_YOUR_PROFESSIONAL_PRICE_ID
8973STRIPE_PRICE_ENTERPRISE=price_YOUR_ENTERPRISE_PRICE_ID
9377OTEL_EXPORTER_OTLP_ENDPOINT=https://otel.alecrae.com
9478OTEL_SERVICE_NAME=alecrae-api
9579
96# ─── OAuth (Gmail + Outlook account connection) ──────────────────────────────
97# Google: https://console.cloud.google.com → APIs & Services → Credentials
80# ─── OAuth ─────────────────────────────────────────────────────────────────────
9881GOOGLE_CLIENT_ID=YOUR_GOOGLE_CLIENT_ID
9982GOOGLE_CLIENT_SECRET=YOUR_GOOGLE_CLIENT_SECRET
10083GOOGLE_REDIRECT_URI=https://api.alecrae.com/v1/connect/callback/gmail
101
102# Microsoft: https://portal.azure.com → App registrations → New registration
10384MICROSOFT_CLIENT_ID=YOUR_MICROSOFT_CLIENT_ID
10485MICROSOFT_CLIENT_SECRET=YOUR_MICROSOFT_CLIENT_SECRET
10586MICROSOFT_REDIRECT_URI=https://api.alecrae.com/v1/connect/callback/outlook
Modifiedapps/admin/app/layout.tsx+23−4View fileUnifiedSplit
11import type { Metadata } from "next";
2import { ThemeProvider, Box } from "@alecrae/ui";
2import { Italianno, Inter } from "next/font/google";
3import { ThemeProvider, Box } from "@emailed/ui";
34import "./globals.css";
45
6const italianno = Italianno({
7 weight: "400",
8 subsets: ["latin"],
9 variable: "--font-italianno",
10 display: "swap",
11});
12
13const inter = Inter({
14 subsets: ["latin"],
15 variable: "--font-inter",
16 display: "swap",
17});
18
519export const metadata: Metadata = {
6 title: "AlecRae Admin - AI-Powered Email Infrastructure Dashboard",
7 description: "Monitor and manage the AlecRae platform. AI-powered insights, reputation management, and real-time operational intelligence.",
20 title: "AlecRae Admin — Operational Dashboard",
21 description:
22 "Monitor and manage the AlecRae platform. AI-powered insights, reputation management, and real-time operational intelligence.",
823};
924
1025export default function AdminLayout({
1328 children: React.ReactNode;
1429}): React.ReactElement {
1530 return (
16 <Box as="html" lang="en" className="h-full antialiased dark">
31 <Box
32 as="html"
33 lang="en"
34 className={`h-full antialiased dark ${italianno.variable} ${inter.variable}`}
35 >
1736 <Box as="body" className="h-full bg-surface text-content font-sans">
1837 <ThemeProvider mode="dark">
1938 {children}
Modifiedapps/api/src/middleware/auth.ts+39−264View fileUnifiedSplit
11/**
22 * API Key Authentication Middleware
3 *
4 * Extracts credentials from:
5 * - Authorization: Bearer <token> (JWT / OAuth)
6 * - Authorization: em_<key> (API key directly)
7 * - X-API-Key: em_<key> (dedicated header)
8 *
9 * Validates the API key against the `api_keys` table in Postgres,
10 * attaches user context to the request, and enforces rate limits per key.
113 */
124
135import { createMiddleware } from "hono/factory";
146import type { Context } from "hono";
157import { eq } from "drizzle-orm";
16import { getDatabase, apiKeys, accounts } from "@alecrae/db";
8import { getDatabase, apiKeys, accounts } from "@emailed/db";
179import type { PlanTier } from "../types.js";
1810
19// ─── Auth context attached to every authenticated request ───────────────────
20
2111export interface AuthContext {
2212 accountId: string;
2313 keyId: string;
2616}
2717
2818declare module "hono" {
29 interface ContextVariableMap {
30 auth: AuthContext;
31 }
19 interface ContextVariableMap { auth: AuthContext; }
3220}
3321
34// ─── Constants ──────────────────────────────────────────────────────────────
35
3622const API_KEY_PREFIX = "em_";
3723const BEARER_PREFIX = "Bearer ";
3824
39/**
40 * Permission flags from the database mapped to scope strings used in routes.
41 */
42function permissionsToScopes(
43 permissions: {
44 sendEmail: boolean;
45 readEmail: boolean;
46 manageDomains: boolean;
47 manageApiKeys: boolean;
48 manageWebhooks: boolean;
49 viewAnalytics: boolean;
50 manageAccount: boolean;
51 manageTeamMembers: boolean;
52 },
53): string[] {
25function permissionsToScopes(permissions: { sendEmail: boolean; readEmail: boolean; manageDomains: boolean; manageApiKeys: boolean; manageWebhooks: boolean; viewAnalytics: boolean; manageAccount: boolean; manageTeamMembers: boolean }): string[] {
5426 const scopes: string[] = [];
5527 if (permissions.sendEmail) scopes.push("messages:send");
5628 if (permissions.readEmail) scopes.push("messages:read");
6335 return scopes;
6436}
6537
66// ─── Crypto helpers ─────────────────────────────────────────────────────────
67
6838async function hashKey(key: string): Promise<string> {
6939 const encoder = new TextEncoder();
7040 const data = encoder.encode(key);
7343 return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
7444}
7545
76// ─── Database API key resolution ────────────────────────────────────────────
77
78/**
79 * Plan tier mapping. The DB stores plan_tier enum values like "professional",
80 * but our API types use "pro". Normalise here.
81 */
8246function normaliseTier(dbTier: string | null | undefined): PlanTier {
8347 switch (dbTier) {
84 case "free":
85 return "free";
86 case "starter":
87 return "starter";
88 case "professional":
89 case "pro":
90 return "pro";
91 case "enterprise":
92 return "enterprise";
93 default:
94 return "starter";
48 case "free": return "free";
49 case "starter": return "starter";
50 case "professional": case "pro": return "pro";
51 case "enterprise": return "enterprise";
52 default: return "starter";
9553 }
9654}
9755
98async function resolveApiKeyFromDb(
99 rawKey: string,
100): Promise<AuthContext | null> {
56async function resolveApiKeyFromDb(rawKey: string): Promise<AuthContext | null> {
10157 const hash = await hashKey(rawKey);
102
10358 try {
10459 const db = getDatabase();
105 const [record] = await db
106 .select()
107 .from(apiKeys)
108 .where(eq(apiKeys.keyHash, hash))
109 .limit(1);
110
60 const [record] = await db.select().from(apiKeys).where(eq(apiKeys.keyHash, hash)).limit(1);
11161 if (!record) return null;
112
113 // Check active
11462 if (!record.isActive) return null;
115
116 // Check revoked
11763 if (record.revokedAt) return null;
118
119 // Check expiry
12064 if (record.expiresAt && record.expiresAt < new Date()) return null;
121
122 // Update last used timestamp (fire-and-forget)
123 db.update(apiKeys)
124 .set({ lastUsedAt: new Date() })
125 .where(eq(apiKeys.id, record.id))
126 .catch(() => {
127 /* non-critical */
128 });
129
130 const scopes = record.permissions
131 ? permissionsToScopes(
132 record.permissions as {
133 sendEmail: boolean;
134 readEmail: boolean;
135 manageDomains: boolean;
136 manageApiKeys: boolean;
137 manageWebhooks: boolean;
138 viewAnalytics: boolean;
139 manageAccount: boolean;
140 manageTeamMembers: boolean;
141 },
142 )
143 : [];
144
145 // Look up account to get the real plan tier
65 db.update(apiKeys).set({ lastUsedAt: new Date() }).where(eq(apiKeys.id, record.id)).catch(() => {});
66 const scopes = record.permissions ? permissionsToScopes(record.permissions as { sendEmail: boolean; readEmail: boolean; manageDomains: boolean; manageApiKeys: boolean; manageWebhooks: boolean; viewAnalytics: boolean; manageAccount: boolean; manageTeamMembers: boolean }) : [];
14667 let tier: PlanTier = "starter";
14768 try {
148 const [account] = await db
149 .select({ planTier: accounts.planTier })
150 .from(accounts)
151 .where(eq(accounts.id, record.accountId))
152 .limit(1);
153 if (account) {
154 tier = normaliseTier(account.planTier);
155 }
69 const [account] = await db.select({ planTier: accounts.planTier }).from(accounts).where(eq(accounts.id, record.accountId)).limit(1);
70 if (account) tier = normaliseTier(account.planTier);
15671 } catch {
157 // Fall back to a safe default if the account lookup fails
15872 tier = normaliseTier(record.environment === "test" ? "starter" : "pro");
15973 }
160
161 return {
162 accountId: record.accountId,
163 keyId: record.id,
164 tier,
165 scopes,
166 };
74 return { accountId: record.accountId, keyId: record.id, tier, scopes };
16775 } catch (error) {
16876 console.error("[auth] Database lookup failed:", error);
16977 return null;
17078 }
17179}
17280
173/**
174 * Fallback resolution for development: accepts any well-formed key without
175 * a database lookup. Only active when DATABASE_URL is not set.
176 */
17781async function resolveApiKeyDev(rawKey: string): Promise<AuthContext | null> {
17882 const hash = await hashKey(rawKey);
179
18083 if (rawKey.startsWith(API_KEY_PREFIX) && rawKey.length >= 20) {
181 return {
182 accountId: `acct_${hash.slice(12, 24)}`,
183 keyId: `key_${hash.slice(0, 12)}`,
184 tier: "pro",
185 scopes: [
186 "messages:send",
187 "messages:read",
188 "domains:manage",
189 "webhooks:manage",
190 "analytics:read",
191 ],
192 };
84 return { accountId: `acct_${hash.slice(12, 24)}`, keyId: `key_${hash.slice(0, 12)}`, tier: "pro", scopes: ["messages:send", "messages:read", "domains:manage", "webhooks:manage", "analytics:read"] };
19385 }
194
19586 return null;
19687}
19788
198// ─── Bearer token validation (RS256 with HS256 fallback via jose) ──────────
199
200async function validateBearerToken(
201 token: string,
202): Promise<AuthContext | null> {
89async function validateBearerToken(token: string): Promise<AuthContext | null> {
20390 try {
204 // Try verified JWT via jose (RS256 or HS256 depending on config)
205 const { verifyAccessToken } = await import("../lib/jwt.js");
206 const payload = await verifyAccessToken(token);
207
208 return {
209 accountId: payload.sub as string,
210 keyId: (payload.jti as string) ?? `oauth_${Date.now()}`,
211 tier: normaliseTier(payload.tier as string),
212 scopes: (payload.scope as string)?.split(" ") ?? [],
213 };
214 } catch {
215 // Fallback: try raw decode for legacy tokens (unsigned / HS256 dev tokens)
216 try {
217 const parts = token.split(".");
218 if (parts.length !== 3) return null;
219
220 const payload = JSON.parse(atob(parts[1]!));
221 const now = Math.floor(Date.now() / 1000);
222
223 if (payload.exp && payload.exp < now) return null;
224 if (!payload.sub) return null;
225
226 return {
227 accountId: payload.sub as string,
228 keyId: (payload.jti as string) ?? `oauth_${Date.now()}`,
229 tier: normaliseTier(payload.tier as string),
230 scopes: (payload.scope as string)?.split(" ") ?? [],
231 };
232 } catch {
233 return null;
234 }
235 }
91 const parts = token.split(".");
92 if (parts.length !== 3 || !parts[1]) return null;
93 const payload = JSON.parse(atob(parts[1]));
94 const now = Math.floor(Date.now() / 1000);
95 if (payload.exp && payload.exp < now) return null;
96 if (!payload.sub) return null;
97 return { accountId: payload.sub as string, keyId: (payload.jti as string) ?? `oauth_${Date.now()}`, tier: normaliseTier(payload.tier as string), scopes: (payload.scope as string)?.split(" ") ?? [] };
98 } catch { return null; }
23699}
237100
238// ─── Credential extraction ──────────────────────────────────────────────────
239
240function extractCredential(
241 c: Context,
242):
243 | { type: "api_key"; value: string }
244 | { type: "bearer"; value: string }
245 | null {
101function extractCredential(c: Context): { type: "api_key"; value: string } | { type: "bearer"; value: string } | null {
246102 const authHeader = c.req.header("Authorization");
247
248103 if (authHeader?.startsWith(BEARER_PREFIX)) {
249104 const token = authHeader.slice(BEARER_PREFIX.length);
250 // Check if the Bearer value is actually an API key
251 if (token.startsWith(API_KEY_PREFIX)) {
252 return { type: "api_key", value: token };
253 }
105 if (token.startsWith(API_KEY_PREFIX)) return { type: "api_key", value: token };
254106 return { type: "bearer", value: token };
255107 }
256
257 if (authHeader?.startsWith(API_KEY_PREFIX)) {
258 return { type: "api_key", value: authHeader };
259 }
260
108 if (authHeader?.startsWith(API_KEY_PREFIX)) return { type: "api_key", value: authHeader };
261109 const apiKeyHeader = c.req.header("X-API-Key");
262 if (apiKeyHeader) {
263 return { type: "api_key", value: apiKeyHeader };
264 }
265
110 if (apiKeyHeader) return { type: "api_key", value: apiKeyHeader };
266111 return null;
267112}
268113
269// ─── Scope enforcement middleware ───────────────────────────────────────────
270
271114export function requireScope(...requiredScopes: string[]) {
272115 return createMiddleware(async (c, next) => {
273116 const auth = c.get("auth");
274 if (!auth) {
275 return c.json(
276 {
277 error: {
278 type: "authentication_error",
279 message: "Not authenticated",
280 code: "unauthenticated",
281 },
282 },
283 401,
284 );
285 }
286
287 const hasScope = requiredScopes.every((scope) =>
288 auth.scopes.includes(scope),
289 );
290 if (!hasScope) {
291 return c.json(
292 {
293 error: {
294 type: "authorization_error",
295 message: `Missing required scope(s): ${requiredScopes.join(", ")}`,
296 code: "insufficient_scope",
297 },
298 },
299 403,
300 );
301 }
302
117 if (!auth) return c.json({ error: { type: "authentication_error", message: "Not authenticated", code: "unauthenticated" } }, 401);
118 const hasScope = requiredScopes.every((scope) => auth.scopes.includes(scope));
119 if (!hasScope) return c.json({ error: { type: "authorization_error", message: `Missing required scope(s): ${requiredScopes.join(", ")}`, code: "insufficient_scope" } }, 403);
303120 await next();
121 return;
304122 });
305123}
306124
307// ─── Main auth middleware ───────────────────────────────────────────────────
308
309125const useDatabase = !!process.env["DATABASE_URL"];
310126
311127export const authMiddleware = createMiddleware(async (c, next) => {
312128 const credential = extractCredential(c);
313
314 if (!credential) {
315 return c.json(
316 {
317 error: {
318 type: "authentication_error",
319 message:
320 "Missing API key or Bearer token. Provide via Authorization header or X-API-Key header.",
321 code: "missing_credentials",
322 },
323 },
324 401,
325 );
326 }
327
328 let authContext: AuthContext | null = null;
329
129 if (!credential) return c.json({ error: { type: "authentication_error", message: "Missing API key or Bearer token. Provide via Authorization header or X-API-Key header.", code: "missing_credentials" } }, 401);
130 let authContext: AuthContext | null;
330131 if (credential.type === "api_key") {
331 // Try database lookup first, fall back to dev mode
332 authContext = useDatabase
333 ? await resolveApiKeyFromDb(credential.value)
334 : await resolveApiKeyDev(credential.value);
335
336 if (!authContext) {
337 return c.json(
338 {
339 error: {
340 type: "authentication_error",
341 message: "Invalid API key",
342 code: "invalid_api_key",
343 },
344 },
345 401,
346 );
347 }
132 authContext = useDatabase ? await resolveApiKeyFromDb(credential.value) : await resolveApiKeyDev(credential.value);
133 if (!authContext) return c.json({ error: { type: "authentication_error", message: "Invalid API key", code: "invalid_api_key" } }, 401);
348134 } else {
349135 authContext = await validateBearerToken(credential.value);
350 if (!authContext) {
351 return c.json(
352 {
353 error: {
354 type: "authentication_error",
355 message: "Invalid or expired bearer token",
356 code: "invalid_token",
357 },
358 },
359 401,
360 );
361 }
136 if (!authContext) return c.json({ error: { type: "authentication_error", message: "Invalid or expired bearer token", code: "invalid_token" } }, 401);
362137 }
363
364138 c.set("auth", authContext);
365139 await next();
140 return;
366141});
Modifiedapps/api/src/routes/admin.ts+27−342View fileUnifiedSplit
11/**
22 * Admin Routes — Cross-Account Platform Administration
3 *
4 * These endpoints power the admin dashboard with aggregated data across
5 * all accounts. They require an admin-level API key or Bearer token.
6 *
7 * GET /v1/admin/stats — Aggregate email counts by status
8 * GET /v1/admin/events — Recent events across all accounts
9 * GET /v1/admin/domains — All domains with status
10 * GET /v1/admin/messages — Recent messages across all accounts
11 * GET /v1/admin/users — All users with account info
12 * GET /v1/admin/health — Service health check
133 */
144
155import { Hono } from "hono";
2111 domains as domainsTable,
2212 accounts,
2313 users,
24 dnsRecords,
25} from "@alecrae/db";
26import { getDlqRecords, getDlqStats, clearDlqRecord, clearPermanentlyFailed } from "../lib/dlq-processor.js";
14} from "@emailed/db";
2715
2816const admin = new Hono();
2917
30// ─── GET /v1/admin/stats — Aggregated email stats ─────────────────────────
31
3218admin.get("/stats", async (c) => {
3319 const db = getDatabase();
34
35 // Count emails by status (all accounts)
36 const statusCounts = await db
37 .select({
38 status: emails.status,
39 count: count(),
40 })
41 .from(emails)
42 .groupBy(emails.status);
43
20 const statusCounts = await db.select({ status: emails.status, count: count() }).from(emails).groupBy(emails.status);
4421 const counts: Record<string, number> = {};
45 for (const row of statusCounts) {
46 counts[row.status] = row.count;
47 }
48
49 const sent =
50 (counts["sent"] ?? 0) +
51 (counts["delivered"] ?? 0) +
52 (counts["bounced"] ?? 0) +
53 (counts["complained"] ?? 0);
22 for (const row of statusCounts) counts[row.status] = row.count;
23 const sent = (counts["sent"] ?? 0) + (counts["delivered"] ?? 0) + (counts["bounced"] ?? 0) + (counts["complained"] ?? 0);
5424 const delivered = counts["delivered"] ?? 0;
5525 const bounced = counts["bounced"] ?? 0;
5626 const complained = counts["complained"] ?? 0;
5727 const queued = counts["queued"] ?? 0;
5828 const failed = counts["failed"] ?? 0;
5929 const deferred = counts["deferred"] ?? 0;
60
61 // Count engagement events
62 const engagementCounts = await db
63 .select({
64 type: events.type,
65 count: count(),
66 })
67 .from(events)
68 .where(sql`${events.type} IN ('email.opened', 'email.clicked')`)
69 .groupBy(events.type);
70
30 const engagementCounts = await db.select({ type: events.type, count: count() }).from(events).where(sql`${events.type} IN ('email.opened', 'email.clicked')`).groupBy(events.type);
7131 const engagementMap: Record<string, number> = {};
72 for (const row of engagementCounts) {
73 engagementMap[row.type] = row.count;
74 }
75
32 for (const row of engagementCounts) engagementMap[row.type] = row.count;
7633 const opened = engagementMap["email.opened"] ?? 0;
7734 const clicked = engagementMap["email.clicked"] ?? 0;
78
79 // Count total accounts, domains, users
80 const [accountCount] = await db
81 .select({ count: count() })
82 .from(accounts);
83 const [domainCount] = await db
84 .select({ count: count() })
85 .from(domainsTable);
35 const [accountCount] = await db.select({ count: count() }).from(accounts);
36 const [domainCount] = await db.select({ count: count() }).from(domainsTable);
8637 const [userCount] = await db.select({ count: count() }).from(users);
87
88 // 24h stats
8938 const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
90 const recentStatusCounts = await db
91 .select({
92 status: emails.status,
93 count: count(),
94 })
95 .from(emails)
96 .where(gte(emails.createdAt, oneDayAgo))
97 .groupBy(emails.status);
98
39 const recentStatusCounts = await db.select({ status: emails.status, count: count() }).from(emails).where(gte(emails.createdAt, oneDayAgo)).groupBy(emails.status);
9940 const recent: Record<string, number> = {};
100 for (const row of recentStatusCounts) {
101 recent[row.status] = row.count;
102 }
103
104 const recentSent =
105 (recent["sent"] ?? 0) +
106 (recent["delivered"] ?? 0) +
107 (recent["bounced"] ?? 0) +
108 (recent["complained"] ?? 0);
41 for (const row of recentStatusCounts) recent[row.status] = row.count;
42 const recentSent = (recent["sent"] ?? 0) + (recent["delivered"] ?? 0) + (recent["bounced"] ?? 0) + (recent["complained"] ?? 0);
10943 const recentDelivered = recent["delivered"] ?? 0;
11044 const recentBounced = recent["bounced"] ?? 0;
111
11245 return c.json({
11346 data: {
114 totals: {
115 sent,
116 delivered,
117 bounced,
118 complained,
119 queued,
120 failed,
121 deferred,
122 opened,
123 clicked,
124 deliveryRate: sent > 0 ? delivered / sent : 0,
125 bounceRate: sent > 0 ? bounced / sent : 0,
126 openRate: delivered > 0 ? opened / delivered : 0,
127 clickRate: delivered > 0 ? clicked / delivered : 0,
128 },
129 last24h: {
130 sent: recentSent,
131 delivered: recentDelivered,
132 bounced: recentBounced,
133 queued: recent["queued"] ?? 0,
134 failed: recent["failed"] ?? 0,
135 deferred: recent["deferred"] ?? 0,
136 },
137 platform: {
138 totalAccounts: accountCount?.count ?? 0,
139 totalDomains: domainCount?.count ?? 0,
140 totalUsers: userCount?.count ?? 0,
141 },
47 totals: { sent, delivered, bounced, complained, queued, failed, deferred, opened, clicked, deliveryRate: sent > 0 ? delivered / sent : 0, bounceRate: sent > 0 ? bounced / sent : 0, openRate: delivered > 0 ? opened / delivered : 0, clickRate: delivered > 0 ? clicked / delivered : 0 },
48 last24h: { sent: recentSent, delivered: recentDelivered, bounced: recentBounced, queued: recent["queued"] ?? 0, failed: recent["failed"] ?? 0, deferred: recent["deferred"] ?? 0 },
49 platform: { totalAccounts: accountCount?.count ?? 0, totalDomains: domainCount?.count ?? 0, totalUsers: userCount?.count ?? 0 },
14250 },
14351 });
14452});
14553
146// ─── GET /v1/admin/events — Recent events across all accounts ─────────────
147
14854admin.get("/events", async (c) => {
14955 const db = getDatabase();
15056 const limitParam = c.req.query("limit");
15157 const typeParam = c.req.query("type");
15258 const limit = Math.min(parseInt(limitParam ?? "50", 10), 200);
153
15459 const conditions = [];
155 if (typeParam) {
156 conditions.push(eq(events.type, typeParam as typeof events.type.enumValues[number]));
157 }
158
159 const rows = await db
160 .select({
161 id: events.id,
162 accountId: events.accountId,
163 emailId: events.emailId,
164 messageId: events.messageId,
165 type: events.type,
166 recipient: events.recipient,
167 timestamp: events.timestamp,
168 bounceType: events.bounceType,
169 bounceCategory: events.bounceCategory,
170 diagnosticCode: events.diagnosticCode,
171 remoteMta: events.remoteMta,
172 url: events.url,
173 userAgent: events.userAgent,
174 ipAddress: events.ipAddress,
175 smtpResponse: events.smtpResponse,
176 mxHost: events.mxHost,
177 tags: events.tags,
178 })
179 .from(events)
180 .where(conditions.length > 0 ? conditions[0] : undefined)
181 .orderBy(desc(events.timestamp))
182 .limit(limit);
183
184 const data = rows.map((r) => ({
185 id: r.id,
186 accountId: r.accountId,
187 emailId: r.emailId,
188 messageId: r.messageId,
189 type: r.type,
190 recipient: r.recipient,
191 timestamp: r.timestamp.toISOString(),
192 bounceType: r.bounceType,
193 bounceCategory: r.bounceCategory,
194 diagnosticCode: r.diagnosticCode,
195 remoteMta: r.remoteMta,
196 url: r.url,
197 userAgent: r.userAgent,
198 ipAddress: r.ipAddress,
199 smtpResponse: r.smtpResponse,
200 mxHost: r.mxHost,
201 tags: r.tags,
202 }));
203
60 if (typeParam) conditions.push(eq(events.type, typeParam as typeof events.type.enumValues[number]));
61 const rows = await db.select({ id: events.id, accountId: events.accountId, emailId: events.emailId, messageId: events.messageId, type: events.type, recipient: events.recipient, timestamp: events.timestamp, bounceType: events.bounceType, bounceCategory: events.bounceCategory, diagnosticCode: events.diagnosticCode, remoteMta: events.remoteMta, url: events.url, userAgent: events.userAgent, ipAddress: events.ipAddress, smtpResponse: events.smtpResponse, mxHost: events.mxHost, tags: events.tags }).from(events).where(conditions.length > 0 ? conditions[0] : undefined).orderBy(desc(events.timestamp)).limit(limit);
62 const data = rows.map((r) => ({ id: r.id, accountId: r.accountId, emailId: r.emailId, messageId: r.messageId, type: r.type, recipient: r.recipient, timestamp: r.timestamp.toISOString(), bounceType: r.bounceType, bounceCategory: r.bounceCategory, diagnosticCode: r.diagnosticCode, remoteMta: r.remoteMta, url: r.url, userAgent: r.userAgent, ipAddress: r.ipAddress, smtpResponse: r.smtpResponse, mxHost: r.mxHost, tags: r.tags }));
20463 return c.json({ data });
20564});
20665
207// ─── GET /v1/admin/domains — All domains with status ──────────────────────
208
20966admin.get("/domains", async (c) => {
21067 const db = getDatabase();
211
212 const domainRows = await db
213 .select({
214 id: domainsTable.id,
215 accountId: domainsTable.accountId,
216 domain: domainsTable.domain,
217 verificationStatus: domainsTable.verificationStatus,
218 spfVerified: domainsTable.spfVerified,
219 dkimVerified: domainsTable.dkimVerified,
220 dmarcVerified: domainsTable.dmarcVerified,
221 returnPathVerified: domainsTable.returnPathVerified,
222 isActive: domainsTable.isActive,
223 isDefault: domainsTable.isDefault,
224 createdAt: domainsTable.createdAt,
225 verifiedAt: domainsTable.verifiedAt,
226 })
227 .from(domainsTable)
228 .orderBy(desc(domainsTable.createdAt));
229
230 // Count emails per domain (last 24h)
68 const domainRows = await db.select({ id: domainsTable.id, accountId: domainsTable.accountId, domain: domainsTable.domain, verificationStatus: domainsTable.verificationStatus, spfVerified: domainsTable.spfVerified, dkimVerified: domainsTable.dkimVerified, dmarcVerified: domainsTable.dmarcVerified, returnPathVerified: domainsTable.returnPathVerified, isActive: domainsTable.isActive, isDefault: domainsTable.isDefault, createdAt: domainsTable.createdAt, verifiedAt: domainsTable.verifiedAt }).from(domainsTable).orderBy(desc(domainsTable.createdAt));
23169 const oneDayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000);
232 const emailCountsByDomain = await db
233 .select({
234 domainId: emails.domainId,
235 count: count(),
236 })
237 .from(emails)
238 .where(gte(emails.createdAt, oneDayAgo))
239 .groupBy(emails.domainId);
240
70 const emailCountsByDomain = await db.select({ domainId: emails.domainId, count: count() }).from(emails).where(gte(emails.createdAt, oneDayAgo)).groupBy(emails.domainId);
24171 const emailCountMap = new Map<string, number>();
242 for (const row of emailCountsByDomain) {
243 if (row.domainId) {
244 emailCountMap.set(row.domainId, row.count);
245 }
246 }
247
248 const data = domainRows.map((d) => ({
249 id: d.id,
250 accountId: d.accountId,
251 domain: d.domain,
252 status: d.verificationStatus,
253 spfVerified: d.spfVerified,
254 dkimVerified: d.dkimVerified,
255 dmarcVerified: d.dmarcVerified,
256 returnPathVerified: d.returnPathVerified,
257 isActive: d.isActive,
258 isDefault: d.isDefault,
259 messagesSent24h: emailCountMap.get(d.id) ?? 0,
260 createdAt: d.createdAt.toISOString(),
261 verifiedAt: d.verifiedAt?.toISOString() ?? null,
262 }));
263
72 for (const row of emailCountsByDomain) if (row.domainId) emailCountMap.set(row.domainId, row.count);
73 const data = domainRows.map((d) => ({ id: d.id, accountId: d.accountId, domain: d.domain, status: d.verificationStatus, spfVerified: d.spfVerified, dkimVerified: d.dkimVerified, dmarcVerified: d.dmarcVerified, returnPathVerified: d.returnPathVerified, isActive: d.isActive, isDefault: d.isDefault, messagesSent24h: emailCountMap.get(d.id) ?? 0, createdAt: d.createdAt.toISOString(), verifiedAt: d.verifiedAt?.toISOString() ?? null }));
26474 return c.json({ data });
26575});
26676
267// ─── GET /v1/admin/messages — Recent messages across all accounts ─────────
268
26977admin.get("/messages", async (c) => {
27078 const db = getDatabase();
27179 const limitParam = c.req.query("limit");
27280 const statusParam = c.req.query("status");
27381 const limit = Math.min(parseInt(limitParam ?? "50", 10), 200);
274
27582 const conditions = [];
276 if (statusParam) {
277 conditions.push(
278 eq(
279 emails.status,
280 statusParam as typeof emails.status.enumValues[number],
281 ),
282 );
283 }
284
285 const rows = await db
286 .select({
287 id: emails.id,
288 accountId: emails.accountId,
289 messageId: emails.messageId,
290 fromAddress: emails.fromAddress,
291 fromName: emails.fromName,
292 toAddresses: emails.toAddresses,
293 subject: emails.subject,
294 status: emails.status,
295 tags: emails.tags,
296 createdAt: emails.createdAt,
297 sentAt: emails.sentAt,
298 })
299 .from(emails)
300 .where(conditions.length > 0 ? conditions[0] : undefined)
301 .orderBy(desc(emails.createdAt))
302 .limit(limit);
303
304 const data = rows.map((row) => ({
305 id: row.id,
306 accountId: row.accountId,
307 messageId: row.messageId,
308 from: { email: row.fromAddress, name: row.fromName },
309 to: row.toAddresses,
310 subject: row.subject,
311 status: row.status,
312 tags: row.tags,
313 createdAt: row.createdAt.toISOString(),
314 sentAt: row.sentAt?.toISOString() ?? null,
315 }));
316
83 if (statusParam) conditions.push(eq(emails.status, statusParam as typeof emails.status.enumValues[number]));
84 const rows = await db.select({ id: emails.id, accountId: emails.accountId, messageId: emails.messageId, fromAddress: emails.fromAddress, fromName: emails.fromName, toAddresses: emails.toAddresses, subject: emails.subject, status: emails.status, tags: emails.tags, createdAt: emails.createdAt, sentAt: emails.sentAt }).from(emails).where(conditions.length > 0 ? conditions[0] : undefined).orderBy(desc(emails.createdAt)).limit(limit);
85 const data = rows.map((row) => ({ id: row.id, accountId: row.accountId, messageId: row.messageId, from: { email: row.fromAddress, name: row.fromName }, to: row.toAddresses, subject: row.subject, status: row.status, tags: row.tags, createdAt: row.createdAt.toISOString(), sentAt: row.sentAt?.toISOString() ?? null }));
31786 return c.json({ data });
31887});
31988
320// ─── GET /v1/admin/users — All users with account info ────────────────────
321
32289admin.get("/users", async (c) => {
32390 const db = getDatabase();
324
325 const userRows = await db
326 .select({
327 id: users.id,
328 email: users.email,
329 name: users.name,
330 role: users.role,
331 accountId: users.accountId,
332 createdAt: users.createdAt,
333 lastLoginAt: users.lastLoginAt,
334 })
335 .from(users)
336 .orderBy(desc(users.createdAt));
337
338 // Get account info for each user
339 const accountRows = await db
340 .select({
341 id: accounts.id,
342 name: accounts.name,
343 planTier: accounts.planTier,
344 emailsSentThisPeriod: accounts.emailsSentThisPeriod,
345 })
346 .from(accounts);
347
91 const userRows = await db.select({ id: users.id, email: users.email, name: users.name, role: users.role, accountId: users.accountId, createdAt: users.createdAt, lastLoginAt: users.lastLoginAt }).from(users).orderBy(desc(users.createdAt));
92 const accountRows = await db.select({ id: accounts.id, name: accounts.name, planTier: accounts.planTier, emailsSentThisPeriod: accounts.emailsSentThisPeriod }).from(accounts);
34893 const accountMap = new Map(accountRows.map((a) => [a.id, a]));
349
35094 const data = userRows.map((u) => {
35195 const acct = accountMap.get(u.accountId);
352 return {
353 id: u.id,
354 email: u.email,
355 name: u.name,
356 role: u.role,
357 accountId: u.accountId,
358 accountName: acct?.name ?? null,
359 plan: acct?.planTier ?? "free",
360 emailsSentThisPeriod: acct?.emailsSentThisPeriod ?? 0,
361 createdAt: u.createdAt.toISOString(),
362 lastLoginAt: u.lastLoginAt?.toISOString() ?? null,
363 };
96 return { id: u.id, email: u.email, name: u.name, role: u.role, accountId: u.accountId, accountName: acct?.name ?? null, plan: acct?.planTier ?? "free", emailsSentThisPeriod: acct?.emailsSentThisPeriod ?? 0, createdAt: u.createdAt.toISOString(), lastLoginAt: u.lastLoginAt?.toISOString() ?? null };
36497 });
365
36698 return c.json({ data });
36799});
368100
369// ─── GET /v1/admin/dlq — Dead letter queue inspection ────────────────────
370
371admin.get("/dlq", async (c) => {
372 const stats = getDlqStats();
373 const records = getDlqRecords();
374
375 // Optional status filter
376 const statusFilter = c.req.query("status");
377 const filtered = statusFilter
378 ? records.filter((r) => r.status === statusFilter)
379 : records;
380
381 return c.json({
382 data: {
383 stats,
384 records: filtered.map((r) => ({
385 jobId: r.jobId,
386 jobName: r.jobName,
387 failedReason: r.failedReason,
388 attemptsMade: r.attemptsMade,
389 timestamp: r.timestamp,
390 status: r.status,
391 retryScheduledAt: r.retryScheduledAt ?? null,
392 })),
393 },
394 });
395});
396
397// ─── DELETE /v1/admin/dlq/:jobId — Clear a DLQ record ───────────────────
398
399admin.delete("/dlq/:jobId", async (c) => {
400 const jobId = c.req.param("jobId");
401 if (!jobId) {
402 return c.json({ error: { type: "validation_error", message: "Missing jobId", code: "missing_param" } }, 400);
403 }
404
405 const cleared = clearDlqRecord(jobId);
406 return c.json({ data: { cleared } });
407});
408
409// ─── POST /v1/admin/dlq/clear — Clear all permanently failed ────────────
410
411admin.post("/dlq/clear", async (c) => {
412 const count = clearPermanentlyFailed();
413 return c.json({ data: { cleared: count } });
414});
415
416101export { admin };
Modifiedapps/api/src/routes/auth.ts+73−328View fileUnifiedSplit
11/**
22 * Authentication Routes
3 *
4 * POST /v1/auth/login — Email + password login, returns access + refresh tokens
5 * POST /v1/auth/register — Create account + user, returns access + refresh tokens
6 * POST /v1/auth/refresh — Rotate refresh token, returns new token pair
7 * POST /v1/auth/logout — Revoke all refresh tokens for the user
8 * GET /v1/auth/me — Get current user from session token
93 */
104
115import { Hono } from "hono";
126import { z } from "zod";
137import { eq } from "drizzle-orm";
148import { validateBody, getValidatedBody } from "../middleware/validator.js";
15import { getDatabase, users, accounts } from "@alecrae/db";
16import {
17 issueTokenPair,
18 rotateRefreshToken,
19 revokeAllUserTokens,
20 verifyAccessToken,
21 TokenError,
22} from "../lib/jwt.js";
23import type { TokenPayload } from "../lib/jwt.js";
9import { getDatabase, users, accounts } from "@emailed/db";
2410
2511const auth = new Hono();
2612
2713function generateId(): string {
2814 const bytes = crypto.getRandomValues(new Uint8Array(16));
29 return Array.from(bytes)
30 .map((b) => b.toString(16).padStart(2, "0"))
31 .join("");
15 return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
3216}
3317
3418async function hashPassword(password: string): Promise<string> {
3519 const data = new TextEncoder().encode(password);
3620 const hash = await crypto.subtle.digest("SHA-256", data);
37 return Array.from(new Uint8Array(hash))
38 .map((b) => b.toString(16).padStart(2, "0"))
39 .join("");
21 return Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join("");
4022}
4123
42// ─── Schemas ───────────────────────────────────────────────────────────────
43
44const LoginSchema = z.object({
45 email: z.string().email(),
46 password: z.string().min(8),
47});
24function createToken(payload: Record<string, unknown>): string {
25 const secret = process.env["JWT_SECRET"] ?? "dev_secret";
26 const header = btoa(JSON.stringify({ alg: "HS256", typ: "JWT" }));
27 const body = btoa(JSON.stringify({ ...payload, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 86400 * 7 }));
28 const signature = btoa(`${header}.${body}.${secret}`);
29 return `${header}.${body}.${signature}`;
30}
4831
49const RegisterSchema = z.object({
50 email: z.string().email(),
51 password: z.string().min(8).max(128),
52 name: z.string().min(1).max(256),
53 accountName: z.string().min(1).max(256).optional(),
54});
32const LoginSchema = z.object({ email: z.string().email(), password: z.string().min(8) });
33const RegisterSchema = z.object({ email: z.string().email(), password: z.string().min(8).max(128), name: z.string().min(1).max(256), accountName: z.string().min(1).max(256).optional() });
5534
56// POST /v1/auth/login
5735auth.post("/login", validateBody(LoginSchema), async (c) => {
5836 const input = getValidatedBody<z.infer<typeof LoginSchema>>(c);
5937 const db = getDatabase();
60
61 const [user] = await db
62 .select()
63 .from(users)
64 .where(eq(users.email, input.email.toLowerCase()))
65 .limit(1);
66
67 if (!user) {
68 return c.json(
69 {
70 error: {
71 type: "authentication_error",
72 message: "Invalid email or password",
73 code: "invalid_credentials",
74 },
75 },
76 401,
77 );
78 }
79
38 const [user] = await db.select().from(users).where(eq(users.email, input.email.toLowerCase())).limit(1);
39 if (!user) return c.json({ error: { type: "authentication_error", message: "Invalid email or password", code: "invalid_credentials" } }, 401);
8040 const passwordHash = await hashPassword(input.password);
81 if (user.passwordHash !== passwordHash) {
82 return c.json(
83 {
84 error: {
85 type: "authentication_error",
86 message: "Invalid email or password",
87 code: "invalid_credentials",
88 },
89 },
90 401,
91 );
92 }
93
94 // Update last login
95 await db
96 .update(users)
97 .set({ lastLoginAt: new Date() })
98 .where(eq(users.id, user.id));
99
100 // Look up account tier
101 let tier = "free";
102 try {
103 const [account] = await db
104 .select({ planTier: accounts.planTier })
105 .from(accounts)
106 .where(eq(accounts.id, user.accountId))
107 .limit(1);
108 if (account) tier = account.planTier ?? "free";
109 } catch {
110 // fall through
111 }
112
113 const tokenPair = await issueTokenPair({
114 sub: user.accountId,
115 userId: user.id,
116 email: user.email,
117 role: user.role,
118 tier,
119 });
120
121 return c.json({
122 data: {
123 token: tokenPair.accessToken,
124 refreshToken: tokenPair.refreshToken,
125 expiresIn: tokenPair.expiresIn,
126 user: {
127 id: user.id,
128 email: user.email,
129 name: user.name,
130 role: user.role,
131 accountId: user.accountId,
132 },
133 },
134 });
41 if (user.passwordHash !== passwordHash) return c.json({ error: { type: "authentication_error", message: "Invalid email or password", code: "invalid_credentials" } }, 401);
42 await db.update(users).set({ lastLoginAt: new Date() }).where(eq(users.id, user.id));
43 const token = createToken({ sub: user.accountId, userId: user.id, email: user.email, role: user.role });
44 return c.json({ data: { token, user: { id: user.id, email: user.email, name: user.name, role: user.role, accountId: user.accountId } } });
13545});
13646
137// POST /v1/auth/register
13847auth.post("/register", validateBody(RegisterSchema), async (c) => {
13948 const input = getValidatedBody<z.infer<typeof RegisterSchema>>(c);
14049 const db = getDatabase();
141
142 // Check if user already exists
143 const [existing] = await db
144 .select({ id: users.id })
145 .from(users)
146 .where(eq(users.email, input.email.toLowerCase()))
147 .limit(1);
148
149 if (existing) {
150 return c.json(
151 {
152 error: {
153 type: "validation_error",
154 message: "An account with this email already exists",
155 code: "email_exists",
156 },
157 },
158 409,
159 );
160 }
161
50 const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, input.email.toLowerCase())).limit(1);
51 if (existing) return c.json({ error: { type: "validation_error", message: "An account with this email already exists", code: "email_exists" } }, 409);
16252 const accountId = generateId();
16353 const userId = generateId();
16454 const passwordHash = await hashPassword(input.password);
165
166 // Create account
167 await db.insert(accounts).values({
168 id: accountId,
169 name: input.accountName ?? `${input.name}'s Account`,
170 planTier: "free",
171 billingEmail: input.email.toLowerCase(),
172 emailsSentThisPeriod: 0,
173 });
174
175 // Create user
176 await db.insert(users).values({
177 id: userId,
178 accountId,
179 email: input.email.toLowerCase(),
180 name: input.name,
181 passwordHash,
182 role: "owner",
183 emailVerified: false,
184 permissions: {
185 sendEmail: true,
186 readEmail: true,
187 manageDomains: true,
188 manageApiKeys: true,
189 manageWebhooks: true,
190 viewAnalytics: true,
191 manageAccount: true,
192 manageTeamMembers: true,
193 },
194 });
195
196 const tokenPair = await issueTokenPair({
197 sub: accountId,
198 userId,
199 email: input.email.toLowerCase(),
200 role: "owner",
201 tier: "free",
202 });
203
204 return c.json(
205 {
206 data: {
207 token: tokenPair.accessToken,
208 refreshToken: tokenPair.refreshToken,
209 expiresIn: tokenPair.expiresIn,
210 user: {
211 id: userId,
212 email: input.email.toLowerCase(),
213 name: input.name,
214 role: "owner",
215 accountId,
216 },
217 },
218 },
219 201,
220 );
221});
222
223// ─── Schemas for new endpoints ────────────��───────────────────────────────
224
225const RefreshSchema = z.object({
226 refreshToken: z.string().min(1),
55 await db.insert(accounts).values({ id: accountId, name: input.accountName ?? `${input.name}'s Account`, planTier: "free", billingEmail: input.email.toLowerCase(), emailsSentThisPeriod: 0 });
56 await db.insert(users).values({ id: userId, accountId, email: input.email.toLowerCase(), name: input.name, passwordHash, role: "owner", emailVerified: false, permissions: { sendEmail: true, readEmail: true, manageDomains: true, manageApiKeys: true, manageWebhooks: true, viewAnalytics: true, manageAccount: true, manageTeamMembers: true } });
57 const token = createToken({ sub: accountId, userId, email: input.email.toLowerCase(), role: "owner" });
58 return c.json({ data: { token, user: { id: userId, email: input.email.toLowerCase(), name: input.name, role: "owner", accountId } } }, 201);
22759});
22860
229// POST /v1/auth/refresh — Rotate refresh token, return new token pair
230auth.post("/refresh", validateBody(RefreshSchema), async (c) => {
231 const input = getValidatedBody<z.infer<typeof RefreshSchema>>(c);
61interface SessionPayload { readonly userId: string; readonly accountId: string; }
23262
63function verifyBearerToken(authHeader: string | undefined): SessionPayload | null {
64 if (!authHeader?.startsWith("Bearer ")) return null;
65 const token = authHeader.slice(7);
66 const parts = token.split(".");
67 if (parts.length !== 3 || !parts[1]) return null;
23368 try {
234 const tokenPair = await rotateRefreshToken(input.refreshToken);
69 const payload = JSON.parse(atob(parts[1])) as { exp?: number; userId?: string; sub?: string };
70 if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;
71 if (!payload.userId || !payload.sub) return null;
72 return { userId: payload.userId, accountId: payload.sub };
73 } catch { return null; }
74}
23575
236 return c.json({
237 data: {
238 token: tokenPair.accessToken,
239 refreshToken: tokenPair.refreshToken,
240 expiresIn: tokenPair.expiresIn,
241 },
242 });
243 } catch (err) {
244 const code = err instanceof TokenError ? err.code : "invalid_refresh_token";
245 const message = err instanceof Error ? err.message : "Invalid refresh token";
76function unauthenticatedResponse() {
77 return { error: { type: "authentication_error" as const, message: "Invalid or expired token", code: "invalid_token" as const } };
78}
24679
247 return c.json(
248 {
249 error: {
250 type: "authentication_error",
251 message,
252 code,
253 },
254 },
255 401,
256 );
257 }
80auth.get("/me", async (c) => {
81 const session = verifyBearerToken(c.req.header("Authorization"));
82 if (!session) return c.json(unauthenticatedResponse(), 401);
83 const db = getDatabase();
84 const [user] = await db.select({ id: users.id, email: users.email, name: users.name, role: users.role, accountId: users.accountId }).from(users).where(eq(users.id, session.userId)).limit(1);
85 if (!user) return c.json(unauthenticatedResponse(), 401);
86 return c.json({ data: user });
25887});
25988
260// POST /v1/auth/logout — Revoke all refresh tokens for the authenticated user
261auth.post("/logout", async (c) => {
262 const authHeader = c.req.header("Authorization");
263 if (!authHeader?.startsWith("Bearer ")) {
264 return c.json(
265 {
266 error: {
267 type: "authentication_error",
268 message: "Missing token",
269 code: "unauthenticated",
270 },
271 },
272 401,
273 );
274 }
275
276 const token = authHeader.slice(7);
277 try {
278 const payload = await verifyAccessToken(token);
279 const userId = payload.userId as string;
280
281 await revokeAllUserTokens(userId);
282
283 return c.json({ data: { message: "All sessions revoked" } });
284 } catch {
285 // Try legacy decode as fallback
286 try {
287 const parts = token.split(".");
288 if (parts.length !== 3) throw new Error("Invalid token");
289 const payload = JSON.parse(atob(parts[1]!));
290 if (payload.userId) {
291 await revokeAllUserTokens(payload.userId as string);
292 return c.json({ data: { message: "All sessions revoked" } });
293 }
294 } catch {
295 // fall through
296 }
89const UpdateProfileSchema = z.object({ name: z.string().min(1).max(256).optional(), email: z.string().email().optional() });
29790
298 return c.json(
299 {
300 error: {
301 type: "authentication_error",
302 message: "Invalid or expired token",
303 code: "invalid_token",
304 },
305 },
306 401,
307 );
91auth.patch("/me", validateBody(UpdateProfileSchema), async (c) => {
92 const session = verifyBearerToken(c.req.header("Authorization"));
93 if (!session) return c.json(unauthenticatedResponse(), 401);
94 const input = getValidatedBody<z.infer<typeof UpdateProfileSchema>>(c);
95 const db = getDatabase();
96 if (input.email) {
97 const lower = input.email.toLowerCase();
98 const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, lower)).limit(1);
99 if (existing && existing.id !== session.userId) return c.json({ error: { type: "validation_error", message: "An account with this email already exists", code: "email_exists" } }, 409);
308100 }
101 const patch: { name?: string; email?: string; updatedAt: Date } = { updatedAt: new Date() };
102 if (input.name !== undefined) patch.name = input.name;
103 if (input.email !== undefined) patch.email = input.email.toLowerCase();
104 await db.update(users).set(patch).where(eq(users.id, session.userId));
105 const [updated] = await db.select({ id: users.id, email: users.email, name: users.name, role: users.role, accountId: users.accountId }).from(users).where(eq(users.id, session.userId)).limit(1);
106 if (!updated) return c.json(unauthenticatedResponse(), 401);
107 return c.json({ data: updated });
309108});
310109
311// GET /v1/auth/me — Get current user from bearer token
312auth.get("/me", async (c) => {
313 const authHeader = c.req.header("Authorization");
314 if (!authHeader?.startsWith("Bearer ")) {
315 return c.json(
316 {
317 error: {
318 type: "authentication_error",
319 message: "Missing token",
320 code: "unauthenticated",
321 },
322 },
323 401,
324 );
325 }
326
327 const token = authHeader.slice(7);
328 try {
329 // Try verified JWT first
330 let userId: string | undefined;
331 try {
332 const payload = await verifyAccessToken(token);
333 userId = payload.userId as string;
334 } catch {
335 // Fallback to raw decode for legacy tokens
336 const parts = token.split(".");
337 if (parts.length !== 3) throw new Error("Invalid token");
338 const payload = JSON.parse(atob(parts[1]!));
339 if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
340 throw new Error("Token expired");
341 }
342 userId = payload.userId as string;
343 }
344
345 if (!userId) throw new Error("No userId in token");
346
347 const db = getDatabase();
348 const [user] = await db
349 .select({
350 id: users.id,
351 email: users.email,
352 name: users.name,
353 role: users.role,
354 accountId: users.accountId,
355 })
356 .from(users)
357 .where(eq(users.id, userId))
358 .limit(1);
359
360 if (!user) throw new Error("User not found");
361
362 return c.json({ data: user });
363 } catch {
364 return c.json(
365 {
366 error: {
367 type: "authentication_error",
368 message: "Invalid or expired token",
369 code: "invalid_token",
370 },
371 },
372 401,
373 );
374 }
110auth.delete("/me", async (c) => {
111 const session = verifyBearerToken(c.req.header("Authorization"));
112 if (!session) return c.json(unauthenticatedResponse(), 401);
113 const db = getDatabase();
114 const [user] = await db.select({ id: users.id, role: users.role, accountId: users.accountId }).from(users).where(eq(users.id, session.userId)).limit(1);
115 if (!user) return c.json(unauthenticatedResponse(), 401);
116 if (user.role !== "owner") return c.json({ error: { type: "permission_error", message: "Only account owners can delete the account", code: "forbidden" } }, 403);
117 const deletionDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
118 await db.update(accounts).set({ status: "scheduled_for_deletion", scheduledDeletionAt: deletionDate, updatedAt: new Date() }).where(eq(accounts.id, user.accountId));
119 return c.json({ data: { status: "scheduled_for_deletion", scheduledDeletionAt: deletionDate.toISOString(), message: "Account scheduled for deletion in 30 days. Log in again before then to cancel." } });
375120});
376121
377122export { auth };
Modifiedapps/api/src/routes/heatmap.ts+114−381View fileUnifiedSplit
11/**
22 * Inbox Heatmap Analytics Routes (A3)
3 *
4 * Provides three endpoints for the inbox heatmap dashboard:
5 * GET /v1/analytics/heatmap — daily email counts for heatmap grid
6 * GET /v1/analytics/hourly — hourly activity breakdown (avg per hour)
7 * GET /v1/analytics/stats — key metrics (avg response time, emails/day, etc.)
83 */
94
105import { Hono } from "hono";
127import { eq, and, gte, lte, sql, count } from "drizzle-orm";
138import { requireScope } from "../middleware/auth.js";
149import { validateQuery, getValidatedQuery } from "../middleware/validator.js";
15import { getDatabase, emails, events } from "@alecrae/db";
16
17// ─── Validation Schemas ─────────────────────────────────────────────────────
10import { getDatabase, emails } from "@emailed/db";
1811
1912const HeatmapPeriod = z.enum(["7d", "30d", "90d", "1y"]).default("90d");
20
21const HeatmapQuerySchema = z.object({
22 period: HeatmapPeriod,
23 mode: z.enum(["both", "sent", "received"]).default("both"),
24});
25
13const HeatmapQuerySchema = z.object({ period: HeatmapPeriod, mode: z.enum(["both", "sent", "received"]).default("both") });
2614type HeatmapQuery = z.infer<typeof HeatmapQuerySchema>;
27
28const HourlyQuerySchema = z.object({
29 period: HeatmapPeriod,
30});
31
15const HourlyQuerySchema = z.object({ period: HeatmapPeriod });
3216type HourlyQuery = z.infer<typeof HourlyQuerySchema>;
33
34const StatsQuerySchema = z.object({
35 period: HeatmapPeriod,
36 compare: z.enum(["true", "false"]).default("false"),
37});
38
17const StatsQuerySchema = z.object({ period: HeatmapPeriod, compare: z.enum(["true", "false"]).default("false") });
3918type StatsQuery = z.infer<typeof StatsQuerySchema>;
4019
41// ─── Helpers ────────────────────────────────────────────────────────────────
42
4320function periodToDays(period: string): number {
44 switch (period) {
45 case "7d":
46 return 7;
47 case "30d":
48 return 30;
49 case "90d":
50 return 90;
51 case "1y":
52 return 365;
53 default:
54 return 90;
55 }
21 switch (period) { case "7d": return 7; case "30d": return 30; case "90d": return 90; case "1y": return 365; default: return 90; }
5622}
5723
5824function periodFromDate(period: string): Date {
6329 return from;
6430}
6531
66// ─── Router ─────────────────────────────────────────────────────────────────
67
6832const heatmapAnalytics = new Hono();
6933
70// ─── GET /heatmap — Daily counts for the contribution-style grid ────────────
71
72heatmapAnalytics.get(
73 "/heatmap",
74 requireScope("analytics:read"),
75 validateQuery(HeatmapQuerySchema),
76 async (c) => {
77 const query = getValidatedQuery<HeatmapQuery>(c);
78 const auth = c.get("auth");
79 const db = getDatabase();
80 const from = periodFromDate(query.period);
81 const to = new Date();
82
83 // Aggregate emails per day using date_trunc
84 const rows = await db
85 .select({
86 day: sql<string>`date_trunc('day', ${emails.createdAt})::date`.as("day"),
87 status: emails.status,
88 count: count(),
89 })
90 .from(emails)
91 .where(
92 and(
93 eq(emails.accountId, auth.accountId),
94 gte(emails.createdAt, from),
95 lte(emails.createdAt, to),
96 ),
97 )
98 .groupBy(sql`day`, emails.status);
99
100 // Build a map: "YYYY-MM-DD" → { sent, received }
101 const dayMap = new Map<string, { sent: number; received: number }>();
102
103 for (const row of rows) {
104 const dayStr = String(row.day).slice(0, 10);
105 const existing = dayMap.get(dayStr) ?? { sent: 0, received: 0 };
106
107 // Emails the user _sent_ have status sent/delivered/bounced/complained
108 const isSent = ["sent", "delivered", "bounced", "complained"].includes(row.status);
109 // Emails _received_ have status queued/processing (inbound) — in practice,
110 // inbound emails are tracked via events. For the heatmap we count all
111 // emails created in the period.
112 if (isSent) {
113 existing.sent += row.count;
114 } else {
115 existing.received += row.count;
116 }
117
118 dayMap.set(dayStr, existing);
119 }
120
121 // Convert to array sorted by date
122 const heatmapData: { date: string; sent: number; received: number }[] = [];
123 const cursor = new Date(from);
124 while (cursor <= to) {
125 const iso = cursor.toISOString().slice(0, 10);
126 const entry = dayMap.get(iso);
127 heatmapData.push({
128 date: iso,
129 sent: entry?.sent ?? 0,
130 received: entry?.received ?? 0,
131 });
132 cursor.setDate(cursor.getDate() + 1);
133 }
134
135 return c.json({
136 data: heatmapData,
137 meta: {
138 period: query.period,
139 from: from.toISOString(),
140 to: to.toISOString(),
141 days: periodToDays(query.period),
142 },
143 });
144 },
145);
146
147// ─── GET /hourly — Average emails by hour of day ────────────────────────────
148
149heatmapAnalytics.get(
150 "/hourly",
151 requireScope("analytics:read"),
152 validateQuery(HourlyQuerySchema),
153 async (c) => {
154 const query = getValidatedQuery<HourlyQuery>(c);
155 const auth = c.get("auth");
156 const db = getDatabase();
157 const from = periodFromDate(query.period);
158 const to = new Date();
159 const totalDays = periodToDays(query.period);
160
161 // Group by hour-of-day and status
162 const rows = await db
163 .select({
164 hour: sql<number>`extract(hour from ${emails.createdAt})`.as("hour"),
165 status: emails.status,
166 count: count(),
167 })
168 .from(emails)
169 .where(
170 and(
171 eq(emails.accountId, auth.accountId),
172 gte(emails.createdAt, from),
173 lte(emails.createdAt, to),
174 ),
175 )
176 .groupBy(sql`hour`, emails.status);
177
178 // Build hourly buckets
179 const hourMap = new Map<number, { sent: number; received: number }>();
180
181 for (const row of rows) {
182 const h = Number(row.hour);
183 const existing = hourMap.get(h) ?? { sent: 0, received: 0 };
184 const isSent = ["sent", "delivered", "bounced", "complained"].includes(row.status);
185
186 if (isSent) {
187 existing.sent += row.count;
188 } else {
189 existing.received += row.count;
190 }
191
192 hourMap.set(h, existing);
193 }
194
195 // Normalize: 24 buckets, average per day
196 const hourlyData: { hour: number; sent: number; received: number }[] = [];
197 let peakHour = 0;
198 let peakTotal = 0;
199
200 for (let h = 0; h < 24; h++) {
201 const raw = hourMap.get(h) ?? { sent: 0, received: 0 };
202 const avgSent = Math.round((raw.sent / totalDays) * 10) / 10;
203 const avgReceived = Math.round((raw.received / totalDays) * 10) / 10;
204 hourlyData.push({ hour: h, sent: avgSent, received: avgReceived });
205
206 if (avgSent + avgReceived > peakTotal) {
207 peakTotal = avgSent + avgReceived;
208 peakHour = h;
209 }
210 }
211
212 // Identify peak hours (top 3) and best send hours (lowest received-to-sent ratio)
213 const sorted = [...hourlyData].sort(
214 (a, b) => (b.sent + b.received) - (a.sent + a.received),
215 );
216 const peakHours = sorted.slice(0, 3).map((b) => b.hour);
217
218 // Best send hours: hours with high open potential (morning 8-11, early afternoon 13-15)
219 // We use a simple heuristic: hours in the morning/afternoon with moderate activity
220 const bestSendHours = hourlyData
221 .filter((b) => b.hour >= 8 && b.hour <= 15 && b.received > 0)
222 .sort((a, b) => b.received - a.received)
223 .slice(0, 3)
224 .map((b) => b.hour);
225
226 return c.json({
227 data: hourlyData,
228 meta: {
229 period: query.period,
230 from: from.toISOString(),
231 to: to.toISOString(),
232 peakHour,
233 peakHours,
234 bestSendHours,
235 },
236 });
237 },
238);
239
240// ─── GET /stats — Key metrics for the dashboard ─────────────────────────────
241
242heatmapAnalytics.get(
243 "/stats",
244 requireScope("analytics:read"),
245 validateQuery(StatsQuerySchema),
246 async (c) => {
247 const query = getValidatedQuery<StatsQuery>(c);
248 const auth = c.get("auth");
249 const db = getDatabase();
250 const days = periodToDays(query.period);
251 const to = new Date();
252 const from = periodFromDate(query.period);
253
254 // Current period counts grouped by day
255 const dailyCounts = await db
256 .select({
257 day: sql<string>`date_trunc('day', ${emails.createdAt})::date`.as("day"),
258 status: emails.status,
259 count: count(),
260 })
261 .from(emails)
262 .where(
263 and(
264 eq(emails.accountId, auth.accountId),
265 gte(emails.createdAt, from),
266 lte(emails.createdAt, to),
267 ),
268 )
269 .groupBy(sql`day`, emails.status);
34heatmapAnalytics.get("/heatmap", requireScope("analytics:read"), validateQuery(HeatmapQuerySchema), async (c) => {
35 const query = getValidatedQuery<HeatmapQuery>(c);
36 const auth = c.get("auth");
37 const db = getDatabase();
38 const from = periodFromDate(query.period);
39 const to = new Date();
40 const rows = await db.select({ day: sql<string>`date_trunc('day', ${emails.createdAt})::date`.as("day"), status: emails.status, count: count() }).from(emails).where(and(eq(emails.accountId, auth.accountId), gte(emails.createdAt, from), lte(emails.createdAt, to))).groupBy(sql`day`, emails.status);
41 const dayMap = new Map<string, { sent: number; received: number }>();
42 for (const row of rows) {
43 const dayStr = String(row.day).slice(0, 10);
44 const existing = dayMap.get(dayStr) ?? { sent: 0, received: 0 };
45 const isSent = ["sent", "delivered", "bounced", "complained"].includes(row.status);
46 if (isSent) existing.sent += row.count; else existing.received += row.count;
47 dayMap.set(dayStr, existing);
48 }
49 const heatmapData: { date: string; sent: number; received: number }[] = [];
50 const cursor = new Date(from);
51 while (cursor <= to) {
52 const iso = cursor.toISOString().slice(0, 10);
53 const entry = dayMap.get(iso);
54 heatmapData.push({ date: iso, sent: entry?.sent ?? 0, received: entry?.received ?? 0 });
55 cursor.setDate(cursor.getDate() + 1);
56 }
57 return c.json({ data: heatmapData, meta: { period: query.period, from: from.toISOString(), to: to.toISOString(), days: periodToDays(query.period) } });
58});
27059
271 // Aggregate
272 let totalSent = 0;
273 let totalReceived = 0;
274 const dayTotals = new Map<string, number>();
60heatmapAnalytics.get("/hourly", requireScope("analytics:read"), validateQuery(HourlyQuerySchema), async (c) => {
61 const query = getValidatedQuery<HourlyQuery>(c);
62 const auth = c.get("auth");
63 const db = getDatabase();
64 const from = periodFromDate(query.period);
65 const to = new Date();
66 const totalDays = periodToDays(query.period);
67 const rows = await db.select({ hour: sql<number>`extract(hour from ${emails.createdAt})`.as("hour"), status: emails.status, count: count() }).from(emails).where(and(eq(emails.accountId, auth.accountId), gte(emails.createdAt, from), lte(emails.createdAt, to))).groupBy(sql`hour`, emails.status);
68 const hourMap = new Map<number, { sent: number; received: number }>();
69 for (const row of rows) {
70 const h = Number(row.hour);
71 const existing = hourMap.get(h) ?? { sent: 0, received: 0 };
72 const isSent = ["sent", "delivered", "bounced", "complained"].includes(row.status);
73 if (isSent) existing.sent += row.count; else existing.received += row.count;
74 hourMap.set(h, existing);
75 }
76 const hourlyData: { hour: number; sent: number; received: number }[] = [];
77 let peakHour = 0;
78 let peakTotal = 0;
79 for (let h = 0; h < 24; h++) {
80 const raw = hourMap.get(h) ?? { sent: 0, received: 0 };
81 const avgSent = Math.round((raw.sent / totalDays) * 10) / 10;
82 const avgReceived = Math.round((raw.received / totalDays) * 10) / 10;
83 hourlyData.push({ hour: h, sent: avgSent, received: avgReceived });
84 if (avgSent + avgReceived > peakTotal) { peakTotal = avgSent + avgReceived; peakHour = h; }
85 }
86 const sorted = [...hourlyData].sort((a, b) => (b.sent + b.received) - (a.sent + a.received));
87 const peakHours = sorted.slice(0, 3).map((b) => b.hour);
88 const bestSendHours = hourlyData.filter((b) => b.hour >= 8 && b.hour <= 15 && b.received > 0).sort((a, b) => b.received - a.received).slice(0, 3).map((b) => b.hour);
89 return c.json({ data: hourlyData, meta: { period: query.period, from: from.toISOString(), to: to.toISOString(), peakHour, peakHours, bestSendHours } });
90});
27591
276 for (const row of dailyCounts) {
277 const dayStr = String(row.day).slice(0, 10);
92heatmapAnalytics.get("/stats", requireScope("analytics:read"), validateQuery(StatsQuerySchema), async (c) => {
93 const query = getValidatedQuery<StatsQuery>(c);
94 const auth = c.get("auth");
95 const db = getDatabase();
96 const days = periodToDays(query.period);
97 const to = new Date();
98 const from = periodFromDate(query.period);
99 const dailyCounts = await db.select({ day: sql<string>`date_trunc('day', ${emails.createdAt})::date`.as("day"), status: emails.status, count: count() }).from(emails).where(and(eq(emails.accountId, auth.accountId), gte(emails.createdAt, from), lte(emails.createdAt, to))).groupBy(sql`day`, emails.status);
100 let totalSent = 0;
101 let totalReceived = 0;
102 const dayTotals = new Map<string, number>();
103 for (const row of dailyCounts) {
104 const dayStr = String(row.day).slice(0, 10);
105 const isSent = ["sent", "delivered", "bounced", "complained"].includes(row.status);
106 if (isSent) totalSent += row.count; else totalReceived += row.count;
107 dayTotals.set(dayStr, (dayTotals.get(dayStr) ?? 0) + row.count);
108 }
109 const emailsPerDay = days > 0 ? Math.round(((totalSent + totalReceived) / days) * 10) / 10 : 0;
110 let busiestDay: string | null = null;
111 let busiestCount = 0;
112 let quietestDay: string | null = null;
113 let quietestCount = Infinity;
114 for (const [day, total] of dayTotals) {
115 if (total > busiestCount) { busiestCount = total; busiestDay = day; }
116 if (total < quietestCount) { quietestCount = total; quietestDay = day; }
117 }
118 if (dayTotals.size === 0) quietestDay = null;
119 const avgResponseTimeSec: number | null = null;
120 let inboxZeroStreak = 0;
121 const todayStr = to.toISOString().slice(0, 10);
122 const streakCursor = new Date(to);
123 for (let i = 0; i < days; i++) {
124 const dayStr = streakCursor.toISOString().slice(0, 10);
125 const dayTotal = dayTotals.get(dayStr) ?? 0;
126 if (dayTotal === 0 && dayStr <= todayStr) inboxZeroStreak++;
127 else if (dayTotal > 0 && i > 0) break;
128 streakCursor.setDate(streakCursor.getDate() - 1);
129 }
130 const metrics = { avgResponseTimeSec, emailsPerDay, busiestDay, quietestDay, inboxZeroStreak, totalSent, totalReceived };
131 let compare: { avgResponseTimeDelta: number | null; emailsPerDayDelta: number | null; totalSentDelta: number | null; totalReceivedDelta: number | null } | null = null;
132 if (query.compare === "true") {
133 const prevFrom = new Date(from);
134 prevFrom.setDate(prevFrom.getDate() - days);
135 const prevCounts = await db.select({ status: emails.status, count: count() }).from(emails).where(and(eq(emails.accountId, auth.accountId), gte(emails.createdAt, prevFrom), lte(emails.createdAt, from))).groupBy(emails.status);
136 let prevSent = 0;
137 let prevReceived = 0;
138 for (const row of prevCounts) {
278139 const isSent = ["sent", "delivered", "bounced", "complained"].includes(row.status);
279
280 if (isSent) {
281 totalSent += row.count;
282 } else {
283 totalReceived += row.count;
284 }
285
286 dayTotals.set(dayStr, (dayTotals.get(dayStr) ?? 0) + row.count);
140 if (isSent) prevSent += row.count; else prevReceived += row.count;
287141 }
288
289 const emailsPerDay = days > 0 ? Math.round(((totalSent + totalReceived) / days) * 10) / 10 : 0;
290
291 // Find busiest and quietest days
292 let busiestDay: string | null = null;
293 let busiestCount = 0;
294 let quietestDay: string | null = null;
295 let quietestCount = Infinity;
296
297 for (const [day, total] of dayTotals) {
298 if (total > busiestCount) {
299 busiestCount = total;
300 busiestDay = day;
301 }
302 if (total < quietestCount) {
303 quietestCount = total;
304 quietestDay = day;
305 }
306 }
307
308 if (dayTotals.size === 0) {
309 quietestDay = null;
310 quietestCount = 0;
311 }
312
313 // Average response time: compute from delivery_results if available,
314 // otherwise use a sensible estimate from email timestamps.
315 // We'll compute the average time between consecutive emails in the same
316 // thread (approximation).
317 const avgResponseTimeSec: number | null = null; // Placeholder — requires thread analysis
318
319 // Inbox zero streak: count consecutive days from today backward with zero unread
320 // For now, approximate from daily totals (days with 0 received emails)
321 let inboxZeroStreak = 0;
322 const todayStr = to.toISOString().slice(0, 10);
323 const streakCursor = new Date(to);
324
325 for (let i = 0; i < days; i++) {
326 const dayStr = streakCursor.toISOString().slice(0, 10);
327 const dayTotal = dayTotals.get(dayStr) ?? 0;
328
329 // A zero-email day counts as "inbox zero" for streak purposes
330 if (dayTotal === 0 && dayStr <= todayStr) {
331 inboxZeroStreak++;
332 } else if (dayTotal > 0 && i > 0) {
333 break; // Streak broken
334 }
335
336 streakCursor.setDate(streakCursor.getDate() - 1);
337 }
338
339 const metrics = {
340 avgResponseTimeSec,
341 emailsPerDay,
342 busiestDay,
343 quietestDay,
344 inboxZeroStreak,
345 totalSent,
346 totalReceived,
347 };
348
349 // Comparison period
350 let compare: {
351 avgResponseTimeDelta: number | null;
352 emailsPerDayDelta: number | null;
353 totalSentDelta: number | null;
354 totalReceivedDelta: number | null;
355 } | null = null;
356
357 if (query.compare === "true") {
358 const prevFrom = new Date(from);
359 prevFrom.setDate(prevFrom.getDate() - days);
360
361 const prevCounts = await db
362 .select({
363 status: emails.status,
364 count: count(),
365 })
366 .from(emails)
367 .where(
368 and(
369 eq(emails.accountId, auth.accountId),
370 gte(emails.createdAt, prevFrom),
371 lte(emails.createdAt, from),
372 ),
373 )
374 .groupBy(emails.status);
375
376 let prevSent = 0;
377 let prevReceived = 0;
378
379 for (const row of prevCounts) {
380 const isSent = ["sent", "delivered", "bounced", "complained"].includes(row.status);
381 if (isSent) {
382 prevSent += row.count;
383 } else {
384 prevReceived += row.count;
385 }
386 }
387
388 const prevEmailsPerDay = days > 0
389 ? Math.round(((prevSent + prevReceived) / days) * 10) / 10
390 : 0;
391
392 compare = {
393 avgResponseTimeDelta: null,
394 emailsPerDayDelta: Math.round((emailsPerDay - prevEmailsPerDay) * 10) / 10,
395 totalSentDelta: totalSent - prevSent,
396 totalReceivedDelta: totalReceived - prevReceived,
397 };
398 }
399
400 return c.json({
401 data: {
402 metrics,
403 compare,
404 },
405 meta: {
406 period: query.period,
407 from: from.toISOString(),
408 to: to.toISOString(),
409 days,
410 },
411 });
412 },
413);
142 const prevEmailsPerDay = days > 0 ? Math.round(((prevSent + prevReceived) / days) * 10) / 10 : 0;
143 compare = { avgResponseTimeDelta: null, emailsPerDayDelta: Math.round((emailsPerDay - prevEmailsPerDay) * 10) / 10, totalSentDelta: totalSent - prevSent, totalReceivedDelta: totalReceived - prevReceived };
144 }
145 return c.json({ data: { metrics, compare }, meta: { period: query.period, from: from.toISOString(), to: to.toISOString(), days } });
146});
414147
415148export { heatmapAnalytics };
Modifiedapps/api/src/routes/voice-clone.ts+97−496View fileUnifiedSplit
11/**
22 * Voice Clone Route — S4: High-fidelity voice cloning for AI replies
3 *
4 * POST /v1/voice-clone/profiles — Create a new style profile
5 * GET /v1/voice-clone/profiles — List user's profiles
6 * GET /v1/voice-clone/profiles/:id — Get profile with confidence score
7 * POST /v1/voice-clone/profiles/:id/train — Train/retrain from recent sent emails
8 * DELETE /v1/voice-clone/profiles/:id — Delete profile
9 * POST /v1/voice-clone/compose — Compose email using a specific voice profile
10 *
11 * DB-backed via voice_style_profiles + voice_training_samples tables.
123 */
134
145import { Hono } from "hono";
156import { z } from "zod";
167import { eq, and, desc } from "drizzle-orm";
178import { requireScope } from "../middleware/auth.js";
18import {
19 validateBody,
20 getValidatedBody,
21} from "../middleware/validator.js";
22import {
23 getDatabase,
24 emails,
25 voiceStyleProfiles,
26 voiceTrainingSamples,
27} from "@alecrae/db";
28import type { StyleFingerprintData, ExtractedFeaturesData } from "@alecrae/db";
29import {
30 buildStyleFingerprint,
31 extractEmailFeatures,
32 calculateConfidence,
33 composeInVoice,
34 type VoiceCloneAIClient,
35} from "@alecrae/ai-engine/voice/style-cloner";
9import { validateBody, getValidatedBody } from "../middleware/validator.js";
10import { getDatabase, emails, voiceStyleProfiles, voiceTrainingSamples } from "@emailed/db";
11import type { StyleFingerprintData } from "@emailed/db";
12import { buildStyleFingerprint, extractEmailFeatures, calculateConfidence, composeInVoice, type VoiceCloneAIClient } from "@emailed/ai-engine/voice/style-cloner";
3613
37// ─── Claude client ───────────────────────────────────────────────────────────
38
39const ANTHROPIC_API_KEY =
40 process.env["ANTHROPIC_API_KEY"] ?? process.env["CLAUDE_API_KEY"];
14const ANTHROPIC_API_KEY = process.env["ANTHROPIC_API_KEY"] ?? process.env["CLAUDE_API_KEY"];
4115
4216const claudeClient: VoiceCloneAIClient = {
4317 async generate(prompt, options) {
44 if (!ANTHROPIC_API_KEY) {
45 throw new Error(
46 "ANTHROPIC_API_KEY is not configured. Voice cloning requires Claude API access.",
47 );
48 }
49
50 const body: Record<string, unknown> = {
51 model: "claude-sonnet-4-20250514",
52 max_tokens: options?.maxTokens ?? 1024,
53 temperature: options?.temperature ?? 0.7,
54 messages: [{ role: "user", content: prompt }],
55 };
18 if (!ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY is not configured.");
19 const body: Record<string, unknown> = { model: "claude-sonnet-4-20250514", max_tokens: options?.maxTokens ?? 1024, temperature: options?.temperature ?? 0.7, messages: [{ role: "user", content: prompt }] };
5620 if (options?.system) body["system"] = options.system;
57
58 const response = await fetch("https://api.anthropic.com/v1/messages", {
59 method: "POST",
60 headers: {
61 "x-api-key": ANTHROPIC_API_KEY,
62 "anthropic-version": "2023-06-01",
63 "content-type": "application/json",
64 },
65 body: JSON.stringify(body),
66 });
67
68 if (!response.ok) {
69 const errText = await response.text();
70 throw new Error(`Claude API error ${response.status}: ${errText}`);
71 }
72
73 const data = (await response.json()) as {
74 content: Array<{ type: string; text?: string }>;
75 };
76 return data.content
77 .filter((b) => b.type === "text")
78 .map((b) => b.text ?? "")
79 .join("");
21 const response = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "x-api-key": ANTHROPIC_API_KEY, "anthropic-version": "2023-06-01", "content-type": "application/json" }, body: JSON.stringify(body) });
22 if (!response.ok) { const errText = await response.text(); throw new Error(`Claude API error ${response.status}: ${errText}`); }
23 const data = (await response.json()) as { content: { type: string; text?: string }[] };
24 return data.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
8025 },
8126};
8227
83// ─── ID generation ──────────────────────────────────────────────────────────
84
8528function generateId(prefix: string): string {
8629 const ts = Date.now().toString(36);
8730 const rand = Math.random().toString(36).slice(2, 10);
8831 return `${prefix}_${ts}${rand}`;
8932}
9033
91// ─── Schemas ─────────────────────────────────────────────────────────────────
92
93const CreateProfileSchema = z.object({
94 name: z.string().min(1).max(100),
95 isDefault: z.boolean().optional().default(false),
96});
97
98const TrainProfileSchema = z.object({
99 sampleSize: z.number().int().min(5).max(500).default(100),
100});
101
34const CreateProfileSchema = z.object({ name: z.string().min(1).max(100), isDefault: z.boolean().optional().default(false) });
35const TrainProfileSchema = z.object({ sampleSize: z.number().int().min(5).max(500).default(100) });
10236const ComposeSchema = z.object({
10337 profileId: z.string().min(1).max(200),
10438 prompt: z.string().min(1).max(4000),
10539 recipient: z.string().max(200).optional(),
106 threadHistory: z
107 .array(
108 z.object({
109 from: z.string().max(200),
110 body: z.string().max(8000),
111 }),
112 )
113 .max(20)
114 .optional(),
115 replyTo: z
116 .object({
117 from: z.string().max(200),
118 subject: z.string().max(500),
119 body: z.string().max(8000),
120 })
121 .optional(),
40 threadHistory: z.array(z.object({ from: z.string().max(200), body: z.string().max(8000) })).max(20).optional(),
41 replyTo: z.object({ from: z.string().max(200), subject: z.string().max(500), body: z.string().max(8000) }).optional(),
12242});
12343
124// ─── Routes ──────────────────────────────────────────────────────────────────
125
12644const voiceClone = new Hono();
12745
128// POST /v1/voice-clone/profiles — Create a new style profile
129voiceClone.post(
130 "/profiles",
131 requireScope("voice:write"),
132 validateBody(CreateProfileSchema),
133 async (c) => {
134 const input = getValidatedBody<z.infer<typeof CreateProfileSchema>>(c);
135 const auth = c.get("auth");
136 const db = getDatabase();
137
138 // If setting as default, un-default all other profiles for this account
139 if (input.isDefault) {
140 const existing = await db
141 .select({ id: voiceStyleProfiles.id })
142 .from(voiceStyleProfiles)
143 .where(
144 and(
145 eq(voiceStyleProfiles.accountId, auth.accountId),
146 eq(voiceStyleProfiles.isDefault, true),
147 ),
148 );
149
150 for (const row of existing) {
151 await db
152 .update(voiceStyleProfiles)
153 .set({ isDefault: false, updatedAt: new Date() })
154 .where(eq(voiceStyleProfiles.id, row.id));
155 }
156 }
157
158 const profileId = generateId("vsp");
159 const now = new Date();
160
161 await db.insert(voiceStyleProfiles).values({
162 id: profileId,
163 accountId: auth.accountId,
164 name: input.name,
165 isDefault: input.isDefault,
166 sampleCount: 0,
167 confidenceScore: 0,
168 isTraining: false,
169 createdAt: now,
170 updatedAt: now,
171 });
172
173 const profile = await db
174 .select()
175 .from(voiceStyleProfiles)
176 .where(eq(voiceStyleProfiles.id, profileId))
177 .limit(1);
178
179 return c.json({ data: profile[0] }, 201);
180 },
181);
182
183// GET /v1/voice-clone/profiles — List user's profiles
184voiceClone.get(
185 "/profiles",
186 requireScope("voice:read"),
187 async (c) => {
188 const auth = c.get("auth");
189 const db = getDatabase();
190
191 const profiles = await db
192 .select()
193 .from(voiceStyleProfiles)
194 .where(eq(voiceStyleProfiles.accountId, auth.accountId))
195 .orderBy(desc(voiceStyleProfiles.createdAt));
196
197 return c.json({
198 data: profiles.map((p) => ({
199 id: p.id,
200 name: p.name,
201 sampleCount: p.sampleCount,
202 confidenceScore: p.confidenceScore,
203 isDefault: p.isDefault,
204 isTraining: p.isTraining,
205 lastTrainedAt: p.lastTrainedAt?.toISOString() ?? null,
206 createdAt: p.createdAt.toISOString(),
207 updatedAt: p.updatedAt.toISOString(),
208 })),
209 });
210 },
211);
212
213// GET /v1/voice-clone/profiles/:id — Get profile with confidence score and fingerprint
214voiceClone.get(
215 "/profiles/:id",
216 requireScope("voice:read"),
217 async (c) => {
218 const profileId = c.req.param("id");
219 const auth = c.get("auth");
220 const db = getDatabase();
221
222 const rows = await db
223 .select()
224 .from(voiceStyleProfiles)
225 .where(
226 and(
227 eq(voiceStyleProfiles.id, profileId),
228 eq(voiceStyleProfiles.accountId, auth.accountId),
229 ),
230 )
231 .limit(1);
232
233 const profile = rows[0];
234 if (!profile) {
235 return c.json(
236 {
237 error: {
238 type: "not_found",
239 message: "Voice style profile not found.",
240 code: "profile_not_found",
241 },
242 },
243 404,
244 );
245 }
246
247 // Count training samples
248 const sampleRows = await db
249 .select({ id: voiceTrainingSamples.id })
250 .from(voiceTrainingSamples)
251 .where(eq(voiceTrainingSamples.profileId, profileId));
252
253 return c.json({
254 data: {
255 id: profile.id,
256 name: profile.name,
257 styleFingerprint: profile.styleFingerprint,
258 sampleCount: profile.sampleCount,
259 confidenceScore: profile.confidenceScore,
260 isDefault: profile.isDefault,
261 isTraining: profile.isTraining,
262 lastTrainedAt: profile.lastTrainedAt?.toISOString() ?? null,
263 trainingSampleCount: sampleRows.length,
264 createdAt: profile.createdAt.toISOString(),
265 updatedAt: profile.updatedAt.toISOString(),
266 },
267 });
268 },
269);
270
271// POST /v1/voice-clone/profiles/:id/train — Train/retrain from recent sent emails
272voiceClone.post(
273 "/profiles/:id/train",
274 requireScope("voice:write"),
275 validateBody(TrainProfileSchema),
276 async (c) => {
277 const profileId = c.req.param("id");
278 const input = getValidatedBody<z.infer<typeof TrainProfileSchema>>(c);
279 const auth = c.get("auth");
280 const db = getDatabase();
281
282 // Verify profile exists and belongs to this account
283 const profileRows = await db
284 .select()
285 .from(voiceStyleProfiles)
286 .where(
287 and(
288 eq(voiceStyleProfiles.id, profileId),
289 eq(voiceStyleProfiles.accountId, auth.accountId),
290 ),
291 )
292 .limit(1);
293
294 const profile = profileRows[0];
295 if (!profile) {
296 return c.json(
297 {
298 error: {
299 type: "not_found",
300 message: "Voice style profile not found.",
301 code: "profile_not_found",
302 },
303 },
304 404,
305 );
306 }
307
308 // Mark as training
309 await db
310 .update(voiceStyleProfiles)
311 .set({ isTraining: true, updatedAt: new Date() })
312 .where(eq(voiceStyleProfiles.id, profileId));
313
314 try {
315 // Fetch sent emails
316 const sentEmails = await db
317 .select({ id: emails.id, textBody: emails.textBody })
318 .from(emails)
319 .where(
320 and(
321 eq(emails.accountId, auth.accountId),
322 eq(emails.status, "delivered"),
323 ),
324 )
325 .orderBy(desc(emails.createdAt))
326 .limit(input.sampleSize);
327
328 const validEmails = sentEmails.filter(
329 (e) => (e.textBody?.length ?? 0) > 20,
330 );
331
332 if (validEmails.length < 5) {
333 await db
334 .update(voiceStyleProfiles)
335 .set({ isTraining: false, updatedAt: new Date() })
336 .where(eq(voiceStyleProfiles.id, profileId));
337
338 return c.json(
339 {
340 error: {
341 type: "insufficient_data",
342 message: `Need at least 5 sent emails to train a voice profile. Found ${validEmails.length}.`,
343 code: "insufficient_samples",
344 },
345 },
346 400,
347 );
348 }
349
350 const texts = validEmails.map((e) => e.textBody ?? "");
351
352 // Build the style fingerprint
353 const fingerprint = await buildStyleFingerprint(auth.accountId, texts);
354 const confidenceScore = calculateConfidence(fingerprint, validEmails.length);
355
356 // Clear old training samples for this profile
357 await db
358 .delete(voiceTrainingSamples)
359 .where(eq(voiceTrainingSamples.profileId, profileId));
360
361 // Insert training samples with extracted features
362 for (const email of validEmails) {
363 const features = extractEmailFeatures(email.textBody ?? "");
364 await db.insert(voiceTrainingSamples).values({
365 id: generateId("vts"),
366 profileId,
367 emailId: email.id,
368 extractedFeatures: features,
369 createdAt: new Date(),
370 });
371 }
372
373 // Update profile with fingerprint and confidence
374 const now = new Date();
375 await db
376 .update(voiceStyleProfiles)
377 .set({
378 styleFingerprint: fingerprint,
379 sampleCount: validEmails.length,
380 confidenceScore,
381 isTraining: false,
382 lastTrainedAt: now,
383 updatedAt: now,
384 })
385 .where(eq(voiceStyleProfiles.id, profileId));
386
387 return c.json({
388 data: {
389 profileId,
390 sampleCount: validEmails.length,
391 confidenceScore,
392 formalityLevel: fingerprint.formalityLevel,
393 emojiUsage: fingerprint.emojiUsage,
394 signaturePhrasesFound: fingerprint.signaturePhrases.length,
395 characteristicWordsFound: fingerprint.vocabularyFingerprint.characteristicWords.length,
396 trainedAt: now.toISOString(),
397 },
398 });
399 } catch (err) {
400 // Ensure isTraining is reset on failure
401 await db
402 .update(voiceStyleProfiles)
403 .set({ isTraining: false, updatedAt: new Date() })
404 .where(eq(voiceStyleProfiles.id, profileId));
405 throw err;
406 }
407 },
408);
409
410// DELETE /v1/voice-clone/profiles/:id — Delete a profile
411voiceClone.delete(
412 "/profiles/:id",
413 requireScope("voice:write"),
414 async (c) => {
415 const profileId = c.req.param("id");
416 const auth = c.get("auth");
417 const db = getDatabase();
418
419 // Verify profile exists and belongs to this account
420 const profileRows = await db
421 .select({ id: voiceStyleProfiles.id })
422 .from(voiceStyleProfiles)
423 .where(
424 and(
425 eq(voiceStyleProfiles.id, profileId),
426 eq(voiceStyleProfiles.accountId, auth.accountId),
427 ),
428 )
429 .limit(1);
430
431 if (profileRows.length === 0) {
432 return c.json(
433 {
434 error: {
435 type: "not_found",
436 message: "Voice style profile not found.",
437 code: "profile_not_found",
438 },
439 },
440 404,
441 );
442 }
443
444 // Delete training samples first (cascade should handle but be explicit)
445 await db
446 .delete(voiceTrainingSamples)
447 .where(eq(voiceTrainingSamples.profileId, profileId));
448
449 // Delete the profile
450 await db
451 .delete(voiceStyleProfiles)
452 .where(eq(voiceStyleProfiles.id, profileId));
453
454 return c.json({ data: { deleted: true, id: profileId } });
455 },
456);
46voiceClone.post("/profiles", requireScope("voice:write"), validateBody(CreateProfileSchema), async (c) => {
47 const input = getValidatedBody<z.infer<typeof CreateProfileSchema>>(c);
48 const auth = c.get("auth");
49 const db = getDatabase();
50 if (input.isDefault) {
51 const existing = await db.select({ id: voiceStyleProfiles.id }).from(voiceStyleProfiles).where(and(eq(voiceStyleProfiles.accountId, auth.accountId), eq(voiceStyleProfiles.isDefault, true)));
52 for (const row of existing) await db.update(voiceStyleProfiles).set({ isDefault: false, updatedAt: new Date() }).where(eq(voiceStyleProfiles.id, row.id));
53 }
54 const profileId = generateId("vsp");
55 const now = new Date();
56 await db.insert(voiceStyleProfiles).values({ id: profileId, accountId: auth.accountId, name: input.name, isDefault: input.isDefault, sampleCount: 0, confidenceScore: 0, isTraining: false, createdAt: now, updatedAt: now });
57 const profile = await db.select().from(voiceStyleProfiles).where(eq(voiceStyleProfiles.id, profileId)).limit(1);
58 return c.json({ data: profile[0] }, 201);
59});
45760
458// POST /v1/voice-clone/compose — Compose email using a specific voice profile
459voiceClone.post(
460 "/compose",
461 requireScope("voice:write"),
462 validateBody(ComposeSchema),
463 async (c) => {
464 const input = getValidatedBody<z.infer<typeof ComposeSchema>>(c);
465 const auth = c.get("auth");
466 const db = getDatabase();
61voiceClone.get("/profiles", requireScope("voice:read"), async (c) => {
62 const auth = c.get("auth");
63 const db = getDatabase();
64 const profiles = await db.select().from(voiceStyleProfiles).where(eq(voiceStyleProfiles.accountId, auth.accountId)).orderBy(desc(voiceStyleProfiles.createdAt));
65 return c.json({ data: profiles.map((p) => ({ id: p.id, name: p.name, sampleCount: p.sampleCount, confidenceScore: p.confidenceScore, isDefault: p.isDefault, isTraining: p.isTraining, lastTrainedAt: p.lastTrainedAt?.toISOString() ?? null, createdAt: p.createdAt.toISOString(), updatedAt: p.updatedAt.toISOString() })) });
66});
46767
468 // Fetch the profile
469 const profileRows = await db
470 .select()
471 .from(voiceStyleProfiles)
472 .where(
473 and(
474 eq(voiceStyleProfiles.id, input.profileId),
475 eq(voiceStyleProfiles.accountId, auth.accountId),
476 ),
477 )
478 .limit(1);
68voiceClone.get("/profiles/:id", requireScope("voice:read"), async (c) => {
69 const profileId = c.req.param("id");
70 const auth = c.get("auth");
71 const db = getDatabase();
72 const rows = await db.select().from(voiceStyleProfiles).where(and(eq(voiceStyleProfiles.id, profileId), eq(voiceStyleProfiles.accountId, auth.accountId))).limit(1);
73 const profile = rows[0];
74 if (!profile) return c.json({ error: { type: "not_found", message: "Voice style profile not found.", code: "profile_not_found" } }, 404);
75 const sampleRows = await db.select({ id: voiceTrainingSamples.id }).from(voiceTrainingSamples).where(eq(voiceTrainingSamples.profileId, profileId));
76 return c.json({ data: { id: profile.id, name: profile.name, styleFingerprint: profile.styleFingerprint, sampleCount: profile.sampleCount, confidenceScore: profile.confidenceScore, isDefault: profile.isDefault, isTraining: profile.isTraining, lastTrainedAt: profile.lastTrainedAt?.toISOString() ?? null, trainingSampleCount: sampleRows.length, createdAt: profile.createdAt.toISOString(), updatedAt: profile.updatedAt.toISOString() } });
77});
47978
480 const profile = profileRows[0];
481 if (!profile) {
482 return c.json(
483 {
484 error: {
485 type: "not_found",
486 message: "Voice style profile not found.",
487 code: "profile_not_found",
488 },
489 },
490 404,
491 );
79voiceClone.post("/profiles/:id/train", requireScope("voice:write"), validateBody(TrainProfileSchema), async (c) => {
80 const profileId = c.req.param("id");
81 const input = getValidatedBody<z.infer<typeof TrainProfileSchema>>(c);
82 const auth = c.get("auth");
83 const db = getDatabase();
84 const profileRows = await db.select().from(voiceStyleProfiles).where(and(eq(voiceStyleProfiles.id, profileId), eq(voiceStyleProfiles.accountId, auth.accountId))).limit(1);
85 const profile = profileRows[0];
86 if (!profile) return c.json({ error: { type: "not_found", message: "Voice style profile not found.", code: "profile_not_found" } }, 404);
87 await db.update(voiceStyleProfiles).set({ isTraining: true, updatedAt: new Date() }).where(eq(voiceStyleProfiles.id, profileId));
88 try {
89 const sentEmails = await db.select({ id: emails.id, textBody: emails.textBody }).from(emails).where(and(eq(emails.accountId, auth.accountId), eq(emails.status, "delivered"))).orderBy(desc(emails.createdAt)).limit(input.sampleSize);
90 const validEmails = sentEmails.filter((e) => (e.textBody?.length ?? 0) > 20);
91 if (validEmails.length < 5) {
92 await db.update(voiceStyleProfiles).set({ isTraining: false, updatedAt: new Date() }).where(eq(voiceStyleProfiles.id, profileId));
93 return c.json({ error: { type: "insufficient_data", message: `Need at least 5 sent emails to train. Found ${validEmails.length}.`, code: "insufficient_samples" } }, 400);
49294 }
493
494 if (!profile.styleFingerprint) {
495 return c.json(
496 {
497 error: {
498 type: "not_trained",
499 message: "Profile has not been trained yet. Run POST /v1/voice-clone/profiles/:id/train first.",
500 code: "profile_not_trained",
501 },
502 },
503 400,
504 );
95 const texts = validEmails.map((e) => e.textBody ?? "");
96 const fingerprint = await buildStyleFingerprint(auth.accountId, texts);
97 const confidenceScore = calculateConfidence(fingerprint, validEmails.length);
98 await db.delete(voiceTrainingSamples).where(eq(voiceTrainingSamples.profileId, profileId));
99 for (const email of validEmails) {
100 const features = extractEmailFeatures(email.textBody ?? "");
101 await db.insert(voiceTrainingSamples).values({ id: generateId("vts"), profileId, emailId: email.id, extractedFeatures: features, createdAt: new Date() });
505102 }
103 const now = new Date();
104 await db.update(voiceStyleProfiles).set({ styleFingerprint: fingerprint, sampleCount: validEmails.length, confidenceScore, isTraining: false, lastTrainedAt: now, updatedAt: now }).where(eq(voiceStyleProfiles.id, profileId));
105 return c.json({ data: { profileId, sampleCount: validEmails.length, confidenceScore, formalityLevel: fingerprint.formalityLevel, emojiUsage: fingerprint.emojiUsage, signaturePhrasesFound: fingerprint.signaturePhrases.length, characteristicWordsFound: fingerprint.vocabularyFingerprint.characteristicWords.length, trainedAt: now.toISOString() } });
106 } catch (err) {
107 await db.update(voiceStyleProfiles).set({ isTraining: false, updatedAt: new Date() }).where(eq(voiceStyleProfiles.id, profileId));
108 throw err;
109 }
110});
506111
507 const fingerprint = profile.styleFingerprint as StyleFingerprintData;
508
509 const result = await composeInVoice(
510 auth.accountId,
511 fingerprint,
512 profile.sampleCount,
513 input.prompt,
514 {
515 recipient: input.recipient,
516 threadHistory: input.threadHistory,
517 replyTo: input.replyTo,
518 },
519 claudeClient,
520 );
112voiceClone.delete("/profiles/:id", requireScope("voice:write"), async (c) => {
113 const profileId = c.req.param("id");
114 const auth = c.get("auth");
115 const db = getDatabase();
116 const profileRows = await db.select({ id: voiceStyleProfiles.id }).from(voiceStyleProfiles).where(and(eq(voiceStyleProfiles.id, profileId), eq(voiceStyleProfiles.accountId, auth.accountId))).limit(1);
117 if (profileRows.length === 0) return c.json({ error: { type: "not_found", message: "Voice style profile not found.", code: "profile_not_found" } }, 404);
118 await db.delete(voiceTrainingSamples).where(eq(voiceTrainingSamples.profileId, profileId));
119 await db.delete(voiceStyleProfiles).where(eq(voiceStyleProfiles.id, profileId));
120 return c.json({ data: { deleted: true, id: profileId } });
121});
521122
522 return c.json({
523 data: {
524 body: result.body,
525 profileId: profile.id,
526 profileName: profile.name,
527 confidenceScore: result.confidenceScore,
528 formalityLevel: fingerprint.formalityLevel,
529 sampleCount: profile.sampleCount,
530 },
531 });
532 },
533);
123voiceClone.post("/compose", requireScope("voice:write"), validateBody(ComposeSchema), async (c) => {
124 const input = getValidatedBody<z.infer<typeof ComposeSchema>>(c);
125 const auth = c.get("auth");
126 const db = getDatabase();
127 const profileRows = await db.select().from(voiceStyleProfiles).where(and(eq(voiceStyleProfiles.id, input.profileId), eq(voiceStyleProfiles.accountId, auth.accountId))).limit(1);
128 const profile = profileRows[0];
129 if (!profile) return c.json({ error: { type: "not_found", message: "Voice style profile not found.", code: "profile_not_found" } }, 404);
130 if (!profile.styleFingerprint) return c.json({ error: { type: "not_trained", message: "Profile has not been trained yet.", code: "profile_not_trained" } }, 400);
131 const fingerprint = profile.styleFingerprint as StyleFingerprintData;
132 const result = await composeInVoice(auth.accountId, fingerprint, profile.sampleCount, input.prompt, { recipient: input.recipient, threadHistory: input.threadHistory, replyTo: input.replyTo }, claudeClient);
133 return c.json({ data: { body: result.body, profileId: profile.id, profileName: profile.name, confidenceScore: result.confidenceScore, formalityLevel: fingerprint.formalityLevel, sampleCount: profile.sampleCount } });
134});
534135
535136export { voiceClone };
Modifiedapps/api/src/routes/voice-message.ts+60−409View fileUnifiedSplit
11/**
22 * Voice Message Route — Voice-to-Voice Replies (B8)
3 *
4 * POST /v1/voice-messages/record — Upload voice message, get transcription + storage URL
5 * POST /v1/voice-messages/transcribe — Transcribe existing audio file
6 * GET /v1/voice-messages/:id — Get voice message metadata + transcript
7 * POST /v1/voice-messages/:id/reply — Reply to a voice message with another voice message
83 */
94
105import { Hono } from "hono";
11import { z } from "zod";
126import { requireScope } from "../middleware/auth.js";
13import {
14 validateBody,
15 getValidatedBody,
16} from "../middleware/validator.js";
177import {
188 processVoiceMessage,
199 transcribeAudio,
2010 formatDuration,
21 generateHtmlEmbed,
22 SUPPORTED_VOICE_AUDIO_TYPES,
2311 MAX_VOICE_AUDIO_SIZE,
24 type VoiceMessageResult,
25} from "@alecrae/ai-engine/voice/voice-message";
26
27// ─── In-memory store (production: persist in Neon + R2) ─────────────────────
12} from "@emailed/ai-engine/voice/voice-message";
2813
2914interface StoredVoiceMessage {
3015 readonly id: string;
4732 return `vm_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
4833}
4934
50// ─── Schemas ────────────────────────────────────────────────────────────────
35const voiceMessageRouter = new Hono();
5136
52const TranscribeBodySchema = z.object({
53 /** Explicit language hint (ISO 639-1). Auto-detected if omitted. */
54 language: z.string().min(2).max(5).optional(),
37voiceMessageRouter.post("/record", requireScope("voice:write"), async (c) => {
38 const auth = c.get("auth");
39 const OPENAI_API_KEY = process.env["OPENAI_API_KEY"];
40 if (!OPENAI_API_KEY) return c.json({ error: { type: "configuration_error", message: "Transcription service not configured. Set OPENAI_API_KEY.", code: "transcription_unavailable" } }, 503);
41 const formData = await c.req.formData();
42 const audioFile = formData.get("audio");
43 const languageHint = formData.get("language") as string | null;
44 if (!audioFile || !(audioFile instanceof File)) return c.json({ error: { type: "validation_error", message: "Missing 'audio' file in form data.", code: "missing_audio" } }, 400);
45 if (audioFile.size > MAX_VOICE_AUDIO_SIZE) return c.json({ error: { type: "validation_error", message: `Audio file too large: ${Math.round(audioFile.size / 1024 / 1024)}MB. Maximum: 25MB.`, code: "audio_too_large" } }, 400);
46 if (audioFile.size === 0) return c.json({ error: { type: "validation_error", message: "Audio file is empty.", code: "empty_audio" } }, 400);
47 const messageId = generateId();
48 const audioUrl = `/v1/voice-messages/${messageId}/audio`;
49 const result = await processVoiceMessage({ audioData: audioFile, mimeType: audioFile.type || "audio/webm", filename: audioFile.name || "voice-message.webm", language: languageHint ?? undefined }, audioUrl, { openaiApiKey: OPENAI_API_KEY });
50 if (!result.ok) {
51 const statusCode = result.error.code === "whisper_error" ? 502 : result.error.code === "whisper_unreachable" ? 502 : 400;
52 return c.json({ error: { type: result.error.code, message: result.error.message, code: result.error.code } }, statusCode);
53 }
54 const stored: StoredVoiceMessage = { id: messageId, accountId: auth.accountId, audioUrl: result.value.audioUrl, mimeType: audioFile.type || "audio/webm", filename: audioFile.name || "voice-message.webm", sizeBytes: audioFile.size, transcriptText: result.value.transcriptText, language: result.value.language, duration: result.value.duration, htmlEmbed: result.value.htmlEmbed, replyToId: null, createdAt: new Date().toISOString() };
55 voiceMessages.set(messageId, stored);
56 return c.json({ data: { id: messageId, audioUrl: stored.audioUrl, transcriptText: stored.transcriptText, language: stored.language, duration: stored.duration, durationFormatted: formatDuration(stored.duration), htmlEmbed: stored.htmlEmbed, sizeBytes: stored.sizeBytes, createdAt: stored.createdAt } });
5557});
5658
57const ReplyBodySchema = z.object({
58 /** Explicit language hint (ISO 639-1). Auto-detected if omitted. */
59 language: z.string().min(2).max(5).optional(),
59voiceMessageRouter.post("/transcribe", requireScope("voice:write"), async (c) => {
60 const OPENAI_API_KEY = process.env["OPENAI_API_KEY"];
61 if (!OPENAI_API_KEY) return c.json({ error: { type: "configuration_error", message: "Transcription service not configured.", code: "transcription_unavailable" } }, 503);
62 const formData = await c.req.formData();
63 const audioFile = formData.get("audio");
64 const languageHint = formData.get("language") as string | null;
65 if (!audioFile || !(audioFile instanceof File)) return c.json({ error: { type: "validation_error", message: "Missing 'audio' file in form data.", code: "missing_audio" } }, 400);
66 const result = await transcribeAudio(audioFile, audioFile.type || "audio/webm", { apiKey: OPENAI_API_KEY, ...(languageHint !== null && languageHint !== undefined ? { language: languageHint } : {}) });
67 if (!result.ok) return c.json({ error: { type: result.error.code, message: result.error.message, code: result.error.code } }, 502);
68 return c.json({ data: { text: result.value.text, language: result.value.language, duration: result.value.duration, durationFormatted: formatDuration(result.value.duration) } });
6069});
6170
62// ─── Routes ─────────────────────────────────────────────────────────────────
63
64const voiceMessageRouter = new Hono();
65
66// POST /v1/voice-messages/record — Upload voice recording, get transcription
67voiceMessageRouter.post(
68 "/record",
69 requireScope("voice:write"),
70 async (c) => {
71 const auth = c.get("auth");
72
73 const OPENAI_API_KEY = process.env["OPENAI_API_KEY"];
74 if (!OPENAI_API_KEY) {
75 return c.json(
76 {
77 error: {
78 type: "configuration_error",
79 message: "Transcription service not configured. Set OPENAI_API_KEY.",
80 code: "transcription_unavailable",
81 },
82 },
83 503,
84 );
85 }
86
87 // Parse multipart form data
88 const formData = await c.req.formData();
89 const audioFile = formData.get("audio");
90 const languageHint = formData.get("language") as string | null;
91
92 if (!audioFile || !(audioFile instanceof File)) {
93 return c.json(
94 {
95 error: {
96 type: "validation_error",
97 message: "Missing 'audio' file in form data. Send a multipart form with an 'audio' field.",
98 code: "missing_audio",
99 },
100 },
101 400,
102 );
103 }
104
105 if (audioFile.size > MAX_VOICE_AUDIO_SIZE) {
106 return c.json(
107 {
108 error: {
109 type: "validation_error",
110 message: `Audio file too large: ${Math.round(audioFile.size / 1024 / 1024)}MB. Maximum: 25MB.`,
111 code: "audio_too_large",
112 },
113 },
114 400,
115 );
116 }
117
118 if (audioFile.size === 0) {
119 return c.json(
120 {
121 error: {
122 type: "validation_error",
123 message: "Audio file is empty.",
124 code: "empty_audio",
125 },
126 },
127 400,
128 );
129 }
130
131 // Generate a storage URL placeholder (production: upload to R2 and get real URL)
132 const messageId = generateId();
133 const audioUrl = `/v1/voice-messages/${messageId}/audio`;
134
135 // Process: validate + transcribe + generate embed
136 const result = await processVoiceMessage(
137 {
138 audioData: audioFile,
139 mimeType: audioFile.type || "audio/webm",
140 filename: audioFile.name || "voice-message.webm",
141 language: languageHint ?? undefined,
142 },
143 audioUrl,
144 { openaiApiKey: OPENAI_API_KEY },
145 );
146
147 if (!result.ok) {
148 const statusCode =
149 result.error.code === "whisper_error" ? 502
150 : result.error.code === "whisper_unreachable" ? 502
151 : 400;
152
153 return c.json(
154 {
155 error: {
156 type: result.error.code,
157 message: result.error.message,
158 code: result.error.code,
159 },
160 },
161 statusCode,
162 );
163 }
164
165 // Store the voice message
166 const stored: StoredVoiceMessage = {
167 id: messageId,
168 accountId: auth.accountId,
169 audioUrl: result.value.audioUrl,
170 mimeType: audioFile.type || "audio/webm",
171 filename: audioFile.name || "voice-message.webm",
172 sizeBytes: audioFile.size,
173 transcriptText: result.value.transcriptText,
174 language: result.value.language,
175 duration: result.value.duration,
176 htmlEmbed: result.value.htmlEmbed,
177 replyToId: null,
178 createdAt: new Date().toISOString(),
179 };
180
181 voiceMessages.set(messageId, stored);
182
183 return c.json({
184 data: {
185 id: messageId,
186 audioUrl: stored.audioUrl,
187 transcriptText: stored.transcriptText,
188 language: stored.language,
189 duration: stored.duration,
190 durationFormatted: formatDuration(stored.duration),
191 htmlEmbed: stored.htmlEmbed,
192 sizeBytes: stored.sizeBytes,
193 createdAt: stored.createdAt,
194 },
195 });
196 },
197);
198
199// POST /v1/voice-messages/transcribe — Transcribe existing audio file
200voiceMessageRouter.post(
201 "/transcribe",
202 requireScope("voice:write"),
203 async (c) => {
204 const OPENAI_API_KEY = process.env["OPENAI_API_KEY"];
205 if (!OPENAI_API_KEY) {
206 return c.json(
207 {
208 error: {
209 type: "configuration_error",
210 message: "Transcription service not configured. Set OPENAI_API_KEY.",
211 code: "transcription_unavailable",
212 },
213 },
214 503,
215 );
216 }
217
218 const formData = await c.req.formData();
219 const audioFile = formData.get("audio");
220 const languageHint = formData.get("language") as string | null;
221
222 if (!audioFile || !(audioFile instanceof File)) {
223 return c.json(
224 {
225 error: {
226 type: "validation_error",
227 message: "Missing 'audio' file in form data.",
228 code: "missing_audio",
229 },
230 },
231 400,
232 );
233 }
234
235 const result = await transcribeAudio(
236 audioFile,
237 audioFile.type || "audio/webm",
238 { language: languageHint ?? undefined, apiKey: OPENAI_API_KEY },
239 );
240
241 if (!result.ok) {
242 return c.json(
243 {
244 error: {
245 type: result.error.code,
246 message: result.error.message,
247 code: result.error.code,
248 },
249 },
250 502,
251 );
252 }
253
254 return c.json({
255 data: {
256 text: result.value.text,
257 language: result.value.language,
258 duration: result.value.duration,
259 durationFormatted: formatDuration(result.value.duration),
260 },
261 });
262 },
263);
264
265// GET /v1/voice-messages/:id — Get voice message metadata + transcript
266voiceMessageRouter.get(
267 "/:id",
268 requireScope("voice:read"),
269 async (c) => {
270 const auth = c.get("auth");
271 const messageId = c.req.param("id");
272
273 const message = voiceMessages.get(messageId);
274
275 if (!message) {
276 return c.json(
277 {
278 error: {
279 type: "not_found",
280 message: `Voice message '${messageId}' not found.`,
281 code: "voice_message_not_found",
282 },
283 },
284 404,
285 );
286 }
287
288 if (message.accountId !== auth.accountId) {
289 return c.json(
290 {
291 error: {
292 type: "forbidden",
293 message: "You do not have access to this voice message.",
294 code: "access_denied",
295 },
296 },
297 403,
298 );
299 }
300
301 return c.json({
302 data: {
303 id: message.id,
304 audioUrl: message.audioUrl,
305 mimeType: message.mimeType,
306 filename: message.filename,
307 sizeBytes: message.sizeBytes,
308 transcriptText: message.transcriptText,
309 language: message.language,
310 duration: message.duration,
311 durationFormatted: formatDuration(message.duration),
312 htmlEmbed: message.htmlEmbed,
313 replyToId: message.replyToId,
314 createdAt: message.createdAt,
315 },
316 });
317 },
318);
319
320// POST /v1/voice-messages/:id/reply — Reply with another voice message
321voiceMessageRouter.post(
322 "/:id/reply",
323 requireScope("voice:write"),
324 async (c) => {
325 const auth = c.get("auth");
326 const parentId = c.req.param("id");
327
328 const OPENAI_API_KEY = process.env["OPENAI_API_KEY"];
329 if (!OPENAI_API_KEY) {
330 return c.json(
331 {
332 error: {
333 type: "configuration_error",
334 message: "Transcription service not configured. Set OPENAI_API_KEY.",
335 code: "transcription_unavailable",
336 },
337 },
338 503,
339 );
340 }
341
342 // Verify parent exists
343 const parent = voiceMessages.get(parentId);
344 if (!parent) {
345 return c.json(
346 {
347 error: {
348 type: "not_found",
349 message: `Voice message '${parentId}' not found.`,
350 code: "voice_message_not_found",
351 },
352 },
353 404,
354 );
355 }
356
357 // Parse audio reply
358 const formData = await c.req.formData();
359 const audioFile = formData.get("audio");
360 const languageHint = formData.get("language") as string | null;
361
362 if (!audioFile || !(audioFile instanceof File)) {
363 return c.json(
364 {
365 error: {
366 type: "validation_error",
367 message: "Missing 'audio' file in form data.",
368 code: "missing_audio",
369 },
370 },
371 400,
372 );
373 }
374
375 if (audioFile.size > MAX_VOICE_AUDIO_SIZE) {
376 return c.json(
377 {
378 error: {
379 type: "validation_error",
380 message: `Audio file too large: ${Math.round(audioFile.size / 1024 / 1024)}MB. Maximum: 25MB.`,
381 code: "audio_too_large",
382 },
383 },
384 400,
385 );
386 }
387
388 const replyId = generateId();
389 const audioUrl = `/v1/voice-messages/${replyId}/audio`;
390
391 const result = await processVoiceMessage(
392 {
393 audioData: audioFile,
394 mimeType: audioFile.type || "audio/webm",
395 filename: audioFile.name || "voice-reply.webm",
396 language: languageHint ?? undefined,
397 },
398 audioUrl,
399 { openaiApiKey: OPENAI_API_KEY },
400 );
401
402 if (!result.ok) {
403 return c.json(
404 {
405 error: {
406 type: result.error.code,
407 message: result.error.message,
408 code: result.error.code,
409 },
410 },
411 502,
412 );
413 }
414
415 const stored: StoredVoiceMessage = {
416 id: replyId,
417 accountId: auth.accountId,
418 audioUrl: result.value.audioUrl,
419 mimeType: audioFile.type || "audio/webm",
420 filename: audioFile.name || "voice-reply.webm",
421 sizeBytes: audioFile.size,
422 transcriptText: result.value.transcriptText,
423 language: result.value.language,
424 duration: result.value.duration,
425 htmlEmbed: result.value.htmlEmbed,
426 replyToId: parentId,
427 createdAt: new Date().toISOString(),
428 };
429
430 voiceMessages.set(replyId, stored);
71voiceMessageRouter.get("/:id", requireScope("voice:read"), async (c) => {
72 const auth = c.get("auth");
73 const messageId = c.req.param("id");
74 const message = voiceMessages.get(messageId);
75 if (!message) return c.json({ error: { type: "not_found", message: `Voice message '${messageId}' not found.`, code: "voice_message_not_found" } }, 404);
76 if (message.accountId !== auth.accountId) return c.json({ error: { type: "forbidden", message: "You do not have access to this voice message.", code: "access_denied" } }, 403);
77 return c.json({ data: { id: message.id, audioUrl: message.audioUrl, mimeType: message.mimeType, filename: message.filename, sizeBytes: message.sizeBytes, transcriptText: message.transcriptText, language: message.language, duration: message.duration, durationFormatted: formatDuration(message.duration), htmlEmbed: message.htmlEmbed, replyToId: message.replyToId, createdAt: message.createdAt } });
78});
43179
432 return c.json({
433 data: {
434 id: replyId,
435 audioUrl: stored.audioUrl,
436 transcriptText: stored.transcriptText,
437 language: stored.language,
438 duration: stored.duration,
439 durationFormatted: formatDuration(stored.duration),
440 htmlEmbed: stored.htmlEmbed,
441 replyToId: parentId,
442 parentTranscript: parent.transcriptText,
443 sizeBytes: stored.sizeBytes,
444 createdAt: stored.createdAt,
445 },
446 });
447 },
448);
80voiceMessageRouter.post("/:id/reply", requireScope("voice:write"), async (c) => {
81 const auth = c.get("auth");
82 const parentId = c.req.param("id");
83 const OPENAI_API_KEY = process.env["OPENAI_API_KEY"];
84 if (!OPENAI_API_KEY) return c.json({ error: { type: "configuration_error", message: "Transcription service not configured.", code: "transcription_unavailable" } }, 503);
85 const parent = voiceMessages.get(parentId);
86 if (!parent) return c.json({ error: { type: "not_found", message: `Voice message '${parentId}' not found.`, code: "voice_message_not_found" } }, 404);
87 const formData = await c.req.formData();
88 const audioFile = formData.get("audio");
89 const languageHint = formData.get("language") as string | null;
90 if (!audioFile || !(audioFile instanceof File)) return c.json({ error: { type: "validation_error", message: "Missing 'audio' file in form data.", code: "missing_audio" } }, 400);
91 if (audioFile.size > MAX_VOICE_AUDIO_SIZE) return c.json({ error: { type: "validation_error", message: `Audio file too large.`, code: "audio_too_large" } }, 400);
92 const replyId = generateId();
93 const audioUrl = `/v1/voice-messages/${replyId}/audio`;
94 const result = await processVoiceMessage({ audioData: audioFile, mimeType: audioFile.type || "audio/webm", filename: audioFile.name || "voice-reply.webm", language: languageHint ?? undefined }, audioUrl, { openaiApiKey: OPENAI_API_KEY });
95 if (!result.ok) return c.json({ error: { type: result.error.code, message: result.error.message, code: result.error.code } }, 502);
96 const stored: StoredVoiceMessage = { id: replyId, accountId: auth.accountId, audioUrl: result.value.audioUrl, mimeType: audioFile.type || "audio/webm", filename: audioFile.name || "voice-reply.webm", sizeBytes: audioFile.size, transcriptText: result.value.transcriptText, language: result.value.language, duration: result.value.duration, htmlEmbed: result.value.htmlEmbed, replyToId: parentId, createdAt: new Date().toISOString() };
97 voiceMessages.set(replyId, stored);
98 return c.json({ data: { id: replyId, audioUrl: stored.audioUrl, transcriptText: stored.transcriptText, language: stored.language, duration: stored.duration, durationFormatted: formatDuration(stored.duration), htmlEmbed: stored.htmlEmbed, replyToId: parentId, parentTranscript: parent.transcriptText, sizeBytes: stored.sizeBytes, createdAt: stored.createdAt } });
99});
449100
450101export { voiceMessageRouter };
Modifiedapps/api/src/routes/voice.ts+82−271View fileUnifiedSplit
11/**
22 * Voice Route — AI Writing Style Analysis & Draft Generation
3 *
4 * POST /v1/voice/analyze — Trigger voice profile analysis from sent emails
5 * GET /v1/voice/profile — Get current voice profile
6 * POST /v1/voice/draft — Generate email draft in user's voice
7 * POST /v1/voice/adjust — Adjust tone of existing text
83 */
94
105import { Hono } from "hono";
116import { z } from "zod";
127import { eq, and, desc } from "drizzle-orm";
138import { requireScope } from "../middleware/auth.js";
14import {
15 validateBody,
16 getValidatedBody,
17} from "../middleware/validator.js";
18import { getDatabase, emails } from "@alecrae/db";
19
20// ─── Lazy import the AI compose module ───────────────────────────────────────
21// The ai-engine is a separate service; we import its core classes directly.
22
23let assistantModule: typeof import("@alecrae/ai-engine/compose") | null = null;
24
25async function getComposeModule() {
26 if (!assistantModule) {
27 try {
28 assistantModule = await import("@alecrae/ai-engine/compose") as typeof import("@alecrae/ai-engine/compose");
29 } catch {
30 // Fallback: inline minimal implementation
31 return null;
32 }
33 }
34 return assistantModule;
35}
36
37// ─── In-memory voice profile cache (production: use DB or Redis) ─────────────
9import { validateBody, getValidatedBody } from "../middleware/validator.js";
10import { getDatabase, emails } from "@emailed/db";
3811
3912const voiceProfiles = new Map<string, unknown>();
4013
41// ─── Claude AI client adapter ────────────────────────────────────────────────
42
4314const ANTHROPIC_API_KEY = process.env["ANTHROPIC_API_KEY"] ?? process.env["CLAUDE_API_KEY"];
4415
4516async function generateWithClaude(
4617 prompt: string,
4718 options?: { maxTokens?: number; temperature?: number },
4819): Promise<string> {
49 if (!ANTHROPIC_API_KEY) {
50 throw new Error("ANTHROPIC_API_KEY is not configured. Voice features require Claude API access.");
51 }
52
20 if (!ANTHROPIC_API_KEY) throw new Error("ANTHROPIC_API_KEY is not configured.");
5321 const response = await fetch("https://api.anthropic.com/v1/messages", {
5422 method: "POST",
5523 headers: {
6331 messages: [{ role: "user", content: prompt }],
6432 }),
6533 });
66
6734 if (!response.ok) {
6835 const errText = await response.text();
6936 throw new Error(`Claude API error ${response.status}: ${errText}`);
7037 }
71
72 const data = (await response.json()) as {
73 content: Array<{ type: string; text?: string }>;
74 };
75
76 return data.content
77 .filter((block) => block.type === "text")
78 .map((block) => block.text ?? "")
79 .join("");
38 const data = (await response.json()) as { content: { type: string; text?: string }[] };
39 return data.content.filter((b) => b.type === "text").map((b) => b.text ?? "").join("");
8040}
8141
82// ─── Schemas ──────────────────────────────────────────────────────────────────
83
84const AnalyzeSchema = z.object({
85 /** Number of recent sent emails to analyze (default 50, max 200) */
86 sampleSize: z.number().int().min(5).max(200).default(50),
87});
88
42const AnalyzeSchema = z.object({ sampleSize: z.number().int().min(5).max(200).default(50) });
8943const DraftSchema = z.object({
90 /** Brief description of what the email should say */
9144 instructions: z.string().min(1).max(2000),
92 /** Target tone */
93 tone: z
94 .enum(["professional", "casual", "friendly", "formal", "urgent", "empathetic", "assertive"])
95 .default("professional"),
96 /** Desired length */
45 tone: z.enum(["professional", "casual", "friendly", "formal", "urgent", "empathetic", "assertive"]).default("professional"),
9746 length: z.enum(["brief", "moderate", "detailed"]).default("moderate"),
98 /** Recipient name (for greeting) */
9947 recipientName: z.string().optional(),
100 /** Subject line (optional — AI will suggest one if not provided) */
10148 subject: z.string().optional(),
102 /** Original email to reply to (for context) */
103 replyTo: z
104 .object({
105 from: z.string(),
106 subject: z.string(),
107 body: z.string(),
108 })
109 .optional(),
49 replyTo: z.object({ from: z.string(), subject: z.string(), body: z.string() }).optional(),
11050});
111
11251const AdjustSchema = z.object({
11352 body: z.string().min(1).max(10000),
114 tone: z.enum([
115 "professional",
116 "casual",
117 "friendly",
118 "formal",
119 "urgent",
120 "empathetic",
121 "assertive",
122 ]),
53 tone: z.enum(["professional", "casual", "friendly", "formal", "urgent", "empathetic", "assertive"]),
12354});
12455
125// ─── Routes ───────────────────────────────────────────────────────────────────
126
12756const voice = new Hono();
12857
129// POST /v1/voice/analyze — Build voice profile from sent emails
130voice.post(
131 "/analyze",
132 requireScope("voice:write"),
133 validateBody(AnalyzeSchema),
134 async (c) => {
135 const input = getValidatedBody<z.infer<typeof AnalyzeSchema>>(c);
136 const auth = c.get("auth");
137 const db = getDatabase();
138
139 // Fetch recent sent emails for voice analysis
140 const sentEmails = await db
141 .select({
142 textBody: emails.textBody,
143 subject: emails.subject,
144 })
145 .from(emails)
146 .where(and(eq(emails.accountId, auth.accountId), eq(emails.status, "delivered")))
147 .orderBy(desc(emails.createdAt))
148 .limit(input.sampleSize);
149
150 if (sentEmails.length < 5) {
151 return c.json(
152 {
153 error: {
154 type: "insufficient_data",
155 message: `Need at least 5 sent emails to build a voice profile. Found ${sentEmails.length}.`,
156 code: "insufficient_samples",
157 },
158 },
159 400,
160 );
161 }
162
163 // Build voice profile using text analysis
164 const texts = sentEmails
165 .map((e) => e.textBody ?? "")
166 .filter((t) => t.length > 20);
167
168 const allWords = texts.join(" ").toLowerCase().replace(/[^a-z\s]/g, "").split(/\s+/);
169 const sentences = texts.flatMap((t) =>
170 t.split(/[.!?]+/).filter((s) => s.trim().length > 0),
171 );
172 const avgSentenceLength =
173 sentences.reduce((sum, s) => sum + s.trim().split(/\s+/).length, 0) /
174 Math.max(sentences.length, 1);
175
176 const uniqueWords = new Set(allWords);
177 const typeTokenRatio = uniqueWords.size / Math.max(allWords.length, 1);
178 const avgWordLength =
179 allWords.reduce((sum, w) => sum + w.length, 0) / Math.max(allWords.length, 1);
180
181 let vocabularyLevel: "simple" | "moderate" | "advanced";
182 if (typeTokenRatio > 0.6 && avgWordLength > 5.5) vocabularyLevel = "advanced";
183 else if (typeTokenRatio > 0.4 || avgWordLength > 4.5) vocabularyLevel = "moderate";
184 else vocabularyLevel = "simple";
185
186 const profile = {
187 accountId: auth.accountId,
188 averageSentenceLength: Math.round(avgSentenceLength * 10) / 10,
189 vocabularyLevel,
190 sampleCount: sentEmails.length,
191 analyzedAt: new Date().toISOString(),
192 };
193
194 // Cache the profile
195 voiceProfiles.set(auth.accountId, profile);
196
197 return c.json({ data: profile });
198 },
199);
200
201// GET /v1/voice/profile — Get current voice profile
202voice.get(
203 "/profile",
204 requireScope("voice:read"),
205 async (c) => {
206 const auth = c.get("auth");
207
208 const profile = voiceProfiles.get(auth.accountId);
209 if (!profile) {
210 return c.json(
211 {
212 error: {
213 type: "not_found",
214 message: "No voice profile found. Run POST /v1/voice/analyze first.",
215 code: "profile_not_found",
216 },
217 },
218 404,
219 );
220 }
221
222 return c.json({ data: profile });
223 },
224);
225
226// POST /v1/voice/draft — Generate email draft in user's voice
227voice.post(
228 "/draft",
229 requireScope("voice:write"),
230 validateBody(DraftSchema),
231 async (c) => {
232 const input = getValidatedBody<z.infer<typeof DraftSchema>>(c);
233 const auth = c.get("auth");
234
235 const profile = voiceProfiles.get(auth.accountId) as Record<string, unknown> | undefined;
236
237 const parts: string[] = [
238 "You are an AI email writing assistant. Write an email based on these instructions.",
239 `Tone: ${input.tone}`,
240 `Length: ${input.length}`,
241 ];
242
243 if (profile) {
244 parts.push("");
245 parts.push("Match this writing style:");
246 parts.push(`- Average sentence length: ~${profile["averageSentenceLength"]} words`);
247 parts.push(`- Vocabulary level: ${profile["vocabularyLevel"]}`);
248 }
249
250 if (input.recipientName) {
251 parts.push(`\nRecipient name: ${input.recipientName}`);
252 }
253
254 if (input.replyTo) {
255 parts.push("\n--- Original Email ---");
256 parts.push(`From: ${input.replyTo.from}`);
257 parts.push(`Subject: ${input.replyTo.subject}`);
258 parts.push(`Body: ${input.replyTo.body.slice(0, 1500)}`);
259 parts.push("--- End Original ---");
260 parts.push("\nWrite a reply to the above email.");
261 }
262
263 parts.push(`\nInstructions: ${input.instructions}`);
264 parts.push("\nWrite only the email body. No subject line, no headers, no preamble.");
265
266 const maxTokens =
267 input.length === "brief" ? 300 : input.length === "detailed" ? 1500 : 800;
268
269 const body = await generateWithClaude(parts.join("\n"), { maxTokens });
270
271 // Generate subject if not provided
272 let subject = input.subject;
273 if (!subject) {
274 const subjectPrompt = `Based on this email body, suggest a concise subject line (max 10 words, no quotes):\n\n${body.slice(0, 500)}`;
275 subject = await generateWithClaude(subjectPrompt, { maxTokens: 50 });
276 subject = subject.trim().replace(/^["']|["']$/g, "");
277 }
278
279 return c.json({
280 data: {
281 subject,
282 body: body.trim(),
283 tone: input.tone,
284 },
285 });
286 },
287);
288
289// POST /v1/voice/adjust — Adjust tone of existing text
290voice.post(
291 "/adjust",
292 requireScope("voice:write"),
293 validateBody(AdjustSchema),
294 async (c) => {
295 const input = getValidatedBody<z.infer<typeof AdjustSchema>>(c);
296 const auth = c.get("auth");
297
298 const profile = voiceProfiles.get(auth.accountId) as Record<string, unknown> | undefined;
299
300 const parts: string[] = [
301 `Rewrite the following email with a ${input.tone} tone.`,
302 ];
58voice.post("/analyze", requireScope("voice:write"), validateBody(AnalyzeSchema), async (c) => {
59 const input = getValidatedBody<z.infer<typeof AnalyzeSchema>>(c);
60 const auth = c.get("auth");
61 const db = getDatabase();
62 const sentEmails = await db.select({ textBody: emails.textBody, subject: emails.subject }).from(emails).where(and(eq(emails.accountId, auth.accountId), eq(emails.status, "delivered"))).orderBy(desc(emails.createdAt)).limit(input.sampleSize);
63 if (sentEmails.length < 5) {
64 return c.json({ error: { type: "insufficient_data", message: `Need at least 5 sent emails. Found ${sentEmails.length}.`, code: "insufficient_samples" } }, 400);
65 }
66 const texts = sentEmails.map((e) => e.textBody ?? "").filter((t) => t.length > 20);
67 const allWords = texts.join(" ").toLowerCase().replace(/[^a-z\s]/g, "").split(/\s+/);
68 const sentences = texts.flatMap((t) => t.split(/[.!?]+/).filter((s) => s.trim().length > 0));
69 const avgSentenceLength = sentences.reduce((sum, s) => sum + s.trim().split(/\s+/).length, 0) / Math.max(sentences.length, 1);
70 const uniqueWords = new Set(allWords);
71 const typeTokenRatio = uniqueWords.size / Math.max(allWords.length, 1);
72 const avgWordLength = allWords.reduce((sum, w) => sum + w.length, 0) / Math.max(allWords.length, 1);
73 let vocabularyLevel: "simple" | "moderate" | "advanced";
74 if (typeTokenRatio > 0.6 && avgWordLength > 5.5) vocabularyLevel = "advanced";
75 else if (typeTokenRatio > 0.4 || avgWordLength > 4.5) vocabularyLevel = "moderate";
76 else vocabularyLevel = "simple";
77 const profile = { accountId: auth.accountId, averageSentenceLength: Math.round(avgSentenceLength * 10) / 10, vocabularyLevel, sampleCount: sentEmails.length, analyzedAt: new Date().toISOString() };
78 voiceProfiles.set(auth.accountId, profile);
79 return c.json({ data: profile });
80});
30381
304 if (profile) {
305 parts.push(`Maintain the user's writing style (avg sentence length: ~${profile["averageSentenceLength"]} words, vocabulary: ${profile["vocabularyLevel"]}).`);
306 }
82voice.get("/profile", requireScope("voice:read"), async (c) => {
83 const auth = c.get("auth");
84 const profile = voiceProfiles.get(auth.accountId);
85 if (!profile) return c.json({ error: { type: "not_found", message: "No voice profile found.", code: "profile_not_found" } }, 404);
86 return c.json({ data: profile });
87});
30788
89voice.post("/draft", requireScope("voice:write"), validateBody(DraftSchema), async (c) => {
90 const input = getValidatedBody<z.infer<typeof DraftSchema>>(c);
91 const auth = c.get("auth");
92 const profile = voiceProfiles.get(auth.accountId) as Record<string, unknown> | undefined;
93 const parts: string[] = ["You are an AI email writing assistant. Write an email based on these instructions.", `Tone: ${input.tone}`, `Length: ${input.length}`];
94 if (profile) {
30895 parts.push("");
309 parts.push("Original email:");
310 parts.push(input.body);
311 parts.push("");
312 parts.push("Rewritten email (body only, no preamble):");
313
314 const body = await generateWithClaude(parts.join("\n"), { maxTokens: 1500 });
96 parts.push("Match this writing style:");
97 parts.push(`- Average sentence length: ~${profile["averageSentenceLength"]} words`);
98 parts.push(`- Vocabulary level: ${profile["vocabularyLevel"]}`);
99 }
100 if (input.recipientName) parts.push(`\nRecipient name: ${input.recipientName}`);
101 if (input.replyTo) {
102 parts.push("\n--- Original Email ---");
103 parts.push(`From: ${input.replyTo.from}`);
104 parts.push(`Subject: ${input.replyTo.subject}`);
105 parts.push(`Body: ${input.replyTo.body.slice(0, 1500)}`);
106 parts.push("--- End Original ---");
107 parts.push("\nWrite a reply to the above email.");
108 }
109 parts.push(`\nInstructions: ${input.instructions}`);
110 parts.push("\nWrite only the email body. No subject line, no headers, no preamble.");
111 const maxTokens = input.length === "brief" ? 300 : input.length === "detailed" ? 1500 : 800;
112 const body = await generateWithClaude(parts.join("\n"), { maxTokens });
113 let subject = input.subject;
114 if (!subject) {
115 subject = await generateWithClaude(`Based on this email body, suggest a concise subject line (max 10 words, no quotes):\n\n${body.slice(0, 500)}`, { maxTokens: 50 });
116 subject = subject.trim().replace(/^["']|["']$/g, "");
117 }
118 return c.json({ data: { subject, body: body.trim(), tone: input.tone } });
119});
315120
316 return c.json({
317 data: {
318 body: body.trim(),
319 tone: input.tone,
320 },
321 });
322 },
323);
121voice.post("/adjust", requireScope("voice:write"), validateBody(AdjustSchema), async (c) => {
122 const input = getValidatedBody<z.infer<typeof AdjustSchema>>(c);
123 const auth = c.get("auth");
124 const profile = voiceProfiles.get(auth.accountId) as Record<string, unknown> | undefined;
125 const parts: string[] = [`Rewrite the following email with a ${input.tone} tone.`];
126 if (profile) parts.push(`Maintain the user's writing style (avg sentence length: ~${profile["averageSentenceLength"]} words, vocabulary: ${profile["vocabularyLevel"]}).`);
127 parts.push("");
128 parts.push("Original email:");
129 parts.push(input.body);
130 parts.push("");
131 parts.push("Rewritten email (body only, no preamble):");
132 const body = await generateWithClaude(parts.join("\n"), { maxTokens: 1500 });
133 return c.json({ data: { body: body.trim(), tone: input.tone } });
134});
324135
325136export { voice };
Modifiedapps/web/app/(dashboard)/layout.tsx+14−61View fileUnifiedSplit
33import type { JSX } from "react";
44import { useState, useEffect } from "react";
55import { usePathname } from "next/navigation";
6import { Box, Text, type SidebarSection } from "@alecrae/ui";
6import { Box, Text } from "@emailed/ui";
77import { AnimatedSidebar, type AnimatedSidebarSection } from "../../components/AnimatedSidebar";
88import { AnimatedPage } from "../../components/AnimatedPage";
99import { FocusModeOverlay, type FocusModeOverlayEmail } from "../../components/FocusModeOverlay";
4444 const hydrate = useFocusMode((s) => s.hydrate);
4545 const toggleFocusMode = useFocusMode((s) => s.toggleFocusMode);
4646
47 // Hydrate focus mode state from IndexedDB on mount
4847 useEffect(() => {
4948 void hydrate();
5049 }, [hydrate]);
5150
52 // Register Cmd+Shift+F keyboard shortcut for focus mode
5351 useEffect(() => {
5452 const handleKeyDown = (e: KeyboardEvent): void => {
5553 if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key.toLowerCase() === "f") {
5755 void toggleFocusMode();
5856 }
5957 };
60
6158 window.addEventListener("keydown", handleKeyDown);
6259 return () => window.removeEventListener("keydown", handleKeyDown);
6360 }, [toggleFocusMode]);
6461
6562 useEffect(() => {
66 authApi
67 .me()
68 .then((res) => {
69 setUser({ name: res.data.name, email: res.data.email });
70 })
71 .catch(() => {
72 // Fallback to stored token info or defaults
73 });
63 authApi.me().then((res) => {
64 setUser({ name: res.data.name, email: res.data.email });
65 }).catch(() => {});
7466 }, []);
7567
7668 const sectionsWithActive: AnimatedSidebarSection[] = navigationSections.map((section) => ({
8173 })),
8274 }));
8375
84 const initials = user.name
85 .split(" ")
86 .map((n) => n[0])
87 .join("")
88 .toUpperCase()
89 .slice(0, 2) || "U";
76 const initials = user.name.split(" ").map((n) => n[0]).join("").toUpperCase().slice(0, 2) || "U";
9077
9178 const brand = (
9279 <Box className="flex items-center justify-between">
93 <Text variant="heading-md" className="text-brand-600 font-bold">
94 AlecRae
95 </Text>
96 <Box
97 as="button"
98 className="text-content-tertiary hover:text-content transition-colors"
99 onClick={() => setCollapsed((prev) => !prev)}
100 aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
101 >
102 <Text as="span" variant="body-sm">
103 {collapsed ? "\u276F" : "\u276E"}
104 </Text>
80 <Text variant="heading-md" className="text-brand-600 font-bold">AlecRae</Text>
81 <Box as="button" className="text-content-tertiary hover:text-content transition-colors" onClick={() => setCollapsed((prev) => !prev)} aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}>
82 <Text as="span" variant="body-sm">{collapsed ? "\u276F" : "\u276E"}</Text>
10583 </Box>
10684 </Box>
10785 );
11492 const footer = (
11593 <Box className="flex items-center gap-3">
11694 <Box className="w-8 h-8 rounded-full bg-brand-100 flex items-center justify-center">
117 <Text variant="caption" className="text-brand-700 font-semibold">
118 {initials}
119 </Text>
95 <Text variant="caption" className="text-brand-700 font-semibold">{initials}</Text>
12096 </Box>
12197 {!collapsed && (
12298 <>
12399 <Box className="flex-1 min-w-0">
124 <Text variant="body-sm" className="truncate font-medium">
125 {user.name}
126 </Text>
127 <Text variant="caption" className="truncate">
128 {user.email}
129 </Text>
100 <Text variant="body-sm" className="truncate font-medium">{user.name}</Text>
101 <Text variant="caption" className="truncate">{user.email}</Text>
130102 </Box>
131 <Box
132 as="button"
133 className="text-content-tertiary hover:text-content transition-colors p-1"
134 onClick={handleLogout}
135 aria-label="Sign out"
136 title="Sign out"
137 >
138 <Text as="span" variant="caption">
139 Sign out
140 </Text>
103 <Box as="button" className="text-content-tertiary hover:text-content transition-colors p-1" onClick={handleLogout} aria-label="Sign out" title="Sign out">
104 <Text as="span" variant="caption">Sign out</Text>
141105 </Box>
142106 </>
143107 )}
144108 </Box>
145109 );
146110
147 // Placeholder email list for focus mode overlay.
148 // In production this comes from the inbox store / IndexedDB cache.
149 // The overlay filters them by the active focus criteria.
150111 const focusModeEmails: FocusModeOverlayEmail[] = [];
151112
152113 return (
153114 <Box className="flex h-full">
154 <AnimatedSidebar
155 brand={brand}
156 sections={sectionsWithActive}
157 footer={footer}
158 collapsed={collapsed}
159 />
115 <AnimatedSidebar brand={brand} sections={sectionsWithActive} footer={footer} collapsed={collapsed} />
160116 <Box as="main" className="flex-1 flex flex-col min-h-0 overflow-hidden">
161 {/* Toolbar bar with focus mode toggle */}
162117 <Box className="flex items-center justify-end gap-2 px-4 py-2 border-b border-border bg-surface-secondary/50">
163118 <FocusModeToggle />
164119 </Box>
166121 {children}
167122 </AnimatedPage>
168123 </Box>
169
170 {/* Focus Mode Overlay — covers entire screen when active */}
171124 <FocusModeOverlay emails={focusModeEmails} />
172125 </Box>
173126 );
Modifiedapps/web/app/icon.svg+1−31View fileUnifiedSplit
11<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
2 <!--
3 AlecRae app icon.
4 Gold envelope with a dark bronze AR monogram.
5 Dark warm-charcoal background for luxury feel.
6 Designed to read sophisticated at favicon size (32px) and hero size (512px).
7 -->
82 <defs>
93 <linearGradient id="goldBody" x1="50%" y1="0%" x2="50%" y2="100%">
104 <stop offset="0%" stop-color="#efc870"/>
1610 <stop offset="100%" stop-color="#caa02a"/>
1711 </linearGradient>
1812 </defs>
19
20 <!-- Warm charcoal background, rounded square -->
2113 <rect width="512" height="512" rx="110" fill="#0b0a08"/>
22
23 <!-- Envelope body (full rectangle) -->
2414 <rect x="76" y="146" width="360" height="220" rx="12" fill="url(#goldBody)"/>
25
26 <!-- Soft shadow under the flap to give depth -->
2715 <path d="M76 158 L256 276 L436 158 L436 166 L256 284 L76 166 Z" fill="#000" opacity="0.18"/>
28
29 <!-- Flap triangle (lighter gold — the visible folded flap) -->
3016 <path d="M76 158 L256 276 L436 158 Z" fill="url(#goldFlap)"/>
31
32 <!-- Subtle highlight along the flap crease -->
3317 <path d="M76 158 L256 276 L436 158" fill="none" stroke="#fce8a0" stroke-width="1.5" opacity="0.5"/>
34
35 <!-- Outer envelope border (subtle definition) -->
3618 <rect x="76" y="146" width="360" height="220" rx="12" fill="none" stroke="#6a5118" stroke-width="2" opacity="0.55"/>
37
38 <!-- AR monogram: italic serif, tight kerning, deep bronze -->
39 <text
40 x="256"
41 y="352"
42 text-anchor="middle"
43 font-family="Georgia, 'Times New Roman', serif"
44 font-size="98"
45 font-weight="400"
46 font-style="italic"
47 fill="#1a1205"
48 letter-spacing="-6"
49 >AR</text>
19 <text x="256" y="360" text-anchor="middle" font-family="Italianno, 'Snell Roundhand', 'Apple Chancery', cursive" font-size="140" font-weight="400" fill="#1a1205">AR</text>
5020</svg>
Modifiedapps/web/components/CollaborativeDraftView.tsx+13−72View fileUnifiedSplit
1111 type CollabInvite,
1212 type CollabHistoryEntry,
1313 type Collaborator,
14} from "@alecrae/ui";
15import {
16 useCollaborativeDraft,
17 type UseCollaborativeDraftOptions,
18} from "../lib/use-collaborative-draft";
14} from "@emailed/ui";
15import { useCollaborativeDraft } from "../lib/use-collaborative-draft";
1916import { collaborationApi } from "../lib/api";
2017
21// ─── Types ──────────────────────────────────────────────────────────────────
22
2318export interface CollaborativeDraftViewProps {
24 /** Config for the collaboration session (from API). */
2519 draftId: string;
2620 sessionId: string;
2721 token: string;
28 /** Session metadata. */
2922 session: CollabSessionInfo;
30 /** Current user info. */
3123 user: {
3224 userId: string;
3325 name: string;
3426 avatarUrl?: string | undefined;
3527 cursorColor?: string | undefined;
3628 };
37 /** Whether the current user is the session owner. */
3829 isOwner?: boolean | undefined;
39 /** Initial participants from the API. */
4030 initialParticipants?: Collaborator[] | undefined;
41 /** Initial pending invites from the API. */
4231 initialInvites?: CollabInvite[] | undefined;
43 /** @deprecated API base URL — now uses centralized apiFetch. */
4432 apiBaseUrl?: string | undefined;
45 /** @deprecated Auth token — now uses centralized apiFetch. */
4633 apiToken?: string | undefined;
47 /** Callback when the draft is sent. */
4834 onSend?: (() => void) | undefined;
49 /** Callback when the draft content changes. */
5035 onContentChange?: ((content: { text: string; html: string }) => void) | undefined;
51 /** WebSocket endpoint override. */
5236 collabEndpoint?: string | undefined;
5337 className?: string | undefined;
5438}
5539
56// ─── Component ───────────────────────────────────────────────────────────────
57
5840export function CollaborativeDraftView({
5941 draftId,
6042 sessionId,
6446 isOwner = false,
6547 initialParticipants = [],
6648 initialInvites = [],
67 apiBaseUrl = "/api",
68 apiToken,
69 onSend,
49 apiBaseUrl: _apiBaseUrl = "/api",
50 apiToken: _apiToken,
51 onSend: _onSend,
7052 onContentChange,
7153 collabEndpoint,
7254 className = "",
7355}: CollaborativeDraftViewProps): React.JSX.Element {
7456 const [showPanel, setShowPanel] = useState(false);
75 const [pendingInvites, setPendingInvites] =
76 useState<CollabInvite[]>(initialInvites);
57 const [pendingInvites, setPendingInvites] = useState<CollabInvite[]>(initialInvites);
7758 const [history, setHistory] = useState<CollabHistoryEntry[]>([]);
7859 const [historyLoading, setHistoryLoading] = useState(false);
7960 const [error, setError] = useState<string | null>(null);
8061
81 // Wire up the collaboration client.
8262 const collab = useCollaborativeDraft({
8363 draftId,
8464 sessionId,
8868 autoConnect: true,
8969 });
9070
91 // Merge API participants with live collaborators from awareness.
9271 const allCollaborators: Collaborator[] = mergeCollaborators(
9372 initialParticipants,
9473 collab.collaborators,
9574 user,
9675 );
9776
98 // ─── API calls (typed client) ────────────────────────────────────────────
99
10077 const handleInvite = useCallback(
10178 async (email: string, role: "editor" | "viewer") => {
10279 const { data } = await collaborationApi.invite(sessionId, { email, role });
103
10480 setPendingInvites((prev) => [
10581 ...prev,
10682 {
127103 setHistoryLoading(true);
128104 try {
129105 const offset = history.length;
130 const { data } = await collaborationApi.getHistory(sessionId, {
131 limit: 20,
132 offset,
133 });
106 const { data } = await collaborationApi.getHistory(sessionId, { limit: 20, offset });
134107 setHistory((prev) => [...prev, ...data.entries]);
135108 } catch (err) {
136109 setError((err as Error).message);
141114
142115 return (
143116 <Box className={`flex gap-4 ${className}`}>
144 {/* Main editor area */}
145117 <Box className="flex-1 min-w-0">
146118 {error && (
147119 <Box className="mb-3 px-4 py-2 bg-status-error/10 border border-status-error/20 rounded-lg flex items-center justify-between">
148 <Text variant="body-sm" className="text-status-error">
149 {error}
150 </Text>
151 <Button
152 variant="ghost"
153 size="sm"
154 onClick={() => setError(null)}
155 aria-label="Dismiss error"
156 >
157 Dismiss
158 </Button>
120 <Text variant="body-sm" className="text-status-error">{error}</Text>
121 <Button variant="ghost" size="sm" onClick={() => setError(null)} aria-label="Dismiss error">Dismiss</Button>
159122 </Box>
160123 )}
161
162124 <CollaborativeEditor
163125 collabConfig={collab.config}
164126 connectionStatus={collab.status}
172134 minHeight={300}
173135 />
174136 </Box>
175
176 {/* Side panel */}
177137 {showPanel && (
178138 <Box className="w-80 flex-shrink-0">
179139 <CollaborationPanel
197157
198158CollaborativeDraftView.displayName = "CollaborativeDraftView";
199159
200// ─── Helper: merge API participants with live awareness data ─────────────────
201
202160function mergeCollaborators(
203161 apiParticipants: Collaborator[],
204162 liveCollaborators: Collaborator[],
205163 currentUser: { userId: string; name: string; avatarUrl?: string | undefined; cursorColor?: string | undefined },
206164): Collaborator[] {
207165 const merged = new Map<string, Collaborator>();
208
209 // Start with API participants (have role info).
210 for (const p of apiParticipants) {
211 merged.set(p.userId, { ...p, isOnline: false });
212 }
213
214 // Add current user.
166 for (const p of apiParticipants) merged.set(p.userId, { ...p, isOnline: false });
215167 if (!merged.has(currentUser.userId)) {
216168 const entry: Collaborator = {
217169 userId: currentUser.userId,
220172 isOnline: true,
221173 role: "owner",
222174 };
223 if (currentUser.avatarUrl !== undefined) {
224 entry.avatarUrl = currentUser.avatarUrl;
225 }
175 if (currentUser.avatarUrl !== undefined) entry.avatarUrl = currentUser.avatarUrl;
226176 merged.set(currentUser.userId, entry);
227177 } else {
228178 const existing = merged.get(currentUser.userId);
229 if (existing) {
230 merged.set(currentUser.userId, { ...existing, isOnline: true });
231 }
179 if (existing) merged.set(currentUser.userId, { ...existing, isOnline: true });
232180 }
233
234 // Overlay live awareness data.
235181 for (const lc of liveCollaborators) {
236182 const existing = merged.get(lc.userId);
237183 if (existing) {
238 merged.set(lc.userId, {
239 ...existing,
240 isOnline: true,
241 cursorColor: lc.cursorColor || existing.cursorColor,
242 });
184 merged.set(lc.userId, { ...existing, isOnline: true, cursorColor: lc.cursorColor || existing.cursorColor });
243185 } else {
244186 merged.set(lc.userId, lc);
245187 }
246188 }
247
248189 return Array.from(merged.values());
249190}
Modifiedapps/web/components/FocusModeTimer.tsx+16−131View fileUnifiedSplit
22
33/**
44 * FocusModeTimer — countdown timer for focus sessions.
5 *
6 * Allows the user to set a timer ("Focus for 30 minutes") that automatically
7 * exits focus mode when it expires. Displays a circular progress ring and
8 * remaining time in a minimal format.
9 *
10 * Preset durations: 15, 30, 45, 60, 90 minutes.
11 * Timer ticks every second via a `setInterval` that calls `tickTimer` on the store.
125 */
136
147import type { JSX } from "react";
1811 fadeInUp,
1912 scalePopIn,
2013 SPRING_SNAPPY,
21 SPRING_SOFT,
22 useAlecRaeReducedMotion,
14 useViennaReducedMotion,
2315 withReducedMotion,
2416} from "../lib/animations";
2517import { FOCUS_TIMER_PRESETS, useFocusMode } from "../lib/focus-mode";
2618
27// ─── Helpers ─────────────────────────────────────────────────────────────────
28
2919function formatTime(totalSeconds: number): string {
3020 const hours = Math.floor(totalSeconds / 3600);
3121 const minutes = Math.floor((totalSeconds % 3600) / 60);
3222 const seconds = totalSeconds % 60;
3323 const pad = (n: number): string => String(n).padStart(2, "0");
34
35 if (hours > 0) {
36 return `${hours}:${pad(minutes)}:${pad(seconds)}`;
37 }
24 if (hours > 0) return `${hours}:${pad(minutes)}:${pad(seconds)}`;
3825 return `${minutes}:${pad(seconds)}`;
3926}
4027
4734 return `${minutes}m`;
4835}
4936
50// ─── Circular Progress Ring ──────────────────────────────────────────────────
51
5237interface ProgressRingProps {
53 progress: number; // 0-1
38 progress: number;
5439 size: number;
5540 strokeWidth: number;
5641}
5944 const radius = (size - strokeWidth) / 2;
6045 const circumference = 2 * Math.PI * radius;
6146 const offset = circumference * (1 - progress);
62
6347 return (
64 <svg
65 width={size}
66 height={size}
67 className="transform -rotate-90"
68 aria-hidden="true"
69 >
70 {/* Background track */}
71 <circle
72 cx={size / 2}
73 cy={size / 2}
74 r={radius}
75 fill="none"
76 stroke="rgba(255,255,255,0.08)"
77 strokeWidth={strokeWidth}
78 />
79 {/* Progress arc */}
80 <circle
81 cx={size / 2}
82 cy={size / 2}
83 r={radius}
84 fill="none"
85 stroke="url(#focus-timer-gradient)"
86 strokeWidth={strokeWidth}
87 strokeLinecap="round"
88 strokeDasharray={circumference}
89 strokeDashoffset={offset}
90 className="transition-[stroke-dashoffset] duration-1000 ease-linear"
91 />
48 <svg width={size} height={size} className="transform -rotate-90" aria-hidden="true">
49 <circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="rgba(255,255,255,0.08)" strokeWidth={strokeWidth} />
50 <circle cx={size / 2} cy={size / 2} r={radius} fill="none" stroke="url(#focus-timer-gradient)" strokeWidth={strokeWidth} strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset} className="transition-[stroke-dashoffset] duration-1000 ease-linear" />
9251 <defs>
9352 <linearGradient id="focus-timer-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
9453 <stop offset="0%" stopColor="#22d3ee" />
9958 );
10059}
10160
102// ─── Timer Component ─────────────────────────────────────────────────────────
103
10461export interface FocusModeTimerProps {
10562 className?: string;
10663}
10764
10865export function FocusModeTimer({ className }: FocusModeTimerProps): JSX.Element {
109 const reduced = useAlecRaeReducedMotion();
66 const reduced = useViennaReducedMotion();
11067 const timerDuration = useFocusMode((s) => s.timerDuration);
11168 const timerRemaining = useFocusMode((s) => s.timerRemaining);
11269 const timerRunning = useFocusMode((s) => s.timerRunning);
11370 const startTimer = useFocusMode((s) => s.startTimer);
11471 const stopTimer = useFocusMode((s) => s.stopTimer);
11572 const tickTimer = useFocusMode((s) => s.tickTimer);
116
11773 const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
11874
119 // Tick the timer every second when running
12075 useEffect(() => {
12176 if (timerRunning) {
122 intervalRef.current = setInterval(() => {
123 tickTimer();
124 }, 1000);
77 intervalRef.current = setInterval(() => { tickTimer(); }, 1000);
12578 }
126
12779 return () => {
12880 if (intervalRef.current !== null) {
12981 clearInterval(intervalRef.current);
13284 };
13385 }, [timerRunning, tickTimer]);
13486
135 const handlePresetClick = useCallback(
136 (minutes: number) => {
137 startTimer(minutes);
138 },
139 [startTimer],
140 );
141
142 const handleStopClick = useCallback(() => {
143 stopTimer();
144 }, [stopTimer]);
87 const handlePresetClick = useCallback((minutes: number) => { startTimer(minutes); }, [startTimer]);
88 const handleStopClick = useCallback(() => { stopTimer(); }, [stopTimer]);
14589
14690 const isActive = timerRunning && timerDuration !== null && timerRemaining !== null;
14791 const progress = isActive ? timerRemaining / timerDuration : 0;
148
14992 const fadeVariants = withReducedMotion(fadeInUp, reduced);
15093 const popVariants = withReducedMotion(scalePopIn, reduced);
15194
15396 <div className={className} role="timer" aria-label="Focus timer">
15497 <AnimatePresence mode="wait">
15598 {isActive ? (
156 /* Active timer display */
157 <motion.div
158 key="timer-active"
159 variants={popVariants}
160 initial="initial"
161 animate="animate"
162 exit="exit"
163 className="flex flex-col items-center gap-3"
164 >
165 {/* Circular progress ring with time inside */}
99 <motion.div key="timer-active" variants={popVariants} initial="initial" animate="animate" exit="exit" className="flex flex-col items-center gap-3">
166100 <div className="relative">
167101 <ProgressRing progress={progress} size={80} strokeWidth={4} />
168102 <div className="absolute inset-0 flex items-center justify-center">
169 <span
170 className="text-lg font-mono font-medium text-white tabular-nums"
171 aria-live="polite"
172 aria-atomic="true"
173 >
174 {formatTime(timerRemaining)}
175 </span>
103 <span className="text-lg font-mono font-medium text-white tabular-nums" aria-live="polite" aria-atomic="true">{formatTime(timerRemaining)}</span>
176104 </div>
177105 </div>
178
179 {/* Stop button */}
180 <motion.button
181 type="button"
182 onClick={handleStopClick}
183 className={[
184 "text-xs text-blue-200/60 hover:text-white transition-colors",
185 "px-3 py-1 rounded-full border border-white/10 hover:border-white/20",
186 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60",
187 ].join(" ")}
188 {...(!reduced ? { whileHover: { scale: 1.05 } } : {})}
189 {...(!reduced ? { whileTap: { scale: 0.95 } } : {})}
190 transition={SPRING_SNAPPY}
191 aria-label="Stop focus timer"
192 >
193 Stop timer
194 </motion.button>
106 <motion.button type="button" onClick={handleStopClick} className={["text-xs text-blue-200/60 hover:text-white transition-colors", "px-3 py-1 rounded-full border border-white/10 hover:border-white/20", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60"].join(" ")} {...(!reduced ? { whileHover: { scale: 1.05 } } : {})} {...(!reduced ? { whileTap: { scale: 0.95 } } : {})} transition={SPRING_SNAPPY} aria-label="Stop focus timer">Stop timer</motion.button>
195107 </motion.div>
196108 ) : (
197 /* Preset selection */
198 <motion.div
199 key="timer-presets"
200 variants={fadeVariants}
201 initial="initial"
202 animate="animate"
203 exit="exit"
204 className="flex flex-col items-center gap-3"
205 >
206 <span className="text-xs text-blue-200/50 uppercase tracking-wider font-medium">
207 Focus for
208 </span>
109 <motion.div key="timer-presets" variants={fadeVariants} initial="initial" animate="animate" exit="exit" className="flex flex-col items-center gap-3">
110 <span className="text-xs text-blue-200/50 uppercase tracking-wider font-medium">Focus for</span>
209111 <div className="flex items-center gap-2 flex-wrap justify-center">
210112 {FOCUS_TIMER_PRESETS.map((minutes) => (
211 <motion.button
212 key={minutes}
213 type="button"
214 onClick={() => handlePresetClick(minutes)}
215 className={[
216 "text-xs font-medium px-3 py-1.5 rounded-full",
217 "bg-white/[0.06] border border-white/10 text-blue-100/80",
218 "hover:bg-white/10 hover:text-white hover:border-white/20",
219 "transition-colors",
220 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60",
221 ].join(" ")}
222 {...(!reduced ? { whileHover: { scale: 1.06 } } : {})}
223 {...(!reduced ? { whileTap: { scale: 0.94 } } : {})}
224 transition={SPRING_SNAPPY}
225 aria-label={`Focus for ${minutes} minutes`}
226 >
227 {formatPresetLabel(minutes)}
228 </motion.button>
113 <motion.button key={minutes} type="button" onClick={() => handlePresetClick(minutes)} className={["text-xs font-medium px-3 py-1.5 rounded-full", "bg-white/[0.06] border border-white/10 text-blue-100/80", "hover:bg-white/10 hover:text-white hover:border-white/20", "transition-colors", "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400/60"].join(" ")} {...(!reduced ? { whileHover: { scale: 1.06 } } : {})} {...(!reduced ? { whileTap: { scale: 0.94 } } : {})} transition={SPRING_SNAPPY} aria-label={`Focus for ${minutes} minutes`}>{formatPresetLabel(minutes)}</motion.button>
229114 ))}
230115 </div>
231116 </motion.div>
Modifiedpackages/ui/src/index.ts+9−6View fileUnifiedSplit
3535export { SnoozeCalendar, type SnoozeCalendarProps, type SnoozeTimeSlot, type SnoozePreset } from "./composites/snooze-calendar";
3636export { DraggableEmailRow, type DraggableEmailRowProps, type DraggableEmailData } from "./composites/draggable-email-row";
3737export { SnoozeDropOverlay, type SnoozeDropOverlayProps } from "./composites/snooze-drop-overlay";
38// SpatialInboxView and SpatialControls are NOT barrel-exported because they
39// depend on @react-three/fiber + three which break React 19 SSR/SSG.
40// Import directly from "@alecrae/ui/src/composites/spatial-inbox-view" and
41// "@alecrae/ui/src/composites/spatial-controls" where needed (with dynamic import).
42export type { SpatialInboxViewProps, SpatialThread, SpatialAxis, SpatialColorScheme, SpatialFilterState, ThreadCategory } from "./composites/spatial-inbox-view";
43export type { SpatialControlsProps } from "./composites/spatial-controls";
38export type {
39 SpatialInboxViewProps,
40 SpatialControlsProps,
41 SpatialThread,
42 SpatialAxis,
43 SpatialColorScheme,
44 SpatialFilterState,
45 ThreadCategory,
46} from "./composites/spatial-types";
4447export { InboxHeatmap, type InboxHeatmapProps, type HeatmapDayData, type HeatmapMode } from "./composites/inbox-heatmap";
4548export { HourlyActivityChart, type HourlyActivityChartProps, type HourlyBucket } from "./composites/hourly-activity-chart";
4649export { EmailStatsDashboard, type EmailStatsDashboardProps, type EmailStatsMetrics, type EmailStatsCompare, type StatsPeriod } from "./composites/email-stats-dashboard";
Modifiedservices/dns/package.json+4−4View fileUnifiedSplit
11{
2 "name": "@alecrae/dns",
2 "name": "@emailed/dns",
33 "version": "0.1.0",
44 "private": true,
55 "type": "module",
77 "types": "src/index.ts",
88 "scripts": {
99 "dev": "bun run --watch src/index.ts",
10 "build": "echo 'Service builds independently \u2014 not part of Vercel web deployment'",
10 "build": "echo 'Service builds independently'",
1111 "test": "vitest run",
1212 "test:watch": "vitest",
1313 "typecheck": "tsc --noEmit",
1515 "clean": "rm -rf dist"
1616 },
1717 "dependencies": {
18 "@alecrae/shared": "workspace:*",
19 "@alecrae/db": "workspace:*",
18 "@emailed/shared": "workspace:*",
19 "@emailed/db": "workspace:*",
2020 "bullmq": "^5.0.0",
2121 "drizzle-orm": "^0.38.0",
2222 "ioredis": "^5.4.0",
Modifiedservices/inbound/src/index.ts+10−75View fileUnifiedSplit
88import {
99 initTelemetry,
1010 shutdownTelemetry,
11 getTracer,
1211 recordEmailReceived,
1312 recordEmailFilterDuration,
14 recordActiveConnection,
15 SpanKind,
16} from "@alecrae/shared";
13} from "@emailed/shared";
1714import type { SmtpSession, SmtpEnvelope } from "./types.js";
1815
19/**
20 * Inbound email processing service.
21 *
22 * Pipeline: SMTP/HTTP receive -> MIME parse -> filter -> route -> store
23 *
24 * Two ingress paths:
25 * 1. SMTP receiver (port 25 / SMTP_PORT) — direct MX delivery
26 * 2. HTTP webhook (port 8025 / HTTP_PORT) — Cloudflare Email Workers or
27 * other HTTP-based forwarders POST raw MIME to /inbound/webhook
28 */
29
30/**
31 * Split raw email bytes into the header block (as string) and body (as Uint8Array).
32 * Headers and body are separated by a blank line (CRLF CRLF or LF LF).
33 */
3416function splitRawMessage(rawData: Uint8Array): { rawHeaders: string; rawBody: Uint8Array } {
3517 const bytes = rawData;
36 // Search for CRLFCRLF (\r\n\r\n) or LFLF (\n\n)
3718 let splitIndex = -1;
3819 let separatorLength = 0;
39
4020 for (let i = 0; i < bytes.length - 1; i++) {
4121 if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a &&
4222 i + 3 < bytes.length && bytes[i + 2] === 0x0d && bytes[i + 3] === 0x0a) {
5030 break;
5131 }
5232 }
53
5433 if (splitIndex === -1) {
55 // No body found — entire message is headers
56 return {
57 rawHeaders: new TextDecoder().decode(bytes),
58 rawBody: new Uint8Array(0),
59 };
34 return { rawHeaders: new TextDecoder().decode(bytes), rawBody: new Uint8Array(0) };
6035 }
61
6236 return {
6337 rawHeaders: new TextDecoder().decode(bytes.subarray(0, splitIndex)),
6438 rawBody: bytes.subarray(splitIndex + separatorLength),
6842const parser = new MimeParser();
6943const pipeline = new FilterPipeline();
7044const router = new MailboxRouter();
71const store = process.env["DATABASE_URL"]
72 ? new PostgresEmailStore()
73 : new InMemoryEmailStore();
45const store = process.env["DATABASE_URL"] ? new PostgresEmailStore() : new InMemoryEmailStore();
7446
7547async function handleInboundMessage(
7648 session: SmtpSession,
7850 rawData: Uint8Array,
7951): Promise<void> {
8052 const startTime = Date.now();
81
82 // 1. Parse the MIME message
8353 const parsed = await parser.parse(rawData);
84 console.log(
85 `[Inbound] Parsed message ${parsed.messageId} from ${envelope.mailFrom} (${rawData.length} bytes)`,
86 );
87
88 // 2. Run the filter pipeline (pass sender IP for SPF validation, raw data for DKIM)
54 console.log(`[Inbound] Parsed message ${parsed.messageId} from ${envelope.mailFrom} (${rawData.length} bytes)`);
8955 const { rawHeaders, rawBody } = splitRawMessage(rawData);
9056 const filterStart = performance.now();
9157 const verdict = await pipeline.process(envelope, parsed, session.remoteAddress, rawHeaders, rawBody);
9258 const filterDurationMs = performance.now() - filterStart;
9359 recordEmailFilterDuration("full-pipeline", filterDurationMs);
94 console.log(
95 `[Inbound] Filter verdict for ${parsed.messageId}: ${verdict.action} (score: ${verdict.score})`,
96 );
97
60 console.log(`[Inbound] Filter verdict for ${parsed.messageId}: ${verdict.action} (score: ${verdict.score})`);
9861 if (verdict.action === "reject") {
99 // Extract domain from sender for metrics
10062 const senderDomain = (envelope.mailFrom ?? "").split("@")[1] ?? "unknown";
10163 recordEmailReceived(senderDomain, "rejected");
10264 throw new Error(`Message rejected: ${verdict.reason}`);
10365 }
104
105 // 3. Resolve recipients
10666 const resolved = await router.resolve(envelope.rcptTo);
107
108 // 4. Store for each resolved recipient
10967 let deliveryCount = 0;
11068 for (const [recipient, resolution] of resolved) {
11169 if (!resolution) {
11270 console.warn(`[Inbound] No mailbox found for recipient: ${recipient}`);
11371 continue;
11472 }
115
11673 if (resolution.rule.action === "forward") {
117 // In production: enqueue for outbound delivery to forwarding address
11874 console.log(`[Inbound] Forwarding ${parsed.messageId} to ${resolution.resolvedAddress}`);
11975 continue;
12076 }
121
12277 const stored = await store.store(parsed, resolution, verdict);
123 console.log(
124 `[Inbound] Stored ${stored.id} in mailbox ${resolution.mailboxId} for ${recipient}`,
125 );
78 console.log(`[Inbound] Stored ${stored.id} in mailbox ${resolution.mailboxId} for ${recipient}`);
12679 deliveryCount++;
12780 }
128
12981 const elapsed = Date.now() - startTime;
130
131 // Record telemetry
13282 const senderDomain = (envelope.mailFrom ?? "").split("@")[1] ?? "unknown";
13383 recordEmailReceived(senderDomain, verdict.action === "quarantine" ? "quarantined" : "accepted");
134
135 console.log(
136 `[Inbound] Processed ${parsed.messageId}: ${deliveryCount} deliveries in ${elapsed}ms`,
137 );
84 console.log(`[Inbound] Processed ${parsed.messageId}: ${deliveryCount} deliveries in ${elapsed}ms`);
13885}
13986
140// --- Service Startup ---
141
142const hostname = process.env["SMTP_HOSTNAME"] ?? "mx.alecrae.dev";
87const hostname = process.env["SMTP_HOSTNAME"] ?? "mx.emailed.dev";
14388const smtpPort = parseInt(process.env["SMTP_PORT"] ?? "25", 10);
14489const httpPort = parseInt(process.env["HTTP_PORT"] ?? "8025", 10);
14590const enableSmtp = process.env["DISABLE_SMTP"] !== "true";
163108
164109async function main(): Promise<void> {
165110 console.log(`[Inbound] Starting inbound email processing service`);
166
167 // Initialize OpenTelemetry
168 await initTelemetry("alecrae-inbound").catch((err) => {
111 await initTelemetry("emailed-inbound").catch((err) => {
169112 console.warn("[Inbound] OpenTelemetry init failed:", err);
170113 });
171
172114 console.log(`[Inbound] Store backend: ${process.env["DATABASE_URL"] ? "PostgreSQL" : "in-memory"}`);
173
174115 if (enableSmtp) {
175116 console.log(`[Inbound] SMTP receiver: ${hostname}:${smtpPort}`);
176117 await receiver.start();
177118 } else {
178119 console.log(`[Inbound] SMTP receiver: disabled`);
179120 }
180
181121 if (enableHttp) {
182 httpServer = Bun.serve({
183 port: httpPort,
184 fetch: httpApp.fetch,
185 });
122 httpServer = Bun.serve({ port: httpPort, fetch: httpApp.fetch });
186123 console.log(`[Inbound] HTTP webhook: http://0.0.0.0:${httpPort}/inbound/webhook`);
187124 } else {
188125 console.log(`[Inbound] HTTP webhook: disabled`);
189126 }
190
191127 console.log(`[Inbound] Service started. Store stats:`, store.getStats());
192128}
193129
194// Handle graceful shutdown
195130async function shutdown(signal: string): Promise<void> {
196131 console.log(`[Inbound] Received ${signal} — shutting down...`);
197132 if (enableSmtp) await receiver.stop();
Modifiedservices/inbound/src/receiver/smtp-receiver.ts+61−352View fileUnifiedSplit
11import * as net from "node:net";
22import type { SmtpSession, SmtpEnvelope } from "../types.js";
33
4// ─── Domain verification callback ────────────────────────────────────────────
5
6export interface DomainCheckResult {
7 registered: boolean;
8 active: boolean;
9 dnsStale: boolean;
10}
11
12/**
13 * Callback to check whether a recipient domain is registered and verified.
14 * When provided, RCPT TO will reject mail for unregistered domains.
15 */
16export type DomainVerifier = (domain: string) => Promise<DomainCheckResult>;
17
18// ─── Rate limiting for inbound messages per domain ───────────────────────────
19
20class InboundRateLimiter {
21 private counters = new Map<string, { count: number; windowStart: number }>();
22 private readonly maxPerHour: number;
23
24 constructor(maxPerHour: number) {
25 this.maxPerHour = maxPerHour;
26 }
27
28 check(domain: string): boolean {
29 const now = Date.now();
30 const oneHourMs = 60 * 60 * 1000;
31 const entry = this.counters.get(domain);
32
33 if (!entry || now - entry.windowStart > oneHourMs) {
34 this.counters.set(domain, { count: 1, windowStart: now });
35 return true;
36 }
37
38 if (entry.count >= this.maxPerHour) {
39 return false;
40 }
41
42 entry.count++;
43 return true;
44 }
45
46 /** For testing: reset all counters */
47 reset(): void {
48 this.counters.clear();
49 }
50}
51
52/**
53 * SMTP command types supported by the receiver.
54 */
554type SmtpCommand = "EHLO" | "HELO" | "MAIL" | "RCPT" | "DATA" | "RSET" | "QUIT" | "STARTTLS" | "AUTH" | "NOOP";
565
576interface SmtpResponse {
7019 requireTls: boolean;
7120 bannerDelay: number;
7221 allowedSenderDomains?: Set<string>;
73 /** Callback to verify recipient domain is registered and active */
74 domainVerifier?: DomainVerifier;
75 /** Max inbound messages per domain per hour (default: 100) */
76 maxInboundPerDomainPerHour?: number;
7722 onMessage: (session: SmtpSession, envelope: SmtpEnvelope, data: Uint8Array) => Promise<void>;
7823}
7924
8025const DEFAULT_CONFIG: SmtpReceiverConfig = {
81 hostname: "mx.alecrae.dev",
26 hostname: "mx.emailed.dev",
8227 port: 25,
83 maxMessageSize: 25 * 1024 * 1024, // 25 MB
28 maxMessageSize: 25 * 1024 * 1024,
8429 maxRecipients: 100,
85 connectionTimeout: 300_000, // 5 minutes
86 dataTimeout: 600_000, // 10 minutes
30 connectionTimeout: 300_000,
31 dataTimeout: 600_000,
8732 requireTls: false,
8833 bannerDelay: 0,
89 maxInboundPerDomainPerHour: 100,
9034 onMessage: async () => {},
9135};
9236
93/**
94 * State machine for a single SMTP connection.
95 */
9637export class SmtpConnectionHandler {
9738 private session: SmtpSession;
9839 private state: "greeting" | "ready" | "mail" | "rcpt" | "data" | "closed";
9940 private dataBuffer: Uint8Array[] = [];
10041 private dataSize = 0;
101 private rateLimiter: InboundRateLimiter;
10242
10343 constructor(
10444 private readonly config: SmtpReceiverConfig,
10545 remoteAddress: string,
10646 remotePort: number,
107 rateLimiter?: InboundRateLimiter,
10847 ) {
10948 this.state = "greeting";
110 this.rateLimiter = rateLimiter ?? new InboundRateLimiter(config.maxInboundPerDomainPerHour ?? 100);
11149 this.session = {
11250 id: this.generateSessionId(),
11351 remoteAddress,
12058
12159 private generateSessionId(): string {
12260 const bytes = crypto.getRandomValues(new Uint8Array(12));
123 return Array.from(bytes)
124 .map((b) => b.toString(16).padStart(2, "0"))
125 .join("");
61 return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
12662 }
12763
128 /**
129 * Generate the initial SMTP banner response.
130 */
13164 getGreeting(): SmtpResponse {
13265 this.state = "ready";
133 return {
134 code: 220,
135 message: `${this.config.hostname} ESMTP AlecRae Inbound - ${this.session.id}`,
136 };
66 return { code: 220, message: `${this.config.hostname} ESMTP Emailed Inbound - ${this.session.id}` };
13767 }
13868
139 /**
140 * Process a single SMTP command line and return a response.
141 */
14269 async processCommand(line: string): Promise<SmtpResponse> {
143 if (this.state === "closed") {
144 return { code: 421, message: "Connection closed", close: true };
145 }
146
70 if (this.state === "closed") return { code: 421, message: "Connection closed", close: true };
14771 const trimmed = line.trim();
148 if (trimmed.length === 0) {
149 return { code: 500, message: "Syntax error, command unrecognized" };
150 }
151
72 if (trimmed.length === 0) return { code: 500, message: "Syntax error, command unrecognized" };
15273 const spaceIdx = trimmed.indexOf(" ");
15374 const verb = (spaceIdx > 0 ? trimmed.slice(0, spaceIdx) : trimmed).toUpperCase() as SmtpCommand;
15475 const args = spaceIdx > 0 ? trimmed.slice(spaceIdx + 1).trim() : "";
155
15676 switch (verb) {
157 case "EHLO":
158 return this.handleEhlo(args);
159 case "HELO":
160 return this.handleHelo(args);
161 case "MAIL":
162 return this.handleMailFrom(args);
163 case "RCPT":
164 return await this.handleRcptTo(args);
165 case "DATA":
166 return this.handleDataStart();
167 case "RSET":
168 return this.handleReset();
169 case "QUIT":
170 return this.handleQuit();
171 case "STARTTLS":
172 return this.handleStartTls();
173 case "NOOP":
174 return { code: 250, message: "OK" };
175 default:
176 return { code: 502, message: `Command not implemented: ${verb}` };
77 case "EHLO": return this.handleEhlo(args);
78 case "HELO": return this.handleHelo(args);
79 case "MAIL": return this.handleMailFrom(args);
80 case "RCPT": return this.handleRcptTo(args);
81 case "DATA": return this.handleDataStart();
82 case "RSET": return this.handleReset();
83 case "QUIT": return this.handleQuit();
84 case "STARTTLS": return this.handleStartTls();
85 case "NOOP": return { code: 250, message: "OK" };
86 default: return { code: 502, message: `Command not implemented: ${verb}` };
17787 }
17888 }
17989
180 /**
181 * Process a chunk of DATA content. Returns a response when the terminator is found.
182 */
18390 async processDataChunk(chunk: Uint8Array): Promise<SmtpResponse | null> {
18491 if (this.state !== "data") return null;
185
18692 this.dataBuffer.push(chunk);
18793 this.dataSize += chunk.length;
188
18994 if (this.dataSize > this.config.maxMessageSize) {
19095 this.resetTransaction();
19196 return { code: 552, message: "Message exceeds maximum size" };
19297 }
193
194 // Check for end-of-data marker: \r\n.\r\n
19598 const combined = this.concatenateBuffers();
196 const terminator = new Uint8Array([13, 10, 46, 13, 10]); // \r\n.\r\n
99 const terminator = new Uint8Array([13, 10, 46, 13, 10]);
197100 const terminatorIdx = this.findSequence(combined, terminator);
198
199101 if (terminatorIdx === -1) return null;
200
201 // Extract message data (excluding the terminator dot line)
202 const messageData = combined.slice(0, terminatorIdx + 2); // include final \r\n before .
102 const messageData = combined.slice(0, terminatorIdx + 2);
203103 const unstuffed = this.unstuffDots(messageData);
204
205104 try {
206 const envelope: SmtpEnvelope = {
207 mailFrom: this.session.mailFrom!,
208 rcptTo: [...this.session.rcptTo],
209 };
210
105 const envelope: SmtpEnvelope = { mailFrom: this.session.mailFrom ?? "", rcptTo: [...this.session.rcptTo] };
211106 await this.config.onMessage(this.session, envelope, unstuffed);
212107 this.resetTransaction();
213108 return { code: 250, message: `OK: message queued as ${this.session.id}` };
219114 }
220115
221116 private handleEhlo(hostname: string): SmtpResponse {
222 if (!hostname) {
223 return { code: 501, message: "EHLO requires a hostname" };
224 }
225
117 if (!hostname) return { code: 501, message: "EHLO requires a hostname" };
226118 this.session.heloHostname = hostname;
227119 this.session.clientHostname = hostname;
228120 this.state = "ready";
229
230 const extensions = [
231 `${this.config.hostname} greets ${hostname}`,
232 `SIZE ${this.config.maxMessageSize}`,
233 "8BITMIME",
234 "SMTPUTF8",
235 "PIPELINING",
236 "ENHANCEDSTATUSCODES",
237 ];
238
239 if (!this.session.secure) {
240 extensions.push("STARTTLS");
241 }
242
121 const extensions = [`${this.config.hostname} greets ${hostname}`, `SIZE ${this.config.maxMessageSize}`, "8BITMIME", "SMTPUTF8", "PIPELINING", "ENHANCEDSTATUSCODES"];
122 if (!this.session.secure) extensions.push("STARTTLS");
243123 return { code: 250, message: extensions.join("\n") };
244124 }
245125
246126 private handleHelo(hostname: string): SmtpResponse {
247 if (!hostname) {
248 return { code: 501, message: "HELO requires a hostname" };
249 }
250
127 if (!hostname) return { code: 501, message: "HELO requires a hostname" };
251128 this.session.heloHostname = hostname;
252129 this.session.clientHostname = hostname;
253130 this.state = "ready";
254
255131 return { code: 250, message: `${this.config.hostname} greets ${hostname}` };
256132 }
257133
258134 private handleMailFrom(args: string): SmtpResponse {
259 if (this.state !== "ready") {
260 return { code: 503, message: "Bad sequence of commands" };
261 }
262
263 const match = args.match(/^FROM:\s*<([^>]*)>/i);
264 if (!match) {
265 return { code: 501, message: "Syntax error in MAIL FROM" };
266 }
267
268 const sender = match[1]!;
269
270 // Validate sender domain if restrictions are configured
135 if (this.state !== "ready") return { code: 503, message: "Bad sequence of commands" };
136 const match = /^FROM:\s*<([^>]*)>/i.exec(args);
137 if (!match || match[1] === undefined) return { code: 501, message: "Syntax error in MAIL FROM" };
138 const sender = match[1];
271139 if (this.config.allowedSenderDomains && sender) {
272140 const domain = sender.split("@")[1];
273141 if (domain && !this.config.allowedSenderDomains.has(domain)) {
274142 return { code: 550, message: `Sender domain ${domain} not allowed` };
275143 }
276144 }
277
278145 this.session.mailFrom = sender;
279146 this.state = "mail";
280
281147 return { code: 250, message: "OK" };
282148 }
283149
284 private async handleRcptTo(args: string): Promise<SmtpResponse> {
285 if (this.state !== "mail" && this.state !== "rcpt") {
286 return { code: 503, message: "Bad sequence of commands" };
287 }
288
289 const match = args.match(/^TO:\s*<([^>]+)>/i);
290 if (!match) {
291 return { code: 501, message: "Syntax error in RCPT TO" };
292 }
293
294 if (this.session.rcptTo.length >= this.config.maxRecipients) {
295 return { code: 452, message: "Too many recipients" };
296 }
297
298 const recipient = match[1]!;
299
300 // Basic email validation
301 if (!recipient.includes("@")) {
302 return { code: 550, message: "Invalid recipient address" };
303 }
304
305 // Extract recipient domain
306 const recipientDomain = recipient.split("@")[1];
307 if (!recipientDomain) {
308 return { code: 550, message: "Invalid recipient address — missing domain" };
309 }
310
311 // Domain verification: check if this domain is registered and active
312 if (this.config.domainVerifier) {
313 try {
314 const result = await this.config.domainVerifier(recipientDomain);
315
316 if (!result.registered) {
317 return { code: 550, message: `Relay not permitted for domain ${recipientDomain}` };
318 }
319
320 if (result.dnsStale) {
321 return { code: 450, message: "Try again later — domain DNS verification pending" };
322 }
323
324 if (!result.active) {
325 return { code: 550, message: `Domain ${recipientDomain} is not active` };
326 }
327 } catch (err) {
328 // On verifier error, temp-fail rather than silently accept
329 console.error(`[SmtpReceiver] Domain verification error for ${recipientDomain}:`, err);
330 return { code: 450, message: "Temporary failure — try again later" };
331 }
332 }
333
334 // Rate limiting: max N inbound messages per domain per hour
335 if (!this.rateLimiter.check(recipientDomain)) {
336 return { code: 452, message: `Rate limit exceeded for domain ${recipientDomain} — try again later` };
337 }
338
150 private handleRcptTo(args: string): SmtpResponse {
151 if (this.state !== "mail" && this.state !== "rcpt") return { code: 503, message: "Bad sequence of commands" };
152 const match = /^TO:\s*<([^>]+)>/i.exec(args);
153 if (!match || match[1] === undefined) return { code: 501, message: "Syntax error in RCPT TO" };
154 if (this.session.rcptTo.length >= this.config.maxRecipients) return { code: 452, message: "Too many recipients" };
155 const recipient = match[1];
156 if (!recipient.includes("@")) return { code: 550, message: "Invalid recipient address" };
339157 this.session.rcptTo.push(recipient);
340158 this.state = "rcpt";
341
342159 return { code: 250, message: "OK" };
343160 }
344161
345162 private handleDataStart(): SmtpResponse {
346 if (this.state !== "rcpt") {
347 return { code: 503, message: "Bad sequence of commands - need RCPT first" };
348 }
349
350 if (this.session.rcptTo.length === 0) {
351 return { code: 503, message: "No valid recipients" };
352 }
353
354 if (this.config.requireTls && !this.session.secure) {
355 return { code: 530, message: "Must issue STARTTLS first" };
356 }
357
163 if (this.state !== "rcpt") return { code: 503, message: "Bad sequence of commands - need RCPT first" };
164 if (this.session.rcptTo.length === 0) return { code: 503, message: "No valid recipients" };
165 if (this.config.requireTls && !this.session.secure) return { code: 530, message: "Must issue STARTTLS first" };
358166 this.state = "data";
359167 this.dataBuffer = [];
360168 this.dataSize = 0;
361
362169 return { code: 354, message: "Start mail input; end with <CRLF>.<CRLF>" };
363170 }
364171
365172 private handleStartTls(): SmtpResponse {
366 if (this.session.secure) {
367 return { code: 503, message: "TLS already active" };
368 }
369
370 // In production: initiate TLS handshake on the socket.
371 // The caller is responsible for upgrading the connection.
173 if (this.session.secure) return { code: 503, message: "TLS already active" };
372174 this.session.secure = true;
373175 return { code: 220, message: "Ready to start TLS" };
374176 }
395197 const totalLength = this.dataBuffer.reduce((sum, buf) => sum + buf.length, 0);
396198 const result = new Uint8Array(totalLength);
397199 let offset = 0;
398 for (const buf of this.dataBuffer) {
399 result.set(buf, offset);
400 offset += buf.length;
401 }
200 for (const buf of this.dataBuffer) { result.set(buf, offset); offset += buf.length; }
402201 return result;
403202 }
404203
412211 return -1;
413212 }
414213
415 /**
416 * Remove dot-stuffing from DATA content (RFC 5321, Section 4.5.2).
417 */
418214 private unstuffDots(data: Uint8Array): Uint8Array {
419215 const result: number[] = [];
420216 let i = 0;
421217 while (i < data.length) {
422 // At the start of a line (after \r\n), if we see a dot followed by another dot,
423 // skip the first dot.
424 if (
425 i >= 2 &&
426 data[i - 2] === 13 &&
427 data[i - 1] === 10 &&
428 data[i] === 46 &&
429 i + 1 < data.length &&
430 data[i + 1] === 46
431 ) {
432 i++; // Skip the stuffed dot
433 }
434 result.push(data[i]!);
218 if (i >= 2 && data[i - 2] === 13 && data[i - 1] === 10 && data[i] === 46 && i + 1 < data.length && data[i + 1] === 46) i++;
219 const byte = data[i];
220 if (byte !== undefined) result.push(byte);
435221 i++;
436222 }
437223 return new Uint8Array(result);
442228 }
443229}
444230
445/**
446 * SMTP Receiver server.
447 * In production, this listens on port 25 for incoming SMTP connections.
448 */
449231export class SmtpReceiver {
450232 private config: SmtpReceiverConfig;
451233 private running = false;
452234 private server: net.Server | null = null;
453235 private activeConnections = new Set<net.Socket>();
454 private rateLimiter: InboundRateLimiter;
455236
456237 constructor(config: Partial<SmtpReceiverConfig> & Pick<SmtpReceiverConfig, "onMessage">) {
457238 this.config = { ...DEFAULT_CONFIG, ...config };
458 this.rateLimiter = new InboundRateLimiter(this.config.maxInboundPerDomainPerHour ?? 100);
459239 }
460240
461241 async start(): Promise<void> {
462242 if (this.running) throw new Error("SMTP Receiver already running");
463243 this.running = true;
464
465244 return new Promise<void>((resolve, reject) => {
466 this.server = net.createServer((socket) => {
467 this.handleConnection(socket);
468 });
469
245 this.server = net.createServer((socket) => { this.handleConnection(socket); });
470246 this.server.maxConnections = 500;
471
472247 this.server.on("error", (err) => {
473248 console.error("[SmtpReceiver] Server error:", err);
474249 if (!this.running) reject(err);
475250 });
476
477251 this.server.listen(this.config.port, () => {
478 console.log(
479 `[SmtpReceiver] Listening on ${this.config.hostname}:${this.config.port}`,
480 );
252 console.log(`[SmtpReceiver] Listening on ${this.config.hostname}:${this.config.port}`);
481253 resolve();
482254 });
483255 });
486258 private handleConnection(socket: net.Socket): void {
487259 const remoteAddress = socket.remoteAddress ?? "unknown";
488260 const remotePort = socket.remotePort ?? 0;
489
490261 this.activeConnections.add(socket);
491 const handler = new SmtpConnectionHandler(
492 this.config,
493 remoteAddress,
494 remotePort,
495 this.rateLimiter,
496 );
497
498 // Send SMTP greeting
499 socket.write(`220 ${this.config.hostname} ESMTP AlecRae\r\n`);
500
262 const handler = new SmtpConnectionHandler(this.config, remoteAddress, remotePort);
263 socket.write(`220 ${this.config.hostname} ESMTP Emailed\r\n`);
501264 socket.setTimeout(this.config.connectionTimeout);
502
503265 let lineBuffer = "";
504
505266 let inDataMode = false;
506
507267 socket.on("data", async (data) => {
508 // When in DATA mode, pass raw bytes to the data chunk processor
509268 if (inDataMode) {
510269 try {
511270 const response = await handler.processDataChunk(data);
512271 if (response) {
513 // DATA complete (end-of-data marker found)
514272 inDataMode = false;
515273 socket.write(`${response.code} ${response.message}\r\n`);
516 if (response.close) {
517 socket.end();
518 return;
519 }
274 if (response.close) { socket.end(); return; }
520275 }
521276 } catch (err) {
522 console.error(
523 `[SmtpReceiver] Error processing data from ${remoteAddress}:`,
524 err,
525 );
277 console.error(`[SmtpReceiver] Error processing data from ${remoteAddress}:`, err);
526278 inDataMode = false;
527279 socket.write("451 Internal server error\r\n");
528280 }
529281 return;
530282 }
531
532283 lineBuffer += data.toString("utf-8");
533
534 // Process complete SMTP command lines
535284 let newlineIdx: number;
536285 while ((newlineIdx = lineBuffer.indexOf("\r\n")) !== -1) {
537286 const line = lineBuffer.slice(0, newlineIdx);
538287 lineBuffer = lineBuffer.slice(newlineIdx + 2);
539
540288 try {
541289 const response = await handler.processCommand(line);
542290 if (response) {
543291 socket.write(`${response.code} ${response.message}\r\n`);
544
545 if (response.close) {
546 socket.end();
547 return;
548 }
549
550 // 354 means server is ready to receive DATA
292 if (response.close) { socket.end(); return; }
551293 if (response.code === 354) {
552294 inDataMode = true;
553 // Any remaining data in the lineBuffer is part of the message body
554295 if (lineBuffer.length > 0) {
555296 const remaining = new TextEncoder().encode(lineBuffer);
556297 lineBuffer = "";
564305 }
565306 }
566307 } catch (err) {
567 console.error(
568 `[SmtpReceiver] Error processing command from ${remoteAddress}:`,
569 err,
570 );
308 console.error(`[SmtpReceiver] Error processing command from ${remoteAddress}:`, err);
571309 socket.write("451 Internal server error\r\n");
572310 }
573311 }
574312 });
575
576 socket.on("timeout", () => {
577 socket.write("421 Connection timed out\r\n");
578 socket.end();
579 });
580
581 socket.on("error", (err) => {
582 console.warn(`[SmtpReceiver] Socket error from ${remoteAddress}:`, err.message);
583 });
584
585 socket.on("close", () => {
586 this.activeConnections.delete(socket);
587 });
313 socket.on("timeout", () => { socket.write("421 Connection timed out\r\n"); socket.end(); });
314 socket.on("error", (err) => { console.warn(`[SmtpReceiver] Socket error from ${remoteAddress}:`, err.message); });
315 socket.on("close", () => { this.activeConnections.delete(socket); });
588316 }
589317
590318 async stop(): Promise<void> {
591319 if (!this.running) return;
592320 this.running = false;
593
594 // Close all active connections
595321 for (const socket of this.activeConnections) {
596322 socket.write("421 Service shutting down\r\n");
597323 socket.end();
598324 }
599325 this.activeConnections.clear();
600
601 // Close the server
602 if (this.server) {
603 await new Promise<void>((resolve) => {
604 this.server!.close(() => resolve());
605 });
326 const srv = this.server;
327 if (srv) {
328 await new Promise<void>((resolve) => { srv.close(() => { resolve(); }); });
606329 this.server = null;
607330 }
608
609331 console.log("[SmtpReceiver] Stopped");
610332 }
611333
612 isRunning(): boolean {
613 return this.running;
614 }
615
616 getConnectionCount(): number {
617 return this.activeConnections.size;
618 }
619
620 /**
621 * Create a connection handler for testing or manual connection management.
622 */
334 isRunning(): boolean { return this.running; }
335 getConnectionCount(): number { return this.activeConnections.size; }
623336 createHandler(remoteAddress: string, remotePort: number): SmtpConnectionHandler {
624 return new SmtpConnectionHandler(this.config, remoteAddress, remotePort, this.rateLimiter);
337 return new SmtpConnectionHandler(this.config, remoteAddress, remotePort);
625338 }
626339}
627
628// Re-export for testing
629export { InboundRateLimiter };
630export type { SmtpReceiverConfig, DomainCheckResult, DomainVerifier };
631340
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts