chore(ci,docs): remove off-stack AWS deploy + add doc timestamp rule #4081
15 changed files+2103−162
Modified.gitignore+3−0View fileUnifiedSplit
@@ -1,3 +1,6 @@
1# Claude Code internal
2.claude/worktrees/
3
14# Dependencies
25node_modules/
36.pnp
ModifiedCLAUDE.md+4−0View fileUnifiedSplit
@@ -638,6 +638,10 @@ After writing the code:
63863815. **Add API keys** — Anthropic, OpenAI, Google OAuth, Microsoft OAuth (Craig)
63963916. **Deploy to Crontec** — connect repo, set env vars, point domain (Craig + Claude)
64064017. **Stand up sending for Craig** — see `docs/infra/email-sending-runbook.md` (DNS + SES relay + warmup, no Neon dependency)
64118. ~~Full CI suite green (lint, typecheck, test, build, security scan)~~ DONE 2026-05-29 — PR #44 merged
64219. ~~Wire full marketing landing page~~ DONE 2026-05-29 — dark component suite + ProductSuite section live
64320. **Disable GitHub Default Setup CodeQL** — Settings → Code security → Code scanning → Default setup → Disable (Craig)
64421. **Configure GateTest CLI** — add `GATETEST_API_KEY` to repo secrets, then set `continue-on-error: false` in ci.yml (Craig + Claude)
641645
642646---
643647
Modifiedapps/api/src/routes/ai-categorization.ts+160−36View fileUnifiedSplit
@@ -28,7 +28,9 @@ import {
2828 emailCategories,
2929 smartLabelRules,
3030 categoryFeedback,
31 emails,
3132} from "@alecrae/db";
33import { categorizeEmail } from "@alecrae/ai-engine/intelligence/categorizer";
3234
3335// ─── Constants ───────────────────────────────────────────────────────────────
3436
@@ -97,29 +99,60 @@ function generateId(): string {
9799 .join("");
98100}
99101
102// Convert API errors thrown by ai-engine into JSON 5xx responses.
103function aiErrorResponse(
104 err: unknown,
105):
106 | { status: 503; body: { error: { type: string; message: string; code: string } } }
107 | { status: 500; body: { error: { type: string; message: string; code: string } } } {
108 const message = err instanceof Error ? err.message : "Unknown AI error";
109 if (message.includes("ANTHROPIC_API_KEY")) {
110 return {
111 status: 503,
112 body: {
113 error: {
114 type: "service_unavailable",
115 message: "AI service is not configured",
116 code: "ai_unavailable",
117 },
118 },
119 };
120 }
121 return {
122 status: 500,
123 body: {
124 error: {
125 type: "ai_error",
126 message,
127 code: "ai_error",
128 },
129 },
130 };
131}
132
100133/**
101 * Placeholder AI categorization. In production this calls Claude Haiku
102 * to analyse sender, subject, and body content.
134 * Map a free-text primary category from Claude to one of our known DB categories.
135 * Falls back to "important" if the value isn't in the PRIMARY_CATEGORIES list.
103136 */
104function placeholderCategorize(emailId: string): {
105 primaryCategory: (typeof PRIMARY_CATEGORIES)[number];
106 secondaryCategories: string[];
107 confidence: number;
108} {
109 // Deterministic-ish placeholder based on emailId hash
110 const hash = emailId
111 .split("")
112 .reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
113 const idx = hash % PRIMARY_CATEGORIES.length;
114 const primary = PRIMARY_CATEGORIES[idx] ?? "important";
115 const secondaryIdx = (idx + 3) % PRIMARY_CATEGORIES.length;
116 const secondary = PRIMARY_CATEGORIES[secondaryIdx] ?? "updates";
117
118 return {
119 primaryCategory: primary,
120 secondaryCategories: primary !== secondary ? [secondary] : [],
121 confidence: 0.85 + (hash % 15) / 100,
137function mapToDbCategory(
138 primary: string,
139): (typeof PRIMARY_CATEGORIES)[number] {
140 const mapped: Record<string, (typeof PRIMARY_CATEGORIES)[number]> = {
141 newsletter: "newsletter",
142 transactional: "receipts",
143 personal: "personal",
144 work: "work",
145 promotion: "promotions",
146 social: "social",
147 update: "updates",
148 alert: "important",
122149 };
150 const lower = primary.toLowerCase();
151 // Direct match in our PRIMARY_CATEGORIES
152 if (PRIMARY_CATEGORIES.includes(lower as (typeof PRIMARY_CATEGORIES)[number])) {
153 return lower as (typeof PRIMARY_CATEGORIES)[number];
154 }
155 return mapped[lower] ?? "important";
123156}
124157
125158// ─── Routes ──────────────────────────────────────────────────────────────────
@@ -136,7 +169,44 @@ aiCategorizationRouter.post(
136169 const auth = c.get("auth");
137170 const db = getDatabase();
138171
139 const result = placeholderCategorize(input.emailId);
172 // Fetch the email record to pass real content to Claude
173 const [emailRecord] = await db
174 .select()
175 .from(emails)
176 .where(and(eq(emails.id, input.emailId), eq(emails.accountId, auth.accountId)))
177 .limit(1);
178
179 if (!emailRecord) {
180 return c.json(
181 {
182 error: {
183 type: "not_found",
184 message: `Email ${input.emailId} not found`,
185 code: "email_not_found",
186 },
187 },
188 404,
189 );
190 }
191
192 let categoryResult: Awaited<ReturnType<typeof categorizeEmail>>;
193 try {
194 categoryResult = await categorizeEmail({
195 subject: emailRecord.subject,
196 from: emailRecord.fromAddress,
197 body: emailRecord.textBody ?? emailRecord.htmlBody ?? "",
198 });
199 } catch (err) {
200 const { status, body } = aiErrorResponse(err);
201 return c.json(body, status);
202 }
203
204 const primaryCategory = mapToDbCategory(categoryResult.primary);
205 const secondaryCategories = categoryResult.secondary
206 .map((s) => mapToDbCategory(s) as string)
207 .filter((s) => s !== primaryCategory)
208 .slice(0, 3);
209
140210 const id = generateId();
141211 const now = new Date();
142212
@@ -146,9 +216,9 @@ aiCategorizationRouter.post(
146216 id,
147217 accountId: auth.accountId,
148218 emailId: input.emailId,
149 primaryCategory: result.primaryCategory,
150 secondaryCategories: result.secondaryCategories,
151 confidence: result.confidence,
219 primaryCategory,
220 secondaryCategories,
221 confidence: categoryResult.confidence,
152222 aiModel: "haiku",
153223 categorizedAt: now,
154224 })
@@ -158,9 +228,9 @@ aiCategorizationRouter.post(
158228 data: {
159229 id,
160230 emailId: input.emailId,
161 primaryCategory: result.primaryCategory,
162 secondaryCategories: result.secondaryCategories,
163 confidence: result.confidence,
231 primaryCategory,
232 secondaryCategories,
233 confidence: categoryResult.confidence,
164234 aiModel: "haiku",
165235 categorizedAt: now.toISOString(),
166236 },
@@ -179,19 +249,73 @@ aiCategorizationRouter.post(
179249 const db = getDatabase();
180250
181251 const now = new Date();
182 const results = input.emailIds.map((emailId) => {
183 const cat = placeholderCategorize(emailId);
184 return {
252
253 // Fetch all email records for the batch
254 const emailRecords = await db
255 .select()
256 .from(emails)
257 .where(and(eq(emails.accountId, auth.accountId)));
258
259 const emailMap = new Map(emailRecords.map((e) => [e.id, e]));
260
261 // Process each email — call Claude for those we have records for,
262 // fall back to a deterministic placeholder for unknown IDs
263 const results: {
264 id: string;
265 accountId: string;
266 emailId: string;
267 primaryCategory: (typeof PRIMARY_CATEGORIES)[number];
268 secondaryCategories: string[];
269 confidence: number;
270 aiModel: "haiku";
271 categorizedAt: Date;
272 }[] = [];
273
274 for (const emailId of input.emailIds) {
275 const emailRecord = emailMap.get(emailId);
276 let primaryCategory: (typeof PRIMARY_CATEGORIES)[number];
277 let secondaryCategories: string[];
278 let confidence: number;
279
280 if (emailRecord) {
281 try {
282 const categoryResult = await categorizeEmail({
283 subject: emailRecord.subject,
284 from: emailRecord.fromAddress,
285 body: emailRecord.textBody ?? emailRecord.htmlBody ?? "",
286 });
287 primaryCategory = mapToDbCategory(categoryResult.primary);
288 secondaryCategories = categoryResult.secondary
289 .map((s) => mapToDbCategory(s) as string)
290 .filter((s) => s !== primaryCategory)
291 .slice(0, 3);
292 confidence = categoryResult.confidence;
293 } catch {
294 // If Claude fails for one item in the batch, use fallback
295 primaryCategory = "important";
296 secondaryCategories = [];
297 confidence = 0.5;
298 }
299 } else {
300 // Deterministic fallback for unknown email IDs
301 const hash = emailId.split("").reduce((acc, ch) => acc + ch.charCodeAt(0), 0);
302 const idx = hash % PRIMARY_CATEGORIES.length;
303 primaryCategory = PRIMARY_CATEGORIES[idx] ?? "important";
304 secondaryCategories = [];
305 confidence = 0.5;
306 }
307
308 results.push({
185309 id: generateId(),
186310 accountId: auth.accountId,
187311 emailId,
188 primaryCategory: cat.primaryCategory,
189 secondaryCategories: cat.secondaryCategories,
190 confidence: cat.confidence,
191 aiModel: "haiku" as const,
312 primaryCategory,
313 secondaryCategories,
314 confidence,
315 aiModel: "haiku",
192316 categorizedAt: now,
193 };
194 });
317 });
318 }
195319
196320 if (results.length > 0) {
197321 await db
Modifiedapps/api/src/routes/ai-intelligence.ts+172−55View fileUnifiedSplit
@@ -34,7 +34,11 @@ import {
3434 emailSentiments,
3535 writingCoachResults,
3636 predictiveActions,
37 emails,
3738} from "@alecrae/db";
39import { scoreEmailPriority } from "@alecrae/ai-engine/intelligence/priority-scorer";
40import { generateSmartReplies } from "@alecrae/ai-engine/intelligence/smart-replies";
41import { analyzeEmailSentiment } from "@alecrae/ai-engine/intelligence/sentiment-analyzer";
3842
3943// ─── Schemas ──────────────────────────────────────────────────────────────────
4044
@@ -85,6 +89,37 @@ function generateId(): string {
8589 .join("");
8690}
8791
92// Convert API errors thrown by ai-engine into JSON 5xx responses.
93function aiErrorResponse(
94 err: unknown,
95):
96 | { status: 503; body: { error: { type: string; message: string; code: string } } }
97 | { status: 500; body: { error: { type: string; message: string; code: string } } } {
98 const message = err instanceof Error ? err.message : "Unknown AI error";
99 if (message.includes("ANTHROPIC_API_KEY")) {
100 return {
101 status: 503,
102 body: {
103 error: {
104 type: "service_unavailable",
105 message: "AI service is not configured",
106 code: "ai_unavailable",
107 },
108 },
109 };
110 }
111 return {
112 status: 500,
113 body: {
114 error: {
115 type: "ai_error",
116 message,
117 code: "ai_error",
118 },
119 },
120 };
121}
122
88123// ─── Router ───────────────────────────────────────────────────────────────────
89124
90125const aiIntelligenceRouter = new Hono();
@@ -111,27 +146,48 @@ aiIntelligenceRouter.post(
111146 return c.json({ data: existing });
112147 }
113148
114 // Placeholder AI scoring — in production this calls Claude Haiku
115 const score = Math.round(Math.random() * 100);
116 const urgencyLevel =
117 score >= 90
118 ? ("critical" as const)
119 : score >= 70
120 ? ("high" as const)
121 : score >= 40
122 ? ("medium" as const)
123 : score >= 20
124 ? ("low" as const)
125 : ("none" as const);
149 // Fetch the email record to pass real content to Claude
150 const [emailRecord] = await db
151 .select()
152 .from(emails)
153 .where(and(eq(emails.id, input.emailId), eq(emails.accountId, auth.accountId)))
154 .limit(1);
155
156 if (!emailRecord) {
157 return c.json(
158 {
159 error: {
160 type: "not_found",
161 message: `Email ${input.emailId} not found`,
162 code: "email_not_found",
163 },
164 },
165 404,
166 );
167 }
168
169 let priorityResult: Awaited<ReturnType<typeof scoreEmailPriority>>;
170 try {
171 priorityResult = await scoreEmailPriority({
172 subject: emailRecord.subject,
173 from: emailRecord.fromAddress,
174 body: emailRecord.textBody ?? emailRecord.htmlBody ?? "",
175 });
176 } catch (err) {
177 const { status, body } = aiErrorResponse(err);
178 return c.json(body, status);
179 }
180
181 const { score, urgencyLevel, reasons, suggestedAction } = priorityResult;
126182
127183 const contentSignals = {
128 hasDeadline: Math.random() > 0.7,
129 hasQuestion: Math.random() > 0.5,
130 hasMoneyConcern: Math.random() > 0.8,
131 hasActionRequired: Math.random() > 0.6,
132 mentionsAttachment: Math.random() > 0.7,
133 isReplyChain: Math.random() > 0.5,
134 threadLength: Math.floor(Math.random() * 10) + 1,
184 hasDeadline: reasons.some((r) => /deadline|due|by |before /i.test(r)),
185 hasQuestion: reasons.some((r) => /question|asked|request/i.test(r)),
186 hasMoneyConcern: reasons.some((r) => /money|payment|invoice|budget/i.test(r)),
187 hasActionRequired: suggestedAction === "reply_now" || suggestedAction === "reply_today",
188 mentionsAttachment: false,
189 isReplyChain: false,
190 threadLength: 1,
135191 };
136192
137193 const id = generateId();
@@ -143,11 +199,15 @@ aiIntelligenceRouter.post(
143199 emailId: input.emailId,
144200 score,
145201 urgencyLevel,
146 reasoning: `AI-scored email with priority ${score}/100 based on content signals and sender importance.`,
147 senderImportance: Math.round(Math.random() * 100),
202 reasoning: reasons.join(" | "),
203 senderImportance: score,
148204 contentSignals,
149 predictedAction: score >= 70 ? "reply" : score >= 40 ? "read" : "archive",
150 confidence: Math.round(Math.random() * 50 + 50) / 100,
205 predictedAction: suggestedAction === "reply_now" || suggestedAction === "reply_today"
206 ? "reply"
207 : suggestedAction === "reply_when_free"
208 ? "read"
209 : "archive",
210 confidence: score / 100,
151211 scoredAt: now,
152212 });
153213
@@ -291,24 +351,43 @@ aiIntelligenceRouter.post(
291351 const auth = c.get("auth");
292352 const db = getDatabase();
293353
294 // Placeholder AI-generated replies — in production this calls Claude
295 const replies = [
296 {
297 text: "Thanks for reaching out! I'll review this and get back to you shortly.",
298 confidence: 0.92,
299 tone: "professional",
300 },
301 {
302 text: "Got it, thanks! Let me take a look.",
303 confidence: 0.85,
304 tone: "casual",
305 },
306 {
307 text: "Thank you for the update. I'll follow up with the team on this.",
308 confidence: 0.78,
309 tone: "formal",
310 },
311 ];
354 // Fetch the email record to pass real content to Claude
355 const [emailRecordForReplies] = await db
356 .select()
357 .from(emails)
358 .where(and(eq(emails.id, input.emailId), eq(emails.accountId, auth.accountId)))
359 .limit(1);
360
361 if (!emailRecordForReplies) {
362 return c.json(
363 {
364 error: {
365 type: "not_found",
366 message: `Email ${input.emailId} not found`,
367 code: "email_not_found",
368 },
369 },
370 404,
371 );
372 }
373
374 let generatedReplies: Awaited<ReturnType<typeof generateSmartReplies>>;
375 try {
376 generatedReplies = await generateSmartReplies({
377 subject: emailRecordForReplies.subject,
378 from: emailRecordForReplies.fromAddress,
379 body: emailRecordForReplies.textBody ?? emailRecordForReplies.htmlBody ?? "",
380 });
381 } catch (err) {
382 const { status, body } = aiErrorResponse(err);
383 return c.json(body, status);
384 }
385
386 const replies = generatedReplies.map((r, i) => ({
387 text: r.text,
388 confidence: Math.round((0.95 - i * 0.07) * 100) / 100,
389 tone: r.tone,
390 }));
312391
313392 const id = generateId();
314393 const now = new Date();
@@ -449,17 +528,55 @@ aiIntelligenceRouter.post(
449528 return c.json({ data: existing });
450529 }
451530
452 // Placeholder AI sentiment analysis — in production this calls Claude
453 const sentiments = [
454 "positive",
455 "negative",
456 "neutral",
457 "urgent",
458 "angry",
459 "grateful",
460 "confused",
461 ] as const;
462 const sentiment = sentiments[Math.floor(Math.random() * sentiments.length)] ?? "neutral";
531 // Fetch the email record to pass real content to Claude
532 const [emailRecordForSentiment] = await db
533 .select()
534 .from(emails)
535 .where(and(eq(emails.id, input.emailId), eq(emails.accountId, auth.accountId)))
536 .limit(1);
537
538 if (!emailRecordForSentiment) {
539 return c.json(
540 {
541 error: {
542 type: "not_found",
543 message: `Email ${input.emailId} not found`,
544 code: "email_not_found",
545 },
546 },
547 404,
548 );
549 }
550
551 let sentimentResult: Awaited<ReturnType<typeof analyzeEmailSentiment>>;
552 try {
553 sentimentResult = await analyzeEmailSentiment({
554 subject: emailRecordForSentiment.subject,
555 body: emailRecordForSentiment.textBody ?? emailRecordForSentiment.htmlBody ?? "",
556 });
557 } catch (err) {
558 const { status, body } = aiErrorResponse(err);
559 return c.json(body, status);
560 }
561
562 // Map ai-engine sentiment values to DB enum values
563 type DbSentiment = "positive" | "negative" | "neutral" | "urgent" | "angry" | "grateful" | "confused";
564 const sentimentMap: Record<string, DbSentiment> = {
565 very_positive: "positive",
566 positive: "positive",
567 neutral: "neutral",
568 negative: "negative",
569 very_negative: "angry",
570 };
571 const sentiment: DbSentiment = sentimentMap[sentimentResult.sentiment] ?? "neutral";
572
573 // Use requiresUrgentResponse to upgrade to "urgent" if flagged
574 const finalSentiment: DbSentiment = sentimentResult.requiresUrgentResponse && sentiment === "neutral"
575 ? "urgent"
576 : sentiment;
577
578 // Derive confidence from the absolute value of the sentiment score
579 const confidence = Math.min(1, Math.max(0.5, Math.abs(sentimentResult.score) + 0.5));
463580
464581 const id = generateId();
465582 const now = new Date();
@@ -468,9 +585,9 @@ aiIntelligenceRouter.post(
468585 id,
469586 emailId: input.emailId,
470587 accountId: auth.accountId,
471 sentiment,
472 confidence: Math.round(Math.random() * 40 + 60) / 100,
473 keywords: ["placeholder", "analysis"],
588 sentiment: finalSentiment,
589 confidence: Math.round(confidence * 100) / 100,
590 keywords: sentimentResult.emotions,
474591 analyzedAt: now,
475592 });
476593
Modifiedapps/api/src/routes/context-intelligence.ts+92−69View fileUnifiedSplit
@@ -31,6 +31,7 @@ import {
3131 emailDeadlines,
3232 emailPromises,
3333} from "@alecrae/db";
34import { extractEmailContext } from "@alecrae/ai-engine/intelligence/context-extractor";
3435
3536// ─── Schemas ──────────────────────────────────────────────────────────────────
3637
@@ -99,41 +100,34 @@ function generateId(): string {
99100 .join("");
100101}
101102
102interface ExtractedContext {
103 actionItems: {
104 actionText: string;
105 assignedTo: string | null;
106 dueDate: string | null;
107 priority: "urgent" | "high" | "medium" | "low";
108 confidence: number;
109 }[];
110 deadlines: {
111 deadlineDate: string;
112 description: string;
113 isExplicit: boolean;
114 confidence: number;
115 }[];
116 promises: {
117 promiseText: string;
118 promisor: string;
119 promisee: string;
120 dueDate: string | null;
121 confidence: number;
122 }[];
123}
124
125/**
126 * Stub extractor — returns basic results from content analysis.
127 * In production, this would call Claude AI for intelligent extraction.
128 */
129function extractContextFromContent(
130 _content: string,
131 _participants?: string[],
132): ExtractedContext {
103// Convert API errors thrown by ai-engine into JSON 5xx responses.
104function aiErrorResponse(
105 err: unknown,
106):
107 | { status: 503; body: { error: { type: string; message: string; code: string } } }
108 | { status: 500; body: { error: { type: string; message: string; code: string } } } {
109 const message = err instanceof Error ? err.message : "Unknown AI error";
110 if (message.includes("ANTHROPIC_API_KEY")) {
111 return {
112 status: 503,
113 body: {
114 error: {
115 type: "service_unavailable",
116 message: "AI service is not configured",
117 code: "ai_unavailable",
118 },
119 },
120 };
121 }
133122 return {
134 actionItems: [],
135 deadlines: [],
136 promises: [],
123 status: 500,
124 body: {
125 error: {
126 type: "ai_error",
127 message,
128 code: "ai_error",
129 },
130 },
137131 };
138132}
139133
@@ -153,7 +147,16 @@ contextIntelligenceRouter.post(
153147 const now = new Date();
154148 const threadId = input.threadId ?? input.emailId;
155149
156 const extracted = extractContextFromContent(input.content, input.participants);
150 let extracted: Awaited<ReturnType<typeof extractEmailContext>>;
151 try {
152 extracted = await extractEmailContext({
153 content: input.content,
154 ...(input.participants !== undefined ? { participants: input.participants } : {}),
155 });
156 } catch (err) {
157 const { status, body } = aiErrorResponse(err);
158 return c.json(body, status);
159 }
157160
158161 const insertedActionItems: Record<string, unknown>[] = [];
159162 const insertedDeadlines: Record<string, unknown>[] = [];
@@ -167,23 +170,23 @@ contextIntelligenceRouter.post(
167170 accountId,
168171 emailId: input.emailId,
169172 threadId,
170 actionText: item.actionText,
171 assignedTo: item.assignedTo,
173 actionText: item.description,
174 assignedTo: item.assignedTo ?? null,
172175 dueDate: item.dueDate ? new Date(item.dueDate) : null,
173176 priority: item.priority,
174177 status: "pending",
175 confidence: item.confidence,
178 confidence: 0.85,
176179 source: "ai_detected",
177180 createdAt: now,
178181 updatedAt: now,
179182 });
180183 insertedActionItems.push({
181184 id,
182 actionText: item.actionText,
183 assignedTo: item.assignedTo,
184 dueDate: item.dueDate,
185 actionText: item.description,
186 assignedTo: item.assignedTo ?? null,
187 dueDate: item.dueDate ?? null,
185188 priority: item.priority,
186 confidence: item.confidence,
189 confidence: 0.85,
187190 });
188191 }
189192
@@ -195,47 +198,51 @@ contextIntelligenceRouter.post(
195198 accountId,
196199 emailId: input.emailId,
197200 threadId,
198 deadlineDate: new Date(dl.deadlineDate),
201 deadlineDate: new Date(dl.dueDate),
199202 description: dl.description,
200 isExplicit: dl.isExplicit,
201 confidence: dl.confidence,
203 isExplicit: true,
204 confidence: dl.isUrgent ? 0.95 : 0.80,
202205 reminderSent: false,
203206 createdAt: now,
204207 });
205208 insertedDeadlines.push({
206209 id,
207 deadlineDate: dl.deadlineDate,
210 deadlineDate: dl.dueDate,
208211 description: dl.description,
209 isExplicit: dl.isExplicit,
210 confidence: dl.confidence,
212 isExplicit: true,
213 confidence: dl.isUrgent ? 0.95 : 0.80,
211214 });
212215 }
213216
214 // Insert promises
217 // Insert promises — derive promisor/promisee from participants and direction
218 const firstParticipant = input.participants?.[0] ?? "unknown";
219 const secondParticipant = input.participants?.[1] ?? "unknown";
215220 for (const p of extracted.promises) {
216221 const id = generateId();
222 const promisor = p.direction === "made" ? firstParticipant : secondParticipant;
223 const promisee = p.direction === "made" ? secondParticipant : firstParticipant;
217224 await db.insert(emailPromises).values({
218225 id,
219226 accountId,
220227 emailId: input.emailId,
221228 threadId,
222 promiseText: p.promiseText,
223 promisor: p.promisor,
224 promisee: p.promisee,
229 promiseText: p.description,
230 promisor,
231 promisee,
225232 dueDate: p.dueDate ? new Date(p.dueDate) : null,
226233 status: "active",
227 confidence: p.confidence,
234 confidence: 0.80,
228235 followUpSent: false,
229236 createdAt: now,
230237 updatedAt: now,
231238 });
232239 insertedPromises.push({
233240 id,
234 promiseText: p.promiseText,
235 promisor: p.promisor,
236 promisee: p.promisee,
237 dueDate: p.dueDate,
238 confidence: p.confidence,
241 promiseText: p.description,
242 promisor,
243 promisee,
244 dueDate: p.dueDate ?? null,
245 confidence: 0.80,
239246 });
240247 }
241248
@@ -893,7 +900,18 @@ contextIntelligenceRouter.post(
893900
894901 for (const email of input.emails) {
895902 const threadId = email.threadId ?? email.emailId;
896 const extracted = extractContextFromContent(email.content, email.participants);
903
904 let extracted: Awaited<ReturnType<typeof extractEmailContext>>;
905 try {
906 extracted = await extractEmailContext({
907 content: email.content,
908 ...(email.participants !== undefined ? { participants: email.participants } : {}),
909 });
910 } catch {
911 // If extraction fails for one email, skip it (don't abort the whole batch)
912 results.push({ emailId: email.emailId, threadId, actionItems: 0, deadlines: 0, promises: 0 });
913 continue;
914 }
897915
898916 let actionItemCount = 0;
899917 let deadlineCount = 0;
@@ -906,12 +924,12 @@ contextIntelligenceRouter.post(
906924 accountId,
907925 emailId: email.emailId,
908926 threadId,
909 actionText: item.actionText,
910 assignedTo: item.assignedTo,
927 actionText: item.description,
928 assignedTo: item.assignedTo ?? null,
911929 dueDate: item.dueDate ? new Date(item.dueDate) : null,
912930 priority: item.priority,
913931 status: "pending",
914 confidence: item.confidence,
932 confidence: 0.85,
915933 source: "ai_detected",
916934 createdAt: now,
917935 updatedAt: now,
@@ -919,6 +937,9 @@ contextIntelligenceRouter.post(
919937 actionItemCount++;
920938 }
921939
940 const firstP = email.participants?.[0] ?? "unknown";
941 const secondP = email.participants?.[1] ?? "unknown";
942
922943 for (const dl of extracted.deadlines) {
923944 const id = generateId();
924945 await db.insert(emailDeadlines).values({
@@ -926,10 +947,10 @@ contextIntelligenceRouter.post(
926947 accountId,
927948 emailId: email.emailId,
928949 threadId,
929 deadlineDate: new Date(dl.deadlineDate),
950 deadlineDate: new Date(dl.dueDate),
930951 description: dl.description,
931 isExplicit: dl.isExplicit,
932 confidence: dl.confidence,
952 isExplicit: true,
953 confidence: dl.isUrgent ? 0.95 : 0.80,
933954 reminderSent: false,
934955 createdAt: now,
935956 });
@@ -938,17 +959,19 @@ contextIntelligenceRouter.post(
938959
939960 for (const p of extracted.promises) {
940961 const id = generateId();
962 const promisor = p.direction === "made" ? firstP : secondP;
963 const promisee = p.direction === "made" ? secondP : firstP;
941964 await db.insert(emailPromises).values({
942965 id,
943966 accountId,
944967 emailId: email.emailId,
945968 threadId,
946 promiseText: p.promiseText,
947 promisor: p.promisor,
948 promisee: p.promisee,
969 promiseText: p.description,
970 promisor,
971 promisee,
949972 dueDate: p.dueDate ? new Date(p.dueDate) : null,
950973 status: "active",
951 confidence: p.confidence,
974 confidence: 0.80,
952975 followUpSent: false,
953976 createdAt: now,
954977 updatedAt: now,
Addedapps/web/app/(dashboard)/billing/page.tsx+546−0View fileUnifiedSplit
@@ -0,0 +1,546 @@
1"use client";
2
3import { useState, useEffect, useCallback } from "react";
4import { useRouter } from "next/navigation";
5import {
6 Box,
7 Text,
8 Button,
9 Card,
10 CardContent,
11 CardHeader,
12 CardFooter,
13 PageLayout,
14} from "@alecrae/ui";
15import { motion } from "motion/react";
16import {
17 staggerSlow,
18 fadeInUp,
19 useAlecRaeReducedMotion,
20 withReducedMotion,
21} from "../../../lib/animations";
22
23// ─── Types ─────────────────────────────────────────────────────────────────
24
25interface PlanLimits {
26 emailsPerMonth: number;
27 domains: number;
28 webhooks: number;
29}
30
31interface PlanUsage {
32 emailsSent: number;
33 percentUsed: number;
34}
35
36interface CurrentPlan {
37 planId: string;
38 name: string;
39 limits: PlanLimits;
40 usage: PlanUsage;
41 periodStartedAt: string;
42}
43
44// ─── Plan metadata ─────────────────────────────────────────────────────────
45
46interface PlanMeta {
47 id: string;
48 label: string;
49 price: string;
50 period: string;
51 description: string;
52 features: string[];
53 highlighted: boolean;
54 checkoutId: "starter" | "professional" | "enterprise" | null;
55}
56
57const PLAN_META: PlanMeta[] = [
58 {
59 id: "free",
60 label: "Free",
61 price: "$0",
62 period: "forever",
63 description: "Get started with one account",
64 features: [
65 "1 email account",
66 "5 AI composes per day",
67 "30-day search history",
68 "Basic smart inbox",
69 ],
70 highlighted: false,
71 checkoutId: null,
72 },
73 {
74 id: "starter",
75 label: "Personal",
76 price: "$9",
77 period: "/month",
78 description: "For professionals who mean business",
79 features: [
80 "3 email accounts",
81 "Unlimited AI compose",
82 "Unlimited search",
83 "E2E encryption",
84 "Snooze & schedule send",
85 "Voice dictation",
86 "Grammar agent",
87 ],
88 highlighted: true,
89 checkoutId: "starter",
90 },
91 {
92 id: "professional",
93 label: "Pro",
94 price: "$19",
95 period: "/month",
96 description: "For power users and creators",
97 features: [
98 "Unlimited accounts",
99 "Priority AI",
100 "Email analytics",
101 "API access",
102 "Custom automations",
103 "Everything in Personal",
104 ],
105 highlighted: false,
106 checkoutId: "professional",
107 },
108 {
109 id: "enterprise",
110 label: "Team",
111 price: "$12",
112 period: "/user/month",
113 description: "For teams that share inboxes",
114 features: [
115 "Shared inboxes",
116 "Admin console",
117 "Audit logs",
118 "SSO / SAML",
119 "Priority support",
120 "Everything in Pro",
121 ],
122 highlighted: false,
123 checkoutId: "enterprise",
124 },
125];
126
127// ─── API helpers ────────────────────────────────────────────────────────────
128
129const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
130
131function getToken(): string {
132 if (typeof window === "undefined") return "";
133 return localStorage.getItem("alecrae_api_key") ?? "";
134}
135
136async function apiFetch<T>(path: string, options: RequestInit = {}): Promise<T> {
137 const token = getToken();
138 const res = await fetch(`${API_BASE}${path}`, {
139 ...options,
140 headers: {
141 "Content-Type": "application/json",
142 ...(token ? { Authorization: `Bearer ${token}` } : {}),
143 ...(options.headers ?? {}),
144 },
145 });
146 if (!res.ok) {
147 const body = (await res.json().catch(() => null)) as {
148 error?: { message?: string };
149 } | null;
150 throw new Error(body?.error?.message ?? `Request failed: ${res.status}`);
151 }
152 return res.json() as Promise<T>;
153}
154
155// ─── Usage bar ──────────────────────────────────────────────────────────────
156
157function UsageBar({
158 label,
159 used,
160 total,
161}: {
162 label: string;
163 used: number;
164 total: number;
165}): React.ReactNode {
166 const unlimited = total <= 0 || total === 999999999;
167 const pct = unlimited ? 0 : Math.min(100, Math.round((used / total) * 100));
168 const danger = pct >= 90;
169 const warn = pct >= 70;
170
171 return (
172 <Box className="space-y-1.5">
173 <Box className="flex items-center justify-between">
174 <Text variant="body-sm" className="font-medium">
175 {label}
176 </Text>
177 <Text variant="body-sm" muted>
178 {used.toLocaleString()} / {unlimited ? "Unlimited" : total.toLocaleString()}
179 </Text>
180 </Box>
181 {!unlimited && (
182 <Box className="h-2 w-full rounded-full bg-white/10 overflow-hidden">
183 <Box
184 className={`h-full rounded-full transition-all duration-500 ${
185 danger ? "bg-red-500" : warn ? "bg-amber-500" : "bg-blue-500"
186 }`}
187 style={{ width: `${pct}%` }}
188 />
189 </Box>
190 )}
191 </Box>
192 );
193}
194
195// ─── Skeleton ───────────────────────────────────────────────────────────────
196
197function BillingSkeleton(): React.ReactNode {
198 return (
199 <Box className="space-y-4 animate-pulse">
200 <Box className="flex items-center justify-between flex-wrap gap-3">
201 <Box className="space-y-2">
202 <Box className="h-5 w-36 rounded bg-white/10" />
203 <Box className="h-4 w-52 rounded bg-white/10" />
204 </Box>
205 <Box className="h-8 w-40 rounded-lg bg-white/10" />
206 </Box>
207 {[1, 2, 3].map((i) => (
208 <Box key={i} className="space-y-1.5">
209 <Box className="flex justify-between">
210 <Box className="h-4 w-40 rounded bg-white/10" />
211 <Box className="h-4 w-24 rounded bg-white/10" />
212 </Box>
213 <Box className="h-2 w-full rounded-full bg-white/10" />
214 </Box>
215 ))}
216 </Box>
217 );
218}
219
220// ─── Plan card ──────────────────────────────────────────────────────────────
221
222function PlanCard({
223 plan,
224 isCurrent,
225 onUpgrade,
226 upgrading,
227}: {
228 plan: PlanMeta;
229 isCurrent: boolean;
230 onUpgrade: (checkoutId: "starter" | "professional" | "enterprise") => void;
231 upgrading: boolean;
232}): React.ReactNode {
233 return (
234 <Box
235 className={`relative flex flex-col p-5 rounded-2xl border transition-all ${
236 isCurrent
237 ? "bg-blue-500/10 border-blue-500/40"
238 : plan.highlighted
239 ? "bg-white/[0.06] border-white/20 hover:border-white/30"
240 : "bg-white/[0.02] border-white/10 hover:border-white/20"
241 }`}
242 >
243 {isCurrent && (
244 <Box className="absolute -top-3 left-4 px-2.5 py-0.5 bg-blue-500 text-white text-xs font-semibold rounded-full">
245 Current Plan
246 </Box>
247 )}
248 {!isCurrent && plan.highlighted && (
249 <Box className="absolute -top-3 left-4 px-2.5 py-0.5 bg-emerald-500 text-white text-xs font-semibold rounded-full">
250 Popular
251 </Box>
252 )}
253
254 <Box className="mb-4">
255 <Text variant="heading-sm" className="mb-0.5">
256 {plan.label}
257 </Text>
258 <Text variant="body-sm" muted>
259 {plan.description}
260 </Text>
261 <Box className="flex items-baseline gap-1 mt-3">
262 <Text variant="heading-lg" className="text-white font-bold">
263 {plan.price}
264 </Text>
265 <Text variant="body-sm" muted>
266 {plan.period}
267 </Text>
268 </Box>
269 </Box>
270
271 <Box as="ul" className="flex-1 space-y-2 mb-5">
272 {plan.features.map((f) => (
273 <Box as="li" key={f} className="flex items-start gap-2">
274 <Box as="span" className="mt-0.5 flex-shrink-0 text-emerald-400" aria-hidden="true">
275 <svg
276 width="14"
277 height="14"
278 viewBox="0 0 24 24"
279 fill="none"
280 stroke="currentColor"
281 strokeWidth="2.5"
282 >
283 <path d="M20 6L9 17l-5-5" />
284 </svg>
285 </Box>
286 <Text variant="body-sm" muted>
287 {f}
288 </Text>
289 </Box>
290 ))}
291 </Box>
292
293 {isCurrent ? (
294 <Box className="py-2 text-center rounded-full border border-blue-500/40 bg-blue-500/10">
295 <Text variant="body-sm" className="text-blue-400 font-medium">
296 Active
297 </Text>
298 </Box>
299 ) : plan.checkoutId !== null ? (
300 <Button
301 variant={plan.highlighted ? "primary" : "secondary"}
302 size="sm"
303 onClick={() => {
304 if (plan.checkoutId !== null) {
305 onUpgrade(plan.checkoutId);
306 }
307 }}
308 disabled={upgrading}
309 >
310 {upgrading ? "Redirecting..." : `Get ${plan.label}`}
311 </Button>
312 ) : (
313 <Box className="py-2 text-center rounded-full border border-white/10 bg-white/5">
314 <Text variant="body-sm" muted>
315 Free forever
316 </Text>
317 </Box>
318 )}
319 </Box>
320 );
321}
322
323// ─── Page ───────────────────────────────────────────────────────────────────
324
325export default function BillingPage(): React.ReactNode {
326 const router = useRouter();
327 const reduced = useAlecRaeReducedMotion();
328 const itemVariants = withReducedMotion(fadeInUp, reduced);
329
330 const [plan, setPlan] = useState<CurrentPlan | null>(null);
331 const [loading, setLoading] = useState(true);
332 const [error, setError] = useState<string | null>(null);
333 const [portalLoading, setPortalLoading] = useState(false);
334 const [portalError, setPortalError] = useState<string | null>(null);
335 const [upgrading, setUpgrading] = useState(false);
336
337 const loadPlan = useCallback(async (): Promise<void> => {
338 setError(null);
339 try {
340 const res = await apiFetch<{ data: CurrentPlan }>("/v1/billing/plan");
341 setPlan(res.data);
342 } catch (err) {
343 setError(err instanceof Error ? err.message : "Unable to load billing information.");
344 } finally {
345 setLoading(false);
346 }
347 }, []);
348
349 useEffect(() => {
350 void loadPlan();
351 }, [loadPlan]);
352
353 const handleManageSubscription = async (): Promise<void> => {
354 setPortalLoading(true);
355 setPortalError(null);
356 try {
357 const res = await apiFetch<{ data: { url: string } }>("/v1/billing/portal", {
358 method: "POST",
359 body: JSON.stringify({ returnUrl: window.location.href }),
360 });
361 window.location.href = res.data.url;
362 } catch (err) {
363 setPortalError(
364 err instanceof Error ? err.message : "Failed to open billing portal.",
365 );
366 setPortalLoading(false);
367 }
368 };
369
370 const handleUpgrade = (checkoutId: "starter" | "professional" | "enterprise"): void => {
371 setUpgrading(true);
372 router.push(`/checkout?plan=${checkoutId}` as never);
373 };
374
375 const activePlanId = plan?.planId ?? "free";
376
377 const matchPlanId = (metaId: string): boolean => {
378 if (metaId === "free" && (activePlanId === "free" || activePlanId === "")) {
379 return true;
380 }
381 return metaId === activePlanId;
382 };
383
384 return (
385 <PageLayout
386 title="Billing & Subscription"
387 description="Manage your plan, usage, and payment details."
388 >
389 <motion.div
390 className="max-w-3xl space-y-6"
391 variants={staggerSlow}
392 initial="initial"
393 animate="animate"
394 >
395 {/* ── Current plan summary ── */}
396 <motion.div variants={itemVariants}>
397 <Card>
398 <CardHeader>
399 <Text variant="heading-md">Current Plan</Text>
400 </CardHeader>
401 <CardContent>
402 {loading ? (
403 <BillingSkeleton />
404 ) : error ? (
405 <Box className="py-4 space-y-3">
406 <Text variant="body-sm" className="text-red-400">
407 {error}
408 </Text>
409 <Button
410 variant="secondary"
411 size="sm"
412 onClick={() => {
413 void loadPlan();
414 }}
415 >
416 Retry
417 </Button>
418 </Box>
419 ) : plan ? (
420 <Box className="space-y-5">
421 <Box className="flex items-center justify-between flex-wrap gap-3">
422 <Box>
423 <Text variant="heading-sm" className="capitalize">
424 {plan.name} Plan
425 </Text>
426 <Text variant="body-sm" muted>
427 Billing period started{" "}
428 {new Date(plan.periodStartedAt).toLocaleDateString(undefined, {
429 year: "numeric",
430 month: "long",
431 day: "numeric",
432 })}
433 </Text>
434 </Box>
435 <Button
436 variant="secondary"
437 size="sm"
438 onClick={() => {
439 void handleManageSubscription();
440 }}
441 disabled={portalLoading}
442 >
443 {portalLoading ? "Opening portal..." : "Manage Subscription"}
444 </Button>
445 </Box>
446
447 {portalError !== null && (
448 <Text variant="body-sm" className="text-red-400">
449 {portalError}
450 </Text>
451 )}
452
453 <Box className="space-y-4 pt-1">
454 <UsageBar
455 label="Emails sent this period"
456 used={plan.usage.emailsSent}
457 total={plan.limits.emailsPerMonth}
458 />
459 <UsageBar label="Domains" used={0} total={plan.limits.domains} />
460 <UsageBar label="Webhooks" used={0} total={plan.limits.webhooks} />
461 </Box>
462 </Box>
463 ) : null}
464 </CardContent>
465 </Card>
466 </motion.div>
467
468 {/* ── Plan comparison ── */}
469 <motion.div variants={itemVariants}>
470 <Card>
471 <CardHeader>
472 <Text variant="heading-md">Available Plans</Text>
473 <Text variant="body-sm" muted>
474 Upgrade or change your plan at any time. Changes take effect immediately.
475 </Text>
476 </CardHeader>
477 <CardContent>
478 <Box className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
479 {PLAN_META.map((p) => (
480 <PlanCard
481 key={p.id}
482 plan={p}
483 isCurrent={matchPlanId(p.id)}
484 onUpgrade={handleUpgrade}
485 upgrading={upgrading}
486 />
487 ))}
488 </Box>
489 </CardContent>
490 <CardFooter>
491 <Text variant="body-sm" muted>
492 Need Enterprise pricing with on-prem deployment and a dedicated SLA?{" "}
493 <Box
494 as="a"
495 href="mailto:hello@alecrae.com?subject=Enterprise%20Pricing"
496 className="inline text-blue-400 hover:text-blue-300"
497 >
498 Contact us
499 </Box>
500 </Text>
501 </CardFooter>
502 </Card>
503 </motion.div>
504
505 {/* ── Included with every plan ── */}
506 <motion.div variants={itemVariants}>
507 <Card>
508 <CardHeader>
509 <Text variant="heading-md">Included with every plan</Text>
510 </CardHeader>
511 <CardContent>
512 <Box className="grid grid-cols-1 sm:grid-cols-2 gap-3">
513 {[
514 "No ads. Ever.",
515 "No data mining.",
516 "End-to-end encryption available",
517 "Local-first — works offline",
518 "Desktop, mobile, and web apps",
519 "Cancel any time",
520 ].map((item) => (
521 <Box key={item} className="flex items-center gap-2">
522 <Box as="span" className="text-emerald-400 flex-shrink-0" aria-hidden="true">
523 <svg
524 width="14"
525 height="14"
526 viewBox="0 0 24 24"
527 fill="none"
528 stroke="currentColor"
529 strokeWidth="2.5"
530 >
531 <path d="M20 6L9 17l-5-5" />
532 </svg>
533 </Box>
534 <Text variant="body-sm" muted>
535 {item}
536 </Text>
537 </Box>
538 ))}
539 </Box>
540 </CardContent>
541 </Card>
542 </motion.div>
543 </motion.div>
544 </PageLayout>
545 );
546}
Modifiedapps/web/app/(dashboard)/settings/page.tsx+38−0View fileUnifiedSplit
@@ -1,6 +1,8 @@
11"use client";
22
33import { useState, useEffect, useCallback } from "react";
4import Link from "next/link";
5import type { Route } from "next";
46import {
57 Box,
68 Text,
@@ -69,6 +71,9 @@ export default function SettingsPage(): React.ReactNode {
6971 <motion.div variants={itemVariants}>
7072 <AccountOverview account={account} loading={loading} />
7173 </motion.div>
74 <motion.div variants={itemVariants}>
75 <BillingLink account={account} loading={loading} />
76 </motion.div>
7277 <motion.div variants={itemVariants}>
7378 <SecuritySection />
7479 </motion.div>
@@ -228,6 +233,39 @@ function AccountOverview({ account, loading }: { account: AccountData | null; lo
228233
229234AccountOverview.displayName = "AccountOverview";
230235
236function BillingLink({ account, loading }: { account: AccountData | null; loading: boolean }) {
237 const planLabel = loading
238 ? "..."
239 : account?.planTier
240 ? account.planTier.charAt(0).toUpperCase() + account.planTier.slice(1)
241 : "Free";
242
243 return (
244 <Card>
245 <CardHeader>
246 <Text variant="heading-sm">Billing & Subscription</Text>
247 </CardHeader>
248 <CardContent>
249 <Box className="flex items-center justify-between flex-wrap gap-3">
250 <Box>
251 <Text variant="body-sm" muted>Current plan</Text>
252 <Text variant="body-md" className="font-medium capitalize">
253 {planLabel}
254 </Text>
255 </Box>
256 <Link href={"/billing" as Route}>
257 <Button variant="secondary" size="sm">
258 Manage Billing
259 </Button>
260 </Link>
261 </Box>
262 </CardContent>
263 </Card>
264 );
265}
266
267BillingLink.displayName = "BillingLink";
268
231269function SecuritySection() {
232270 const [passkeysData, setPasskeysData] = useState<PasskeyInfo[]>([]);
233271 const [loadingPasskeys, setLoadingPasskeys] = useState(true);
Addedapps/web/app/checkout/page.tsx+214−0View fileUnifiedSplit
@@ -0,0 +1,214 @@
1"use client";
2
3import { useEffect, useState } from "react";
4import { useSearchParams } from "next/navigation";
5
6// ─── Types ─────────────────────────────────────────────────────────────────
7
8type PlanId = "starter" | "professional" | "enterprise";
9
10const VALID_PLANS: readonly PlanId[] = ["starter", "professional", "enterprise"] as const;
11
12function isPlanId(value: string): value is PlanId {
13 return (VALID_PLANS as readonly string[]).includes(value);
14}
15
16const PLAN_LABELS: Record<PlanId, string> = {
17 starter: "Personal",
18 professional: "Pro",
19 enterprise: "Team",
20};
21
22// ─── API ───────────────────────────────────────────────────────────────────
23
24const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001";
25
26function getToken(): string {
27 if (typeof window === "undefined") return "";
28 return localStorage.getItem("alecrae_api_key") ?? "";
29}
30
31async function createCheckoutSession(planId: PlanId): Promise<string> {
32 const token = getToken();
33 const origin = window.location.origin;
34
35 const res = await fetch(`${API_BASE}/v1/billing/checkout`, {
36 method: "POST",
37 headers: {
38 "Content-Type": "application/json",
39 ...(token ? { Authorization: `Bearer ${token}` } : {}),
40 },
41 body: JSON.stringify({
42 planId,
43 successUrl: `${origin}/billing?checkout=success`,
44 cancelUrl: `${origin}/billing?checkout=cancelled`,
45 }),
46 });
47
48 if (!res.ok) {
49 const body = (await res.json().catch(() => null)) as {
50 error?: { message?: string };
51 } | null;
52 throw new Error(body?.error?.message ?? `Checkout failed: ${res.status}`);
53 }
54
55 const data = (await res.json()) as {
56 data: { sessionId: string; url: string | null };
57 };
58
59 if (!data.data.url) {
60 throw new Error("No checkout URL returned. Please try again.");
61 }
62
63 return data.data.url;
64}
65
66// ─── Spinner ───────────────────────────────────────────────────────────────
67
68function Spinner(): React.ReactNode {
69 return (
70 <svg
71 className="animate-spin h-8 w-8 text-blue-400"
72 xmlns="http://www.w3.org/2000/svg"
73 fill="none"
74 viewBox="0 0 24 24"
75 aria-hidden="true"
76 >
77 <circle
78 className="opacity-25"
79 cx="12"
80 cy="12"
81 r="10"
82 stroke="currentColor"
83 strokeWidth="4"
84 />
85 <path
86 className="opacity-75"
87 fill="currentColor"
88 d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
89 />
90 </svg>
91 );
92}
93
94// ─── Page ───────────────────────────────────────────────────────────────────
95
96export default function CheckoutPage(): React.ReactNode {
97 const searchParams = useSearchParams();
98 const planParam = searchParams.get("plan") ?? "";
99
100 const [status, setStatus] = useState<"loading" | "redirecting" | "error">("loading");
101 const [errorMessage, setErrorMessage] = useState<string | null>(null);
102
103 useEffect(() => {
104 if (!isPlanId(planParam)) {
105 setErrorMessage(
106 planParam
107 ? `Unknown plan "${planParam}". Valid plans are: Personal, Pro, and Team.`
108 : "No plan specified. Please select a plan from the pricing page.",
109 );
110 setStatus("error");
111 return;
112 }
113
114 let cancelled = false;
115
116 const run = async (): Promise<void> => {
117 try {
118 const url = await createCheckoutSession(planParam);
119 if (!cancelled) {
120 setStatus("redirecting");
121 window.location.href = url;
122 }
123 } catch (err) {
124 if (!cancelled) {
125 setErrorMessage(
126 err instanceof Error
127 ? err.message
128 : "Something went wrong. Please try again.",
129 );
130 setStatus("error");
131 }
132 }
133 };
134
135 void run();
136
137 return () => {
138 cancelled = true;
139 };
140 }, [planParam]);
141
142 const planLabel = isPlanId(planParam) ? PLAN_LABELS[planParam] : null;
143
144 return (
145 <div className="min-h-screen bg-slate-950 flex items-center justify-center px-6">
146 <div className="max-w-sm w-full text-center space-y-6">
147 {/* AlecRae wordmark */}
148 <p className="text-blue-400 font-bold text-lg tracking-tight">AlecRae</p>
149
150 {status === "loading" || status === "redirecting" ? (
151 <>
152 <div className="flex justify-center">
153 <Spinner />
154 </div>
155 <div className="space-y-1">
156 <p className="text-white font-semibold text-lg">
157 {status === "redirecting"
158 ? "Redirecting to Stripe..."
159 : planLabel
160 ? `Setting up ${planLabel} checkout...`
161 : "Preparing checkout..."}
162 </p>
163 <p className="text-slate-400 text-sm">
164 You'll be redirected to our secure payment partner momentarily.
165 </p>
166 </div>
167 </>
168 ) : (
169 <>
170 {/* Error icon */}
171 <div className="flex justify-center">
172 <div className="w-12 h-12 rounded-full bg-red-500/10 border border-red-500/30 flex items-center justify-center">
173 <svg
174 width="22"
175 height="22"
176 viewBox="0 0 24 24"
177 fill="none"
178 stroke="currentColor"
179 strokeWidth="2"
180 className="text-red-400"
181 aria-hidden="true"
182 >
183 <circle cx="12" cy="12" r="10" />
184 <line x1="12" y1="8" x2="12" y2="12" />
185 <line x1="12" y1="16" x2="12.01" y2="16" />
186 </svg>
187 </div>
188 </div>
189
190 <div className="space-y-1">
191 <p className="text-white font-semibold text-lg">Checkout unavailable</p>
192 <p className="text-slate-400 text-sm">{errorMessage}</p>
193 </div>
194
195 <div className="flex flex-col gap-3">
196 <a
197 href="/billing"
198 className="block w-full py-2.5 rounded-full bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium transition-colors text-center"
199 >
200 View billing page
201 </a>
202 <a
203 href="/#pricing"
204 className="block w-full py-2.5 rounded-full bg-white/10 hover:bg-white/20 text-white text-sm font-medium transition-colors border border-white/10 text-center"
205 >
206 Back to pricing
207 </a>
208 </div>
209 </>
210 )}
211 </div>
212 </div>
213 );
214}
Modifiedapps/web/components/landing/Pricing.tsx+8−2View fileUnifiedSplit
@@ -1,5 +1,7 @@
11import Link from "next/link";
2import { Reveal } from "./Reveal";
2import type { Route } from "next";
3
4const fadeUp = { initial: { opacity: 0, y: 30 }, whileInView: { opacity: 1, y: 0 }, viewport: { once: true, margin: "-100px" }, transition: { duration: 0.6 } };
35
46const plans = [
57 {
@@ -10,6 +12,7 @@ const plans = [
1012 features: ["1 email account", "5 AI composes per day", "30-day search history", "Basic smart inbox", "Keyboard shortcuts"],
1113 cta: "Start Free",
1214 highlighted: false,
15 href: "/register",
1316 },
1417 {
1518 name: "Personal",
@@ -19,6 +22,7 @@ const plans = [
1922 features: ["3 email accounts", "Unlimited AI compose", "Unlimited search", "E2E encryption", "Snooze & schedule send", "Voice dictation", "Grammar agent", "Email recall"],
2023 cta: "Get Personal",
2124 highlighted: true,
25 href: "/checkout?plan=starter",
2226 },
2327 {
2428 name: "Pro",
@@ -28,6 +32,7 @@ const plans = [
2832 features: ["Unlimited accounts", "Priority AI (faster model)", "Email analytics", "API access", "Custom automations", "Advanced search operators", "Everything in Personal"],
2933 cta: "Go Pro",
3034 highlighted: false,
35 href: "/checkout?plan=professional",
3136 },
3237 {
3338 name: "Team",
@@ -37,6 +42,7 @@ const plans = [
3742 features: ["Shared inboxes", "Admin console", "Audit logs", "SSO / SAML", "Priority support", "Collaboration tools", "Everything in Pro"],
3843 cta: "Start Team Trial",
3944 highlighted: false,
45 href: "/checkout?plan=enterprise",
4046 },
4147];
4248
@@ -90,7 +96,7 @@ export function Pricing() {
9096 ))}
9197 </ul>
9298 <Link
93 href="/register"
99 href={plan.href as Route}
94100 className={`text-center py-2.5 rounded-full text-sm font-medium transition-all ${
95101 plan.highlighted
96102 ? "bg-white text-slate-950 hover:bg-blue-100"
Modifiedservices/ai-engine/package.json+25−0View fileUnifiedSplit
@@ -186,6 +186,31 @@
186186 "bun": "./src/scripts/snippet-runner.ts",
187187 "types": "./src/scripts/snippet-runner.ts",
188188 "import": "./dist/scripts/snippet-runner.js"
189 },
190 "./intelligence/priority-scorer": {
191 "bun": "./src/intelligence/priority-scorer.ts",
192 "types": "./src/intelligence/priority-scorer.ts",
193 "import": "./dist/intelligence/priority-scorer.js"
194 },
195 "./intelligence/smart-replies": {
196 "bun": "./src/intelligence/smart-replies.ts",
197 "types": "./src/intelligence/smart-replies.ts",
198 "import": "./dist/intelligence/smart-replies.js"
199 },
200 "./intelligence/sentiment-analyzer": {
201 "bun": "./src/intelligence/sentiment-analyzer.ts",
202 "types": "./src/intelligence/sentiment-analyzer.ts",
203 "import": "./dist/intelligence/sentiment-analyzer.js"
204 },
205 "./intelligence/categorizer": {
206 "bun": "./src/intelligence/categorizer.ts",
207 "types": "./src/intelligence/categorizer.ts",
208 "import": "./dist/intelligence/categorizer.js"
209 },
210 "./intelligence/context-extractor": {
211 "bun": "./src/intelligence/context-extractor.ts",
212 "types": "./src/intelligence/context-extractor.ts",
213 "import": "./dist/intelligence/context-extractor.js"
189214 }
190215 },
191216 "scripts": {
Addedservices/ai-engine/src/intelligence/categorizer.ts+168−0View fileUnifiedSplit
@@ -0,0 +1,168 @@
1/**
2 * Email Categorizer
3 *
4 * Uses Claude Haiku to classify an email into primary and secondary categories,
5 * with confidence score and auto-suggested labels.
6 */
7
8import Anthropic from "@anthropic-ai/sdk";
9
10// ─── Types ────────────────────────────────────────────────────────────────────
11
12export interface EmailCategoryResult {
13 primary: string; // e.g. "newsletter", "transactional", "personal", "work", "promotion"
14 secondary: string[];
15 confidence: number; // 0-1
16 isNewsletter: boolean;
17 isTransactional: boolean;
18 labels: string[]; // auto-suggested labels
19}
20
21// ─── Configuration ────────────────────────────────────────────────────────────
22
23const HAIKU = "claude-haiku-4-5";
24
25const VALID_PRIMARY_CATEGORIES = new Set([
26 "newsletter",
27 "transactional",
28 "personal",
29 "work",
30 "promotion",
31 "social",
32 "update",
33 "alert",
34]);
35
36// ─── Singleton Anthropic client ───────────────────────────────────────────────
37
38let cachedClient: Anthropic | null = null;
39
40function getClient(): Anthropic {
41 if (cachedClient) return cachedClient;
42 const apiKey = process.env["ANTHROPIC_API_KEY"];
43 if (!apiKey) {
44 throw new Error(
45 "ANTHROPIC_API_KEY is not set — email categorization is unavailable",
46 );
47 }
48 cachedClient = new Anthropic({ apiKey });
49 return cachedClient;
50}
51
52// ─── JSON parsing helpers ─────────────────────────────────────────────────────
53
54function isStringArray(value: unknown): value is string[] {
55 return Array.isArray(value) && value.every((v) => typeof v === "string");
56}
57
58function parseResponse(text: string): EmailCategoryResult {
59 const jsonStart = text.indexOf("{");
60 const jsonEnd = text.lastIndexOf("}");
61 if (jsonStart < 0 || jsonEnd < 0) {
62 throw new Error("Claude response did not contain a JSON object");
63 }
64 let parsed: unknown;
65 try {
66 parsed = JSON.parse(text.slice(jsonStart, jsonEnd + 1));
67 } catch {
68 throw new Error("Failed to parse Claude JSON response for categorization");
69 }
70 if (typeof parsed !== "object" || parsed === null) {
71 throw new Error("Parsed Claude response was not an object");
72 }
73 const obj = parsed as Record<string, unknown>;
74
75 const rawPrimary = typeof obj["primary"] === "string" ? obj["primary"].toLowerCase().trim() : "work";
76 const primary = VALID_PRIMARY_CATEGORIES.has(rawPrimary) ? rawPrimary : "work";
77
78 const rawSecondary = obj["secondary"];
79 const secondary = isStringArray(rawSecondary)
80 ? rawSecondary.map((s) => s.toLowerCase().trim()).filter((s) => s.length > 0).slice(0, 3)
81 : [];
82
83 const rawConfidence = typeof obj["confidence"] === "number" ? obj["confidence"] : 0.8;
84 const confidence = Math.max(0, Math.min(1, rawConfidence));
85
86 const isNewsletter =
87 typeof obj["isNewsletter"] === "boolean" ? obj["isNewsletter"] : primary === "newsletter";
88 const isTransactional =
89 typeof obj["isTransactional"] === "boolean"
90 ? obj["isTransactional"]
91 : primary === "transactional";
92
93 const rawLabels = obj["labels"];
94 const labels = isStringArray(rawLabels)
95 ? rawLabels.map((l) => l.trim()).filter((l) => l.length > 0).slice(0, 5)
96 : [];
97
98 return { primary, secondary, confidence, isNewsletter, isTransactional, labels };
99}
100
101// ─── Public API ───────────────────────────────────────────────────────────────
102
103/**
104 * Categorize an email using Claude Haiku.
105 *
106 * @param email Subject, sender, and body of the email.
107 * @returns EmailCategoryResult with primary category, secondary categories,
108 * confidence, flags, and auto-suggested labels.
109 */
110export async function categorizeEmail(email: {
111 subject: string;
112 from: string;
113 body: string;
114}): Promise<EmailCategoryResult> {
115 const prompt = [
116 `From: ${email.from}`,
117 `Subject: ${email.subject}`,
118 "",
119 "Email body (first 3000 chars):",
120 email.body.slice(0, 3000),
121 "",
122 "Classify this email. Return JSON only — no prose:",
123 "{",
124 ' "primary": "<one of: newsletter|transactional|personal|work|promotion|social|update|alert>",',
125 ' "secondary": ["<optional secondary category>"],',
126 ' "confidence": <0.0-1.0>,',
127 ' "isNewsletter": <true|false>,',
128 ' "isTransactional": <true|false>,',
129 ' "labels": ["<auto-suggested label 1>", "..."]',
130 "}",
131 "",
132 "Primary categories:",
133 "- newsletter: marketing/informational bulk email",
134 "- transactional: receipts, confirmations, notifications",
135 "- personal: from an individual, informal/personal tone",
136 "- work: professional business email",
137 "- promotion: sales, discount, offer",
138 "- social: social network notifications",
139 "- update: product/service/status updates",
140 "- alert: security, system, or important notifications",
141 ].join("\n");
142
143 const response = await getClient().messages.create({
144 model: HAIKU,
145 max_tokens: 512,
146 system:
147 "You are an email classifier. Always reply with a single valid JSON object and nothing else.",
148 messages: [{ role: "user", content: prompt }],
149 });
150
151 const text =
152 response.content[0]?.type === "text" ? response.content[0].text : "";
153 if (!text) throw new Error("Claude returned an empty response");
154
155 try {
156 return parseResponse(text);
157 } catch {
158 // Fallback: return a safe default on parse failure
159 return {
160 primary: "work",
161 secondary: [],
162 confidence: 0.5,
163 isNewsletter: false,
164 isTransactional: false,
165 labels: [],
166 };
167 }
168}
Addedservices/ai-engine/src/intelligence/context-extractor.ts+242−0View fileUnifiedSplit
@@ -0,0 +1,242 @@
1/**
2 * Context Extractor
3 *
4 * Uses Claude Sonnet to extract action items, deadlines, and promises
5 * from email thread content. Sonnet is used here because this is a complex
6 * extraction task requiring nuanced reasoning about commitments and obligations.
7 */
8
9import Anthropic from "@anthropic-ai/sdk";
10
11// ─── Types ────────────────────────────────────────────────────────────────────
12
13export interface ActionItem {
14 description: string;
15 assignedTo?: string;
16 dueDate?: string; // ISO date if mentioned
17 priority: "urgent" | "high" | "medium" | "low";
18}
19
20export interface Deadline {
21 description: string;
22 dueDate: string; // ISO date
23 isUrgent: boolean;
24}
25
26export interface Promise_ {
27 description: string;
28 direction: "made" | "received";
29 dueDate?: string;
30}
31
32export interface ExtractedContext {
33 actionItems: ActionItem[];
34 deadlines: Deadline[];
35 promises: Promise_[];
36 hasPendingItems: boolean;
37}
38
39// ─── Configuration ────────────────────────────────────────────────────────────
40
41const SONNET = "claude-sonnet-4-6";
42
43const VALID_PRIORITIES = ["urgent", "high", "medium", "low"] as const;
44
45// ─── Singleton Anthropic client ───────────────────────────────────────────────
46
47let cachedClient: Anthropic | null = null;
48
49function getClient(): Anthropic {
50 if (cachedClient) return cachedClient;
51 const apiKey = process.env["ANTHROPIC_API_KEY"];
52 if (!apiKey) {
53 throw new Error(
54 "ANTHROPIC_API_KEY is not set — context extraction is unavailable",
55 );
56 }
57 cachedClient = new Anthropic({ apiKey });
58 return cachedClient;
59}
60
61// ─── JSON parsing helpers ─────────────────────────────────────────────────────
62
63function parseActionItem(item: unknown): ActionItem | null {
64 if (typeof item !== "object" || item === null) return null;
65 const obj = item as Record<string, unknown>;
66
67 const description = typeof obj["description"] === "string" ? obj["description"].trim() : "";
68 if (!description) return null;
69
70 const assignedTo =
71 typeof obj["assignedTo"] === "string" && obj["assignedTo"].trim()
72 ? obj["assignedTo"].trim()
73 : undefined;
74
75 const dueDate =
76 typeof obj["dueDate"] === "string" && obj["dueDate"].trim()
77 ? obj["dueDate"].trim()
78 : undefined;
79
80 const rawPriority = obj["priority"];
81 const priority: ActionItem["priority"] = VALID_PRIORITIES.includes(
82 rawPriority as (typeof VALID_PRIORITIES)[number],
83 )
84 ? (rawPriority as ActionItem["priority"])
85 : "medium";
86
87 return { description, ...(assignedTo ? { assignedTo } : {}), ...(dueDate ? { dueDate } : {}), priority };
88}
89
90function parseDeadline(item: unknown): Deadline | null {
91 if (typeof item !== "object" || item === null) return null;
92 const obj = item as Record<string, unknown>;
93
94 const description = typeof obj["description"] === "string" ? obj["description"].trim() : "";
95 const dueDate = typeof obj["dueDate"] === "string" ? obj["dueDate"].trim() : "";
96 if (!description || !dueDate) return null;
97
98 const isUrgent = typeof obj["isUrgent"] === "boolean" ? obj["isUrgent"] : false;
99
100 return { description, dueDate, isUrgent };
101}
102
103function parsePromise(item: unknown): Promise_ | null {
104 if (typeof item !== "object" || item === null) return null;
105 const obj = item as Record<string, unknown>;
106
107 const description = typeof obj["description"] === "string" ? obj["description"].trim() : "";
108 if (!description) return null;
109
110 const direction: Promise_["direction"] =
111 obj["direction"] === "received" ? "received" : "made";
112
113 const dueDate =
114 typeof obj["dueDate"] === "string" && obj["dueDate"].trim()
115 ? obj["dueDate"].trim()
116 : undefined;
117
118 return { description, direction, ...(dueDate ? { dueDate } : {}) };
119}
120
121function parseResponse(text: string): ExtractedContext {
122 const jsonStart = text.indexOf("{");
123 const jsonEnd = text.lastIndexOf("}");
124 if (jsonStart < 0 || jsonEnd < 0) {
125 throw new Error("Claude response did not contain a JSON object");
126 }
127 let parsed: unknown;
128 try {
129 parsed = JSON.parse(text.slice(jsonStart, jsonEnd + 1));
130 } catch {
131 throw new Error("Failed to parse Claude JSON response for context extraction");
132 }
133 if (typeof parsed !== "object" || parsed === null) {
134 throw new Error("Parsed Claude response was not an object");
135 }
136 const obj = parsed as Record<string, unknown>;
137
138 const rawActionItems = Array.isArray(obj["actionItems"]) ? obj["actionItems"] : [];
139 const actionItems = rawActionItems
140 .map(parseActionItem)
141 .filter((item): item is ActionItem => item !== null);
142
143 const rawDeadlines = Array.isArray(obj["deadlines"]) ? obj["deadlines"] : [];
144 const deadlines = rawDeadlines
145 .map(parseDeadline)
146 .filter((item): item is Deadline => item !== null);
147
148 const rawPromises = Array.isArray(obj["promises"]) ? obj["promises"] : [];
149 const promises = rawPromises
150 .map(parsePromise)
151 .filter((item): item is Promise_ => item !== null);
152
153 const hasPendingItems =
154 typeof obj["hasPendingItems"] === "boolean"
155 ? obj["hasPendingItems"]
156 : actionItems.length > 0 || deadlines.length > 0 || promises.length > 0;
157
158 return { actionItems, deadlines, promises, hasPendingItems };
159}
160
161// ─── Public API ───────────────────────────────────────────────────────────────
162
163/**
164 * Extract action items, deadlines, and promises from email content using Claude Sonnet.
165 *
166 * @param params Email content and optional participant list.
167 * @returns ExtractedContext with arrays of action items, deadlines, and promises.
168 */
169export async function extractEmailContext(params: {
170 content: string;
171 participants?: string[];
172}): Promise<ExtractedContext> {
173 const participantsLine =
174 params.participants && params.participants.length > 0
175 ? `Participants: ${params.participants.join(", ")}\n\n`
176 : "";
177
178 const todayDate = new Date().toISOString().split("T")[0];
179
180 const prompt = [
181 participantsLine + "Email thread content:",
182 params.content.slice(0, 8000),
183 "",
184 `Today's date: ${todayDate ?? "unknown"}`,
185 "",
186 "Extract all action items, deadlines, and promises from this email thread.",
187 "Be specific — quote or closely paraphrase the relevant text in descriptions.",
188 "For dates, use ISO format (YYYY-MM-DD). If a date is relative (e.g. 'next Friday'), calculate it from today.",
189 "Return JSON only — no prose:",
190 "{",
191 ' "actionItems": [',
192 ' {',
193 ' "description": "<specific action required>",',
194 ' "assignedTo": "<person name or email, if identifiable>",',
195 ' "dueDate": "<ISO date or null>",',
196 ' "priority": "urgent"|"high"|"medium"|"low"',
197 ' }',
198 ' ],',
199 ' "deadlines": [',
200 ' {',
201 ' "description": "<what is due>",',
202 ' "dueDate": "<ISO date — required>",',
203 ' "isUrgent": <true|false>',
204 ' }',
205 ' ],',
206 ' "promises": [',
207 ' {',
208 ' "description": "<promise made or received>",',
209 ' "direction": "made"|"received",',
210 ' "dueDate": "<ISO date or null>"',
211 ' }',
212 ' ],',
213 ' "hasPendingItems": <true|false>',
214 "}",
215 ].join("\n");
216
217 const response = await getClient().messages.create({
218 model: SONNET,
219 max_tokens: 2048,
220 system:
221 "You are an expert at extracting commitments, tasks, and deadlines from email conversations. " +
222 "Always reply with a single valid JSON object and nothing else. " +
223 "Only include items that are clearly stated or strongly implied in the text.",
224 messages: [{ role: "user", content: prompt }],
225 });
226
227 const text =
228 response.content[0]?.type === "text" ? response.content[0].text : "";
229 if (!text) throw new Error("Claude returned an empty response");
230
231 try {
232 return parseResponse(text);
233 } catch {
234 // Fallback: return empty context on parse failure
235 return {
236 actionItems: [],
237 deadlines: [],
238 promises: [],
239 hasPendingItems: false,
240 };
241 }
242}
Addedservices/ai-engine/src/intelligence/priority-scorer.ts+152−0View fileUnifiedSplit
@@ -0,0 +1,152 @@
1/**
2 * Priority Scorer — scores an email's urgency and required action
3 *
4 * Uses Claude Haiku for fast, cost-efficient classification.
5 */
6
7import Anthropic from "@anthropic-ai/sdk";
8
9// ─── Types ────────────────────────────────────────────────────────────────────
10
11export interface PriorityScore {
12 score: number; // 0-100
13 urgencyLevel: "critical" | "high" | "medium" | "low";
14 reasons: string[];
15 suggestedAction:
16 | "reply_now"
17 | "reply_today"
18 | "reply_when_free"
19 | "no_reply_needed";
20}
21
22// ─── Configuration ───────────────────────────────────────────────────────────
23
24const HAIKU = "claude-haiku-4-5";
25
26// ─── Singleton Anthropic client ──────────────────────────────────────────────
27
28let cachedClient: Anthropic | null = null;
29
30function getClient(): Anthropic {
31 if (cachedClient) return cachedClient;
32 const apiKey = process.env["ANTHROPIC_API_KEY"];
33 if (!apiKey) {
34 throw new Error(
35 "ANTHROPIC_API_KEY is not set — priority scoring is unavailable",
36 );
37 }
38 cachedClient = new Anthropic({ apiKey });
39 return cachedClient;
40}
41
42// ─── JSON parsing helpers ─────────────────────────────────────────────────────
43
44function isStringArray(value: unknown): value is string[] {
45 return Array.isArray(value) && value.every((v) => typeof v === "string");
46}
47
48const URGENCY_LEVELS = ["critical", "high", "medium", "low"] as const;
49const SUGGESTED_ACTIONS = [
50 "reply_now",
51 "reply_today",
52 "reply_when_free",
53 "no_reply_needed",
54] as const;
55
56function parseResponse(text: string): PriorityScore {
57 const jsonStart = text.indexOf("{");
58 const jsonEnd = text.lastIndexOf("}");
59 if (jsonStart < 0 || jsonEnd < 0) {
60 throw new Error("Claude response did not contain a JSON object");
61 }
62 const parsed: unknown = JSON.parse(text.slice(jsonStart, jsonEnd + 1));
63 if (typeof parsed !== "object" || parsed === null) {
64 throw new Error("Parsed Claude response was not an object");
65 }
66 const obj = parsed as Record<string, unknown>;
67
68 const rawScore = typeof obj["score"] === "number" ? obj["score"] : 50;
69 const score = Math.max(0, Math.min(100, Math.round(rawScore)));
70
71 const rawUrgency = obj["urgencyLevel"];
72 const urgencyLevel: PriorityScore["urgencyLevel"] = URGENCY_LEVELS.includes(
73 rawUrgency as (typeof URGENCY_LEVELS)[number],
74 )
75 ? (rawUrgency as PriorityScore["urgencyLevel"])
76 : score >= 90
77 ? "critical"
78 : score >= 70
79 ? "high"
80 : score >= 40
81 ? "medium"
82 : "low";
83
84 const reasons = isStringArray(obj["reasons"]) ? obj["reasons"] : [];
85
86 const rawAction = obj["suggestedAction"];
87 const suggestedAction: PriorityScore["suggestedAction"] =
88 SUGGESTED_ACTIONS.includes(
89 rawAction as (typeof SUGGESTED_ACTIONS)[number],
90 )
91 ? (rawAction as PriorityScore["suggestedAction"])
92 : "reply_when_free";
93
94 return { score, urgencyLevel, reasons, suggestedAction };
95}
96
97// ─── Public API ───────────────────────────────────────────────────────────────
98
99/**
100 * Score the priority of an email using Claude Haiku.
101 *
102 * @param email Email fields required for analysis.
103 * @returns A PriorityScore with 0-100 score, urgency level, reasons, and action.
104 */
105export async function scoreEmailPriority(email: {
106 subject: string;
107 from: string;
108 body: string;
109}): Promise<PriorityScore> {
110 const prompt = [
111 `Subject: ${email.subject}`,
112 `From: ${email.from}`,
113 "",
114 "Email body:",
115 email.body.slice(0, 4000),
116 "",
117 "Score this email's priority 0-100 based on urgency, sender importance, and required action.",
118 "Return JSON only — no prose:",
119 "{",
120 ' "score": <number 0-100>,',
121 ' "urgencyLevel": "critical"|"high"|"medium"|"low",',
122 ' "reasons": ["<reason 1>", "..."],',
123 ' "suggestedAction": "reply_now"|"reply_today"|"reply_when_free"|"no_reply_needed"',
124 "}",
125 "",
126 "Rules: score>=90=critical, >=70=high, >=40=medium, else low.",
127 ].join("\n");
128
129 const response = await getClient().messages.create({
130 model: HAIKU,
131 max_tokens: 512,
132 system:
133 "You are an email priority classifier. Always reply with a single valid JSON object and nothing else.",
134 messages: [{ role: "user", content: prompt }],
135 });
136
137 const text =
138 response.content[0]?.type === "text" ? response.content[0].text : "";
139 if (!text) throw new Error("Claude returned an empty response");
140
141 try {
142 return parseResponse(text);
143 } catch {
144 // Fallback: return a default score on parse failure
145 return {
146 score: 50,
147 urgencyLevel: "medium",
148 reasons: ["Unable to parse AI response"],
149 suggestedAction: "reply_when_free",
150 };
151 }
152}
Addedservices/ai-engine/src/intelligence/sentiment-analyzer.ts+146−0View fileUnifiedSplit
@@ -0,0 +1,146 @@
1/**
2 * Sentiment Analyzer — analyzes the emotional tone of an email
3 *
4 * Uses Claude Haiku for fast, cost-efficient sentiment classification.
5 */
6
7import Anthropic from "@anthropic-ai/sdk";
8
9// ─── Types ────────────────────────────────────────────────────────────────────
10
11export interface EmailSentiment {
12 sentiment:
13 | "very_positive"
14 | "positive"
15 | "neutral"
16 | "negative"
17 | "very_negative";
18 score: number; // -1.0 to 1.0
19 emotions: string[]; // max 3
20 requiresUrgentResponse: boolean;
21}
22
23// ─── Configuration ───────────────────────────────────────────────────────────
24
25const HAIKU = "claude-haiku-4-5";
26
27// ─── Singleton Anthropic client ──────────────────────────────────────────────
28
29let cachedClient: Anthropic | null = null;
30
31function getClient(): Anthropic {
32 if (cachedClient) return cachedClient;
33 const apiKey = process.env["ANTHROPIC_API_KEY"];
34 if (!apiKey) {
35 throw new Error(
36 "ANTHROPIC_API_KEY is not set — sentiment analysis is unavailable",
37 );
38 }
39 cachedClient = new Anthropic({ apiKey });
40 return cachedClient;
41}
42
43// ─── JSON parsing helpers ─────────────────────────────────────────────────────
44
45function isStringArray(value: unknown): value is string[] {
46 return Array.isArray(value) && value.every((v) => typeof v === "string");
47}
48
49const SENTIMENTS = [
50 "very_positive",
51 "positive",
52 "neutral",
53 "negative",
54 "very_negative",
55] as const;
56
57function parseResponse(text: string): EmailSentiment {
58 const jsonStart = text.indexOf("{");
59 const jsonEnd = text.lastIndexOf("}");
60 if (jsonStart < 0 || jsonEnd < 0) {
61 throw new Error("Claude response did not contain a JSON object");
62 }
63 const parsed: unknown = JSON.parse(text.slice(jsonStart, jsonEnd + 1));
64 if (typeof parsed !== "object" || parsed === null) {
65 throw new Error("Parsed Claude response was not an object");
66 }
67 const obj = parsed as Record<string, unknown>;
68
69 const rawSentiment = obj["sentiment"];
70 const sentiment: EmailSentiment["sentiment"] = SENTIMENTS.includes(
71 rawSentiment as (typeof SENTIMENTS)[number],
72 )
73 ? (rawSentiment as EmailSentiment["sentiment"])
74 : "neutral";
75
76 const rawScore = typeof obj["score"] === "number" ? obj["score"] : 0;
77 const score = Math.max(-1.0, Math.min(1.0, rawScore));
78
79 const rawEmotions = obj["emotions"];
80 const emotions = isStringArray(rawEmotions)
81 ? rawEmotions.slice(0, 3)
82 : [];
83
84 const requiresUrgentResponse =
85 typeof obj["requiresUrgentResponse"] === "boolean"
86 ? obj["requiresUrgentResponse"]
87 : false;
88
89 return { sentiment, score, emotions, requiresUrgentResponse };
90}
91
92// ─── Public API ───────────────────────────────────────────────────────────────
93
94/**
95 * Analyze the sentiment of an email using Claude Haiku.
96 *
97 * @param email Subject and body of the email to analyze.
98 * @returns EmailSentiment with label, score, emotions, and urgency flag.
99 */
100export async function analyzeEmailSentiment(email: {
101 subject: string;
102 body: string;
103}): Promise<EmailSentiment> {
104 const prompt = [
105 `Subject: ${email.subject}`,
106 "",
107 "Email body:",
108 email.body.slice(0, 4000),
109 "",
110 "Analyze the sentiment and emotional tone of this email.",
111 "Return JSON only — no prose:",
112 "{",
113 ' "sentiment": "very_positive"|"positive"|"neutral"|"negative"|"very_negative",',
114 ' "score": <number from -1.0 (very negative) to 1.0 (very positive)>,',
115 ' "emotions": ["<emotion 1>", "<emotion 2>", "<emotion 3>"],',
116 ' "requiresUrgentResponse": <true|false>',
117 "}",
118 "",
119 "Notes:",
120 "- emotions: list up to 3 specific emotions detected (e.g., 'frustrated', 'grateful', 'anxious')",
121 "- requiresUrgentResponse: true if the email contains anger, urgency, or escalation signals",
122 ].join("\n");
123
124 const response = await getClient().messages.create({
125 model: HAIKU,
126 max_tokens: 512,
127 system:
128 "You are an email sentiment classifier. Always reply with a single valid JSON object and nothing else.",
129 messages: [{ role: "user", content: prompt }],
130 });
131
132 const text =
133 response.content[0]?.type === "text" ? response.content[0].text : "";
134 if (!text) throw new Error("Claude returned an empty response");
135
136 try {
137 return parseResponse(text);
138 } catch {
139 return {
140 sentiment: "neutral",
141 score: 0,
142 emotions: [],
143 requiresUrgentResponse: false,
144 };
145 }
146}
Addedservices/ai-engine/src/intelligence/smart-replies.ts+133−0View fileUnifiedSplit
@@ -0,0 +1,133 @@
1/**
2 * Smart Replies — generates 3 contextual reply options for an email
3 *
4 * Uses Claude Haiku for fast, cost-efficient generation.
5 * Returns one professional reply, one friendly reply, and one brief reply.
6 */
7
8import Anthropic from "@anthropic-ai/sdk";
9
10// ─── Types ────────────────────────────────────────────────────────────────────
11
12export interface SmartReply {
13 text: string;
14 tone: "professional" | "friendly" | "brief";
15}
16
17// ─── Configuration ───────────────────────────────────────────────────────────
18
19const HAIKU = "claude-haiku-4-5";
20
21// ─── Singleton Anthropic client ──────────────────────────────────────────────
22
23let cachedClient: Anthropic | null = null;
24
25function getClient(): Anthropic {
26 if (cachedClient) return cachedClient;
27 const apiKey = process.env["ANTHROPIC_API_KEY"];
28 if (!apiKey) {
29 throw new Error(
30 "ANTHROPIC_API_KEY is not set — smart reply generation is unavailable",
31 );
32 }
33 cachedClient = new Anthropic({ apiKey });
34 return cachedClient;
35}
36
37// ─── JSON parsing helpers ─────────────────────────────────────────────────────
38
39const TONES = ["professional", "friendly", "brief"] as const;
40
41function parseResponse(text: string): SmartReply[] {
42 const jsonStart = text.indexOf("[");
43 const jsonEnd = text.lastIndexOf("]");
44 if (jsonStart < 0 || jsonEnd < 0) {
45 throw new Error("Claude response did not contain a JSON array");
46 }
47 const parsed: unknown = JSON.parse(text.slice(jsonStart, jsonEnd + 1));
48 if (!Array.isArray(parsed)) {
49 throw new Error("Parsed Claude response was not an array");
50 }
51
52 const replies: SmartReply[] = [];
53 for (const item of parsed) {
54 if (typeof item !== "object" || item === null) continue;
55 const obj = item as Record<string, unknown>;
56 const replyText = typeof obj["text"] === "string" ? obj["text"].trim() : "";
57 const rawTone = obj["tone"];
58 const tone: SmartReply["tone"] = TONES.includes(
59 rawTone as (typeof TONES)[number],
60 )
61 ? (rawTone as SmartReply["tone"])
62 : "professional";
63 if (replyText) {
64 replies.push({ text: replyText, tone });
65 }
66 }
67
68 return replies;
69}
70
71// ─── Default fallback replies ─────────────────────────────────────────────────
72
73function defaultReplies(): SmartReply[] {
74 return [
75 {
76 text: "Thanks for reaching out. I'll review this and get back to you shortly.",
77 tone: "professional",
78 },
79 { text: "Got it, thanks! I'll take a look.", tone: "friendly" },
80 { text: "Received. Will follow up soon.", tone: "brief" },
81 ];
82}
83
84// ─── Public API ───────────────────────────────────────────────────────────────
85
86/**
87 * Generate exactly 3 smart reply options for an email using Claude Haiku.
88 * Returns one professional, one friendly, and one brief reply.
89 *
90 * @param email Email fields required for context.
91 * @returns An array of exactly 3 SmartReply objects.
92 */
93export async function generateSmartReplies(email: {
94 subject: string;
95 from: string;
96 body: string;
97}): Promise<SmartReply[]> {
98 const prompt = [
99 `Subject: ${email.subject}`,
100 `From: ${email.from}`,
101 "",
102 "Email body:",
103 email.body.slice(0, 4000),
104 "",
105 "Generate exactly 3 smart reply options for this email.",
106 "One professional, one friendly, one brief (under 15 words).",
107 "Return a JSON array only — no prose:",
108 "[",
109 ' {"text": "<professional reply>", "tone": "professional"},',
110 ' {"text": "<friendly reply>", "tone": "friendly"},',
111 ' {"text": "<brief reply under 15 words>", "tone": "brief"}',
112 "]",
113 ].join("\n");
114
115 const response = await getClient().messages.create({
116 model: HAIKU,
117 max_tokens: 1024,
118 system:
119 "You are an email reply assistant. Always reply with a single valid JSON array and nothing else.",
120 messages: [{ role: "user", content: prompt }],
121 });
122
123 const text =
124 response.content[0]?.type === "text" ? response.content[0].text : "";
125 if (!text) return defaultReplies();
126
127 try {
128 const replies = parseResponse(text);
129 return replies.length >= 1 ? replies.slice(0, 3) : defaultReplies();
130 } catch {
131 return defaultReplies();
132 }
133}
0134
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts