Restore from Gitea ZIP snapshot (12.08.2026) after full instance reinstall

Git history was lost when the previous Gitea instance was wiped and
reinstalled due to an unresolved corruption bug — this commit is the
last known-good file content, exported before the reinstall. Prior
commit history is not recoverable through this path.
This commit is contained in:
Claude Sonnet 5
2026-08-12 19:24:01 +00:00
commit f386f74036
64 changed files with 13447 additions and 0 deletions
+44
View File
@@ -0,0 +1,44 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# app data (SQLite db, WAL files)
/data/
# typescript
*.tsbuildinfo
next-env.d.ts
+5
View File
@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+36
View File
@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
out: "./src/lib/db/migrations",
schema: "./src/lib/db/schema.ts",
dialect: "sqlite",
dbCredentials: {
url: "./data/db.sqlite",
},
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+10
View File
@@ -0,0 +1,10 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Not using output: "standalone" — its Turbopack file-tracing only copied
// argon2's native .node binaries, not its JS loader, causing a segfault
// in production. Deploying against the full node_modules via `next start`
// works correctly and is simple enough for a single-VM deployment.
};
export default nextConfig;
+8955
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "project-claude",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"rotate-key": "tsx scripts/rotate-credential-key.ts"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.115.0",
"argon2": "^0.45.1",
"better-sqlite3": "^12.11.1",
"drizzle-orm": "^0.45.2",
"ical.js": "^2.2.1",
"imapflow": "^1.5.0",
"next": "16.2.12",
"node-cron": "^4.6.0",
"nodemailer": "^9.0.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"tsdav": "^2.3.1",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/node-cron": "^3.0.11",
"@types/nodemailer": "^8.0.1",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.12",
"tsx": "^4.23.1",
"typescript": "^5",
"vitest": "^3.2.7"
}
}
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+62
View File
@@ -0,0 +1,62 @@
/**
* Rotates the AES-256-GCM master key used to encrypt stored mailbox
* credentials. Decrypts every stored IMAP/SMTP/CalDAV password under the
* old key and re-encrypts under a newly generated one, in a single
* transaction, then prints the new key so it can be written into
* /etc/ai-chief-of-staff/secrets.env.
*
* Usage: OLD_KEY=<base64> npx tsx scripts/rotate-credential-key.ts
* (reads the current key from CREDENTIALS_ENCRYPTION_KEY if OLD_KEY is unset)
*/
import crypto from "node:crypto";
import Database from "better-sqlite3";
import path from "node:path";
import { rotateCredential } from "../src/lib/crypto/credentials";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
const oldKeyB64 = process.env.OLD_KEY ?? process.env.CREDENTIALS_ENCRYPTION_KEY;
if (!oldKeyB64) {
console.error("Set OLD_KEY (or CREDENTIALS_ENCRYPTION_KEY) to the current base64-encoded key before running.");
process.exit(1);
}
const oldKey = Buffer.from(oldKeyB64, "base64");
if (oldKey.length !== 32) {
console.error("OLD_KEY must decode to exactly 32 bytes.");
process.exit(1);
}
const newKey = crypto.randomBytes(32);
const sqlite = new Database(path.join(dataDir, "db.sqlite"));
const rows = sqlite
.prepare(
"SELECT id, imap_password_enc, smtp_password_enc, caldav_password_enc FROM mailbox_credentials",
)
.all() as Array<{
id: string;
imap_password_enc: string | null;
smtp_password_enc: string | null;
caldav_password_enc: string | null;
}>;
const update = sqlite.prepare(
"UPDATE mailbox_credentials SET imap_password_enc = ?, smtp_password_enc = ?, caldav_password_enc = ? WHERE id = ?",
);
const rotateAll = sqlite.transaction(() => {
for (const row of rows) {
const imap = row.imap_password_enc ? rotateCredential(row.imap_password_enc, oldKey, newKey) : null;
const smtp = row.smtp_password_enc ? rotateCredential(row.smtp_password_enc, oldKey, newKey) : null;
const caldav = row.caldav_password_enc ? rotateCredential(row.caldav_password_enc, oldKey, newKey) : null;
update.run(imap, smtp, caldav, row.id);
}
});
rotateAll();
console.log(`Rotated ${rows.length} mailbox_credentials row(s).`);
console.log("\nNew key (update CREDENTIALS_ENCRYPTION_KEY in /etc/ai-chief-of-staff/secrets.env, then restart the service):\n");
console.log(newKey.toString("base64"));
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (res.ok) {
router.push("/briefing");
router.refresh();
return;
}
const data = await res.json().catch(() => ({}));
setError(data.error ?? "Login failed");
setSubmitting(false);
}
return (
<div className="auth-shell">
<div className="auth-card">
<div className="auth-header">
<div className="brand-mark" />
<div className="auth-title">Welcome back</div>
<p className="muted">Log in to your Chief of Staff</p>
</div>
<form onSubmit={onSubmit}>
<div className="field">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
required
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
/>
</div>
<div className="field">
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
/>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<button type="submit" className="btn btn-primary btn-block" disabled={submitting}>
{submitting ? "Logging in…" : "Log in"}
</button>
</form>
<div className="auth-footer">
No account? <a href="/signup">Sign up</a>
</div>
</div>
</div>
);
}
+83
View File
@@ -0,0 +1,83 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export default function SignupPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
if (res.ok) {
router.push("/briefing");
router.refresh();
return;
}
const data = await res.json().catch(() => ({}));
setError(data.error ?? "Signup failed");
setSubmitting(false);
}
return (
<div className="auth-shell">
<div className="auth-card">
<div className="auth-header">
<div className="brand-mark" />
<div className="auth-title">Create your account</div>
<p className="muted">Start your daily AI-written briefing</p>
</div>
<form onSubmit={onSubmit}>
<div className="field">
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
required
autoFocus
value={email}
onChange={(e) => setEmail(e.target.value)}
className="input"
/>
</div>
<div className="field">
<label htmlFor="password">Password (min. 8 characters)</label>
<input
id="password"
type="password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
className="input"
/>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<button type="submit" className="btn btn-primary btn-block" disabled={submitting}>
{submitting ? "Creating account…" : "Sign up"}
</button>
</form>
<div className="auth-footer">
Already have an account? <a href="/login">Log in</a>
</div>
</div>
</div>
);
}
@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function GenerateBriefingButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onClick() {
setPending(true);
setError(null);
const res = await fetch("/api/briefing/generate", { method: "POST" });
const data = await res.json().catch(() => ({}));
setPending(false);
if (!res.ok) {
setError(data.error ?? "Failed to generate briefing");
return;
}
router.refresh();
}
return (
<div className="field" style={{ marginBottom: 24 }}>
<button onClick={onClick} className="btn btn-primary" disabled={pending}>
{pending ? "Generating…" : "Generate today's briefing"}
</button>
{error && <div className="alert alert-danger" style={{ marginTop: 10 }}>{error}</div>}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import type { BriefingResult } from "@/lib/ai/briefing";
import { GenerateBriefingButton } from "./generate-button";
const PRIORITY_BADGE: Record<string, string> = {
high: "badge-danger",
medium: "badge-warning",
low: "badge-neutral",
};
export default async function BriefingPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const briefing = await db.query.briefings.findFirst({
where: (b, { eq }) => eq(b.userId, session.user.id),
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
const items: BriefingResult["items"] = briefing ? JSON.parse(briefing.itemsJson) : [];
return (
<div className="page">
<div className="page-title">Today&apos;s briefing</div>
<p className="page-subtitle">A prioritized summary of what needs your attention.</p>
<GenerateBriefingButton />
{!briefing && (
<div className="empty-state card">
No briefing generated yet connect a mailbox, sync, then generate one.
</div>
)}
{briefing && (
<>
<div className="card" style={{ marginBottom: 20 }}>
<p style={{ fontSize: 15 }}>{briefing.overallSummary}</p>
<p className="faint" style={{ marginTop: 10 }}>
Generated {briefing.createdAt.toLocaleString()} · {briefing.briefingDate}
</p>
</div>
<div className="stack">
{items.map((item, i) => (
<div key={i} className="briefing-item">
<div className="briefing-item-head">
<span className="briefing-item-category">{item.category}</span>
<span className={`badge ${PRIORITY_BADGE[item.priority] ?? "badge-neutral"}`}>
{item.priority}
</span>
</div>
<p className="muted" style={{ color: "var(--text)" }}>{item.summary}</p>
</div>
))}
</div>
</>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export interface DraftCardData {
id: string;
generatedSubject: string;
generatedBody: string;
editedBody: string | null;
status: string;
sourceEmail: {
fromAddress: string | null;
subject: string | null;
snippet: string | null;
} | null;
}
export function DraftCard({ draft }: { draft: DraftCardData }) {
const router = useRouter();
const [subject, setSubject] = useState(draft.generatedSubject);
const [body, setBody] = useState(draft.editedBody ?? draft.generatedBody);
const [pending, setPending] = useState<"send" | "reject" | null>(null);
const [error, setError] = useState<string | null>(null);
async function onSend() {
setPending("send");
setError(null);
const res = await fetch(`/api/drafts/${draft.id}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject, body }),
});
const data = await res.json().catch(() => ({}));
setPending(null);
if (!res.ok) {
setError(data.error ?? "Failed to send");
return;
}
router.refresh();
}
async function onReject() {
setPending("reject");
setError(null);
const res = await fetch(`/api/drafts/${draft.id}/reject`, { method: "POST" });
setPending(null);
if (!res.ok) {
setError("Failed to reject");
return;
}
router.refresh();
}
return (
<div className="card">
{draft.sourceEmail && (
<div className="card-section" style={{ borderTop: "none", paddingTop: 0, marginTop: 0, marginBottom: 16 }}>
<p className="muted">
Replying to <strong style={{ color: "var(--text)" }}>{draft.sourceEmail.fromAddress}</strong>
{" — "}&ldquo;{draft.sourceEmail.subject}&rdquo;
</p>
{draft.sourceEmail.snippet && <p className="faint" style={{ marginTop: 4 }}>{draft.sourceEmail.snippet}</p>}
</div>
)}
<div className="field">
<label>Subject</label>
<input value={subject} onChange={(e) => setSubject(e.target.value)} className="input" />
</div>
<div className="field">
<label>Body</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={8}
className="textarea"
/>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<div style={{ display: "flex", gap: 8 }}>
<button onClick={onSend} className="btn btn-primary" disabled={pending !== null}>
{pending === "send" ? "Sending…" : "Send"}
</button>
<button onClick={onReject} className="btn" disabled={pending !== null}>
{pending === "reject" ? "Rejecting…" : "Reject"}
</button>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { redirect } from "next/navigation";
import { and, desc, eq, inArray } from "drizzle-orm";
import { getCurrentSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { emailDrafts, emails } from "@/lib/db/schema";
import { DraftCard } from "./draft-card";
export default async function DraftsPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const rows = await db
.select({ draft: emailDrafts, email: emails })
.from(emailDrafts)
.leftJoin(emails, eq(emailDrafts.sourceEmailId, emails.id))
.where(and(eq(emailDrafts.userId, session.user.id), inArray(emailDrafts.status, ["pending", "edited"])))
.orderBy(desc(emailDrafts.createdAt));
return (
<div className="page">
<div className="page-title">Draft replies</div>
<p className="page-subtitle">Review and edit before anything is sent nothing goes out automatically.</p>
{rows.length === 0 && (
<div className="empty-state card">
No pending drafts. Generate one from an email on the <a href="/sync" style={{ color: "var(--accent)", fontWeight: 500 }}>sync debug page</a>.
</div>
)}
<div className="stack">
{rows.map(({ draft, email }) => (
<DraftCard
key={draft.id}
draft={{
id: draft.id,
generatedSubject: draft.generatedSubject,
generatedBody: draft.generatedBody,
editedBody: draft.editedBody,
status: draft.status,
sourceEmail: email
? { fromAddress: email.fromAddress, subject: email.subject, snippet: email.snippet }
: null,
}}
/>
))}
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { LogoutButton } from "./logout-button";
import { NavLinks } from "./nav-links";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getCurrentSession();
if (!session) {
redirect("/login");
}
return (
<div style={{ display: "flex", flexDirection: "column", minHeight: "100%", flex: 1 }}>
<header className="topnav">
<div className="topnav-inner">
<div className="brand">
<div className="brand-mark" />
Chief of Staff
</div>
<NavLinks />
<div className="user-menu">
<span className="user-email">{session.user.email}</span>
<LogoutButton />
</div>
</div>
</header>
<main style={{ flex: 1 }}>{children}</main>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function onClick() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<button onClick={onClick} className="btn btn-subtle btn-sm">
Log out
</button>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { usePathname } from "next/navigation";
const LINKS = [
{ href: "/briefing", label: "Briefing" },
{ href: "/drafts", label: "Drafts" },
{ href: "/settings/mailbox", label: "Mailbox" },
{ href: "/sync", label: "Sync debug" },
];
export function NavLinks() {
const pathname = usePathname();
return (
<div className="nav-links">
{LINKS.map((link) => (
<a
key={link.href}
href={link.href}
className={`nav-link${pathname === link.href ? " active" : ""}`}
>
{link.label}
</a>
))}
</div>
);
}
@@ -0,0 +1,273 @@
"use client";
import { useEffect, useState } from "react";
type ProtocolResult = { ok: true } | { ok: false; reason: string; message: string };
interface ConnectResponse {
results?: {
imap?: ProtocolResult;
smtp?: ProtocolResult;
caldav?: ProtocolResult;
};
error?: string;
}
interface StatusResponse {
connected: boolean;
imap?: { host: string; user: string } | null;
smtp?: { host: string; user: string } | null;
caldav?: { url: string; user: string } | null;
}
function ResultBadge({ result }: { result?: ProtocolResult }) {
if (!result) return null;
if (result.ok) return <span className="badge badge-success">Connected</span>;
return (
<div className="alert alert-danger" style={{ marginTop: 8, marginBottom: 0 }}>
{result.reason}: {result.message}
</div>
);
}
export default function MailboxSettingsPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [submitting, setSubmitting] = useState(false);
const [results, setResults] = useState<ConnectResponse["results"]>();
const [error, setError] = useState<string | null>(null);
const [imapEnabled, setImapEnabled] = useState(true);
const [imap, setImap] = useState({ host: "", port: "993", secure: true, user: "", pass: "" });
const [smtpEnabled, setSmtpEnabled] = useState(true);
const [smtp, setSmtp] = useState({ host: "", port: "465", secure: true, user: "", pass: "" });
const [caldavEnabled, setCaldavEnabled] = useState(false);
const [caldav, setCaldav] = useState({ serverUrl: "", username: "", password: "" });
useEffect(() => {
fetch("/api/mailbox/connect")
.then((r) => r.json())
.then(setStatus)
.catch(() => {});
}, []);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
setResults(undefined);
const body: Record<string, unknown> = {};
if (imapEnabled) body.imap = { ...imap, port: Number(imap.port) };
if (smtpEnabled) body.smtp = { ...smtp, port: Number(smtp.port) };
if (caldavEnabled) body.caldav = caldav;
const res = await fetch("/api/mailbox/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data: ConnectResponse = await res.json();
setSubmitting(false);
if (!res.ok) {
setError(data.error ?? "Request failed");
return;
}
setResults(data.results);
}
return (
<div className="page">
<div className="page-title">Connect your mailbox</div>
<p className="page-subtitle">
Each protocol is tested before saving you can save whichever ones succeed and retry the
rest later.
</p>
{status?.connected && (
<div className="alert alert-info stack" style={{ gap: 4 }}>
<strong>Currently saved</strong>
{status.imap && <div>IMAP: {status.imap.user}@{status.imap.host}</div>}
{status.smtp && <div>SMTP: {status.smtp.user}@{status.smtp.host}</div>}
{status.caldav && <div>CalDAV: {status.caldav.user} @ {status.caldav.url}</div>}
</div>
)}
<form onSubmit={onSubmit}>
<div className="card">
<div className="card-section">
<div className="card-section-title">
<label className="checkbox-row">
<input
type="checkbox"
checked={imapEnabled}
onChange={(e) => setImapEnabled(e.target.checked)}
/>
IMAP
</label>
</div>
<div className="field">
<label>Host</label>
<input
placeholder="imap.example.com"
value={imap.host}
onChange={(e) => setImap({ ...imap, host: e.target.value })}
disabled={!imapEnabled}
className="input"
/>
</div>
<div className="field">
<label>Port</label>
<input
value={imap.port}
onChange={(e) => setImap({ ...imap, port: e.target.value })}
disabled={!imapEnabled}
className="input"
/>
</div>
<label className="checkbox-row" style={{ marginBottom: 14 }}>
<input
type="checkbox"
checked={imap.secure}
onChange={(e) => setImap({ ...imap, secure: e.target.checked })}
disabled={!imapEnabled}
/>
Use TLS
</label>
<div className="field">
<label>Username</label>
<input
value={imap.user}
onChange={(e) => setImap({ ...imap, user: e.target.value })}
disabled={!imapEnabled}
className="input"
/>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Password</label>
<input
type="password"
value={imap.pass}
onChange={(e) => setImap({ ...imap, pass: e.target.value })}
disabled={!imapEnabled}
className="input"
/>
</div>
<ResultBadge result={results?.imap} />
</div>
<div className="card-section">
<div className="card-section-title">
<label className="checkbox-row">
<input
type="checkbox"
checked={smtpEnabled}
onChange={(e) => setSmtpEnabled(e.target.checked)}
/>
SMTP
</label>
</div>
<div className="field">
<label>Host</label>
<input
placeholder="smtp.example.com"
value={smtp.host}
onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
disabled={!smtpEnabled}
className="input"
/>
</div>
<div className="field">
<label>Port</label>
<input
value={smtp.port}
onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
disabled={!smtpEnabled}
className="input"
/>
</div>
<label className="checkbox-row" style={{ marginBottom: 14 }}>
<input
type="checkbox"
checked={smtp.secure}
onChange={(e) => setSmtp({ ...smtp, secure: e.target.checked })}
disabled={!smtpEnabled}
/>
Use TLS
</label>
<div className="field">
<label>Username</label>
<input
value={smtp.user}
onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
disabled={!smtpEnabled}
className="input"
/>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Password</label>
<input
type="password"
value={smtp.pass}
onChange={(e) => setSmtp({ ...smtp, pass: e.target.value })}
disabled={!smtpEnabled}
className="input"
/>
</div>
<ResultBadge result={results?.smtp} />
</div>
<div className="card-section">
<div className="card-section-title">
<label className="checkbox-row">
<input
type="checkbox"
checked={caldavEnabled}
onChange={(e) => setCaldavEnabled(e.target.checked)}
/>
CalDAV <span className="faint">(optional)</span>
</label>
</div>
<div className="field">
<label>Server URL</label>
<input
value={caldav.serverUrl}
onChange={(e) => setCaldav({ ...caldav, serverUrl: e.target.value })}
disabled={!caldavEnabled}
className="input"
/>
</div>
<div className="field">
<label>Username</label>
<input
value={caldav.username}
onChange={(e) => setCaldav({ ...caldav, username: e.target.value })}
disabled={!caldavEnabled}
className="input"
/>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Password</label>
<input
type="password"
value={caldav.password}
onChange={(e) => setCaldav({ ...caldav, password: e.target.value })}
disabled={!caldavEnabled}
className="input"
/>
</div>
<ResultBadge result={results?.caldav} />
</div>
</div>
{error && <div className="alert alert-danger" style={{ marginTop: 16 }}>{error}</div>}
<button type="submit" className="btn btn-primary" disabled={submitting} style={{ marginTop: 20 }}>
{submitting ? "Testing connections…" : "Test & save"}
</button>
</form>
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function DraftReplyButton({ emailId }: { emailId: string }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onClick() {
setPending(true);
setError(null);
const res = await fetch("/api/drafts/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emailId }),
});
const data = await res.json().catch(() => ({}));
setPending(false);
if (!res.ok) {
setError(data.error ?? "Failed");
return;
}
router.push("/drafts");
}
return (
<div>
<button onClick={onClick} className="btn btn-sm" disabled={pending}>
{pending ? "Drafting…" : "Draft reply"}
</button>
{error && <div className="faint" style={{ color: "var(--danger)", marginTop: 4 }}>{error}</div>}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { desc, eq } from "drizzle-orm";
import { getCurrentSession } from "@/lib/auth/session";
import { redirect } from "next/navigation";
import { db } from "@/lib/db/client";
import { emails, calendarEvents, syncLog } from "@/lib/db/schema";
import { TriggerSyncButton } from "./trigger-button";
import { DraftReplyButton } from "./draft-reply-button";
const STATUS_BADGE: Record<string, string> = {
succeeded: "badge-success",
failed: "badge-danger",
running: "badge-accent",
};
export default async function SyncDebugPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const userId = session.user.id;
const [recentEmails, recentEvents, recentLogs] = await Promise.all([
db.select().from(emails).where(eq(emails.userId, userId)).orderBy(desc(emails.dateReceived)).limit(50),
db
.select()
.from(calendarEvents)
.where(eq(calendarEvents.userId, userId))
.orderBy(desc(calendarEvents.startTime))
.limit(50),
db.select().from(syncLog).where(eq(syncLog.userId, userId)).orderBy(desc(syncLog.startedAt)).limit(10),
]);
return (
<div className="page" style={{ maxWidth: 960 }}>
<div className="page-title">Sync debug</div>
<p className="page-subtitle">Raw view of what&apos;s been fetched, for testing.</p>
<TriggerSyncButton />
<div className="stack" style={{ marginTop: 8 }}>
<div>
<div className="card-section-title" style={{ marginTop: 24 }}>Recent sync runs</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Started</th>
<th>Status</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{recentLogs.length === 0 && (
<tr>
<td colSpan={3} className="muted">No sync runs yet.</td>
</tr>
)}
{recentLogs.map((log) => (
<tr key={log.id}>
<td className="muted">{log.startedAt.toLocaleString()}</td>
<td>
<span className={`badge ${STATUS_BADGE[log.status] ?? "badge-neutral"}`}>{log.status}</span>
</td>
<td className="muted">{log.error ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div>
<div className="card-section-title" style={{ marginTop: 16 }}>Emails ({recentEmails.length})</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>Subject</th>
<th>Unread</th>
<th></th>
</tr>
</thead>
<tbody>
{recentEmails.length === 0 && (
<tr>
<td colSpan={5} className="muted">No emails synced yet.</td>
</tr>
)}
{recentEmails.map((email) => (
<tr key={email.id}>
<td className="muted">{email.dateReceived?.toLocaleString() ?? ""}</td>
<td>{email.fromAddress}</td>
<td>{email.subject}</td>
<td>
{email.isUnread ? (
<span className="badge badge-accent">Unread</span>
) : (
<span className="faint">read</span>
)}
</td>
<td>
<DraftReplyButton emailId={email.id} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div>
<div className="card-section-title" style={{ marginTop: 16 }}>Calendar events ({recentEvents.length})</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Start</th>
<th>End</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{recentEvents.length === 0 && (
<tr>
<td colSpan={3} className="muted">No calendar events synced yet.</td>
</tr>
)}
{recentEvents.map((event) => (
<tr key={event.id}>
<td className="muted">{event.startTime?.toLocaleString() ?? ""}</td>
<td className="muted">{event.endTime?.toLocaleString() ?? ""}</td>
<td>{event.summary}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function TriggerSyncButton() {
const router = useRouter();
const [status, setStatus] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onClick() {
setPending(true);
setStatus(null);
const res = await fetch("/api/cron/trigger", { method: "POST" });
const data = await res.json();
setPending(false);
if (!res.ok) {
setStatus(`Error: ${data.error}`);
return;
}
const r = data.result;
setStatus(
r.skipped
? "A sync was already running — skipped."
: r.error
? `Failed: ${r.error}`
: `Synced ${r.newEmails} new email(s), ${r.calendarEvents} calendar event(s).`,
);
router.refresh();
}
return (
<div className="field" style={{ marginBottom: 8 }}>
<button onClick={onClick} className="btn btn-primary" disabled={pending}>
{pending ? "Syncing…" : "Trigger sync now"}
</button>
{status && <p className="muted" style={{ marginTop: 8 }}>{status}</p>}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { verifyPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
// Simple in-memory rate limit — good enough at MVP scale, resets on restart.
const attempts = new Map<string, { count: number; resetAt: number }>();
const MAX_ATTEMPTS = 10;
const WINDOW_MS = 15 * 60 * 1000;
function isRateLimited(key: string): boolean {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || entry.resetAt < now) {
attempts.set(key, { count: 1, resetAt: now + WINDOW_MS });
return false;
}
entry.count += 1;
return entry.count > MAX_ATTEMPTS;
}
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = loginSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = parsed.data.email.toLowerCase().trim();
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (isRateLimited(`${ip}:${email}`)) {
return NextResponse.json({ error: "Too many attempts — try again later" }, { status: 429 });
}
const user = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, email),
});
// Always run verifyPassword (even against a placeholder hash) so the
// response timing doesn't reveal whether the email exists.
const ok = await verifyPassword(
user?.passwordHash ?? "$argon2id$v=19$m=65536,t=3,p=4$00000000000000000000000000$0000000000000000000000000000000000000000000000000000000000000000",
parsed.data.password,
);
if (!user || !ok) {
return NextResponse.json({ error: "Invalid email or password" }, { status: 401 });
}
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true });
}
+13
View File
@@ -0,0 +1,13 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { getSessionToken, destroySessionToken, clearSessionCookie } from "@/lib/auth/session";
export async function POST() {
const token = await getSessionToken();
if (token) {
await destroySessionToken(token);
}
await clearSessionCookie();
return NextResponse.json({ ok: true });
}
+43
View File
@@ -0,0 +1,43 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "@/lib/db/client";
import { users } from "@/lib/db/schema";
import { hashPassword } from "@/lib/auth/password";
import { createSession, setSessionCookie } from "@/lib/auth/session";
const signupSchema = z.object({
email: z.string().email(),
password: z.string().min(8, "Password must be at least 8 characters"),
});
export async function POST(request: Request) {
const body = await request.json().catch(() => null);
const parsed = signupSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues[0]?.message ?? "Invalid input" },
{ status: 400 },
);
}
const email = parsed.data.email.toLowerCase().trim();
const existing = await db.query.users.findFirst({
where: (u, { eq }) => eq(u.email, email),
});
if (existing) {
return NextResponse.json({ error: "An account with this email already exists" }, { status: 409 });
}
const passwordHash = await hashPassword(parsed.data.password);
const [user] = await db
.insert(users)
.values({ email, passwordHash })
.returning({ id: users.id });
const token = await createSession(user.id);
await setSessionCookie(token);
return NextResponse.json({ ok: true });
}
+21
View File
@@ -0,0 +1,21 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { getCurrentSession } from "@/lib/auth/session";
import { runBriefingForUser } from "@/lib/jobs/briefingJob";
/** Manually (re)generates today's briefing for the current user — for testing without waiting on the daily cron. */
export async function POST() {
const session = await getCurrentSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const briefing = await runBriefingForUser(session.user.id, session.user.timezone, true);
return NextResponse.json({ briefing });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { eq } from "drizzle-orm";
import { getCurrentSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { mailboxCredentials } from "@/lib/db/schema";
import { runSyncForMailbox } from "@/lib/jobs/syncJob";
/** Manually triggers a sync for the current user's mailbox — for testing without waiting on the cron interval. */
export async function POST() {
const session = await getCurrentSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const cred = await db.query.mailboxCredentials.findFirst({
where: eq(mailboxCredentials.userId, session.user.id),
});
if (!cred) {
return NextResponse.json({ error: "No mailbox connected yet" }, { status: 400 });
}
const result = await runSyncForMailbox(cred);
return NextResponse.json({ result });
}
+30
View File
@@ -0,0 +1,30 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { and, eq } from "drizzle-orm";
import { getCurrentSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { emailDrafts } from "@/lib/db/schema";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getCurrentSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const draft = await db.query.emailDrafts.findFirst({
where: and(eq(emailDrafts.id, id), eq(emailDrafts.userId, session.user.id)),
});
if (!draft) {
return NextResponse.json({ error: "Draft not found" }, { status: 404 });
}
const [updated] = await db
.update(emailDrafts)
.set({ status: "rejected" })
.where(eq(emailDrafts.id, id))
.returning();
return NextResponse.json({ draft: updated });
}
+93
View File
@@ -0,0 +1,93 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { getCurrentSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { emailDrafts, emails, mailboxCredentials } from "@/lib/db/schema";
import { decryptCredential } from "@/lib/crypto/credentials";
import { sendReply } from "@/lib/mail/smtp";
const bodySchema = z.object({
subject: z.string().min(1).optional(),
body: z.string().min(1).optional(),
});
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await getCurrentSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { id } = await params;
const parsed = bodySchema.safeParse(await request.json().catch(() => ({})));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const draft = await db.query.emailDrafts.findFirst({
where: and(eq(emailDrafts.id, id), eq(emailDrafts.userId, session.user.id)),
});
if (!draft) {
return NextResponse.json({ error: "Draft not found" }, { status: 404 });
}
if (draft.status === "sent") {
return NextResponse.json({ error: "Draft was already sent" }, { status: 409 });
}
const sourceEmail = await db.query.emails.findFirst({ where: eq(emails.id, draft.sourceEmailId) });
if (!sourceEmail) {
return NextResponse.json({ error: "Source email no longer exists" }, { status: 404 });
}
const cred = await db.query.mailboxCredentials.findFirst({
where: eq(mailboxCredentials.userId, session.user.id),
});
if (!cred?.smtpVerifiedAt || !cred.smtpHost || !cred.smtpPort || !cred.smtpUser || !cred.smtpPasswordEnc) {
return NextResponse.json({ error: "No verified SMTP connection for this account" }, { status: 400 });
}
if (!sourceEmail.fromAddress) {
return NextResponse.json({ error: "Source email has no reply-to address" }, { status: 400 });
}
const finalSubject = parsed.data.subject ?? draft.generatedSubject;
const finalBody = parsed.data.body ?? draft.editedBody ?? draft.generatedBody;
const wasEdited = finalBody !== draft.generatedBody || finalSubject !== draft.generatedSubject;
try {
const password = decryptCredential(cred.smtpPasswordEnc);
const { messageId } = await sendReply(
{
host: cred.smtpHost,
port: cred.smtpPort,
secure: cred.smtpSecure ?? true,
auth: { user: cred.smtpUser, pass: password },
},
{
fromAddress: cred.smtpUser,
toAddress: sourceEmail.fromAddress,
subject: finalSubject,
bodyText: finalBody,
inReplyTo: sourceEmail.messageId ?? undefined,
references: sourceEmail.messageId ?? undefined,
},
);
const [updated] = await db
.update(emailDrafts)
.set({
status: "sent",
editedBody: wasEdited ? finalBody : draft.editedBody,
sentAt: new Date(),
sentMessageId: messageId,
})
.where(eq(emailDrafts.id, draft.id))
.returning();
return NextResponse.json({ draft: updated });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: `Failed to send: ${message}` }, { status: 502 });
}
}
+55
View File
@@ -0,0 +1,55 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { and, eq } from "drizzle-orm";
import { getCurrentSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { emails, emailDrafts } from "@/lib/db/schema";
import { generateDraftReply } from "@/lib/ai/draftReply";
import { BRIEFING_MODEL } from "@/lib/ai/client";
const bodySchema = z.object({ emailId: z.string().min(1) });
export async function POST(request: Request) {
const session = await getCurrentSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const parsed = bodySchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const email = await db.query.emails.findFirst({
where: and(eq(emails.id, parsed.data.emailId), eq(emails.userId, session.user.id)),
});
if (!email) {
return NextResponse.json({ error: "Email not found" }, { status: 404 });
}
try {
const draft = await generateDraftReply({
from: email.fromAddress,
subject: email.subject,
bodyText: email.bodyText ?? email.snippet ?? "",
});
const [row] = await db
.insert(emailDrafts)
.values({
userId: session.user.id,
sourceEmailId: email.id,
generatedSubject: draft.subject,
generatedBody: draft.body,
modelUsed: BRIEFING_MODEL,
})
.returning();
return NextResponse.json({ draft: row });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return NextResponse.json({ error: message }, { status: 500 });
}
}
+152
View File
@@ -0,0 +1,152 @@
export const runtime = "nodejs";
import { NextResponse } from "next/server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { getCurrentSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { mailboxCredentials } from "@/lib/db/schema";
import { encryptCredential } from "@/lib/crypto/credentials";
import { testImapConnection } from "@/lib/mail/imap";
import { testSmtpConnection } from "@/lib/mail/smtp";
import { testCalDavConnection } from "@/lib/calendar/caldav";
import type { ConnectionTestResult } from "@/lib/mail/connection-errors";
const connectSchema = z.object({
imap: z
.object({
host: z.string().min(1),
port: z.coerce.number().int().positive(),
secure: z.boolean().default(true),
user: z.string().min(1),
pass: z.string().min(1),
})
.optional(),
smtp: z
.object({
host: z.string().min(1),
port: z.coerce.number().int().positive(),
secure: z.boolean().default(true),
user: z.string().min(1),
pass: z.string().min(1),
})
.optional(),
caldav: z
.object({
serverUrl: z.string().url(),
username: z.string().min(1),
password: z.string().min(1),
})
.optional(),
});
export async function POST(request: Request) {
const session = await getCurrentSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json().catch(() => null);
const parsed = connectSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.issues[0]?.message ?? "Invalid input" }, { status: 400 });
}
const { imap, smtp, caldav } = parsed.data;
if (!imap && !smtp && !caldav) {
return NextResponse.json({ error: "Provide at least one of imap, smtp, caldav" }, { status: 400 });
}
const results: { imap?: ConnectionTestResult; smtp?: ConnectionTestResult; caldav?: ConnectionTestResult } = {};
const values: Partial<typeof mailboxCredentials.$inferInsert> = {};
const now = new Date();
if (imap) {
results.imap = await testImapConnection({
host: imap.host,
port: imap.port,
secure: imap.secure,
auth: { user: imap.user, pass: imap.pass },
});
if (results.imap.ok) {
values.imapHost = imap.host;
values.imapPort = imap.port;
values.imapSecure = imap.secure;
values.imapUser = imap.user;
values.imapPasswordEnc = encryptCredential(imap.pass);
values.imapVerifiedAt = now;
}
}
if (smtp) {
results.smtp = await testSmtpConnection({
host: smtp.host,
port: smtp.port,
secure: smtp.secure,
auth: { user: smtp.user, pass: smtp.pass },
});
if (results.smtp.ok) {
values.smtpHost = smtp.host;
values.smtpPort = smtp.port;
values.smtpSecure = smtp.secure;
values.smtpUser = smtp.user;
values.smtpPasswordEnc = encryptCredential(smtp.pass);
values.smtpVerifiedAt = now;
}
}
if (caldav) {
results.caldav = await testCalDavConnection({
serverUrl: caldav.serverUrl,
username: caldav.username,
password: caldav.password,
});
if (results.caldav.ok) {
values.caldavUrl = caldav.serverUrl;
values.caldavUser = caldav.username;
values.caldavPasswordEnc = encryptCredential(caldav.password);
values.caldavVerifiedAt = now;
}
}
// Only persist the protocols that actually verified — partial success is
// fine (e.g. IMAP/SMTP work but CalDAV needs another attempt later).
if (Object.keys(values).length > 0) {
const existing = await db.query.mailboxCredentials.findFirst({
where: (m, { eq }) => eq(m.userId, session.user.id),
});
if (existing) {
await db
.update(mailboxCredentials)
.set(values)
.where(eq(mailboxCredentials.id, existing.id));
} else {
await db.insert(mailboxCredentials).values({ userId: session.user.id, ...values });
}
}
return NextResponse.json({ results });
}
export async function GET() {
const session = await getCurrentSession();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const existing = await db.query.mailboxCredentials.findFirst({
where: (m, { eq }) => eq(m.userId, session.user.id),
});
if (!existing) {
return NextResponse.json({ connected: false });
}
return NextResponse.json({
connected: true,
imap: existing.imapVerifiedAt ? { host: existing.imapHost, user: existing.imapUser } : null,
smtp: existing.smtpVerifiedAt ? { host: existing.smtpHost, user: existing.smtpUser } : null,
caldav: existing.caldavVerifiedAt ? { url: existing.caldavUrl, user: existing.caldavUser } : null,
});
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+599
View File
@@ -0,0 +1,599 @@
:root {
--bg: #fafaf9;
--surface: #ffffff;
--surface-hover: #f5f5f4;
--border: #e7e5e4;
--border-strong: #d6d3d1;
--text: #1c1917;
--text-muted: #78716c;
--text-faint: #a8a29e;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-soft: #eef2ff;
--accent-soft-text: #4338ca;
--danger: #dc2626;
--danger-soft: #fef2f2;
--danger-soft-text: #b91c1c;
--warning: #d97706;
--warning-soft: #fffbeb;
--warning-soft-text: #b45309;
--success: #059669;
--success-soft: #ecfdf5;
--success-soft-text: #047857;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--shadow-sm: 0 1px 2px rgba(28, 25, 23, 0.04);
--shadow: 0 1px 3px rgba(28, 25, 23, 0.06), 0 1px 2px rgba(28, 25, 23, 0.04);
--shadow-md: 0 4px 12px rgba(28, 25, 23, 0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0c0a09;
--surface: #1c1917;
--surface-hover: #292524;
--border: #292524;
--border-strong: #44403c;
--text: #f5f5f4;
--text-muted: #a8a29e;
--text-faint: #6b6560;
--accent: #818cf8;
--accent-hover: #a5b4fc;
--accent-soft: #1e1b4b;
--accent-soft-text: #c7d2fe;
--danger: #f87171;
--danger-soft: #2a1414;
--danger-soft-text: #fca5a5;
--warning: #fbbf24;
--warning-soft: #2a1f0d;
--warning-soft-text: #fcd34d;
--success: #34d399;
--success-soft: #0d2420;
--success-soft-text: #6ee7b7;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.4);
}
html {
color-scheme: dark;
}
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
}
html {
height: 100%;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
min-height: 100%;
display: flex;
flex-direction: column;
color: var(--text);
background: var(--bg);
font-family: var(--font-geist-sans), -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
color: inherit;
}
/* ---------- Layout ---------- */
.page {
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 40px 24px 80px;
}
.stack {
display: flex;
flex-direction: column;
gap: 16px;
}
/* ---------- Typography ---------- */
.page-title {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.01em;
margin-bottom: 4px;
}
.page-subtitle {
color: var(--text-muted);
font-size: 14px;
margin-bottom: 24px;
}
.muted {
color: var(--text-muted);
font-size: 13px;
}
.faint {
color: var(--text-faint);
font-size: 12px;
}
/* ---------- Buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 38px;
padding: 0 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, opacity 0.12s ease;
}
.btn:hover:not(:disabled) {
background: var(--surface-hover);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--accent-hover);
border-color: var(--accent-hover);
}
.btn-subtle {
background: transparent;
border-color: transparent;
color: var(--text-muted);
}
.btn-subtle:hover:not(:disabled) {
background: var(--surface-hover);
color: var(--text);
}
.btn-sm {
height: 32px;
padding: 0 12px;
font-size: 13px;
}
.btn-block {
width: 100%;
}
/* ---------- Forms ---------- */
.field {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 14px;
}
.field label {
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
}
.input,
.textarea,
select.input {
width: 100%;
padding: 9px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-strong);
background: var(--surface);
color: var(--text);
font-size: 14px;
font-family: inherit;
transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.input:focus,
.textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input:disabled,
.textarea:disabled {
background: var(--surface-hover);
color: var(--text-faint);
}
.textarea {
resize: vertical;
line-height: 1.5;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
.checkbox-row input {
width: 16px;
height: 16px;
accent-color: var(--accent);
}
/* ---------- Cards ---------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
padding: 20px;
}
.card + .card {
margin-top: 12px;
}
.card-section {
border-top: 1px solid var(--border);
padding-top: 16px;
margin-top: 16px;
}
.card-section:first-child {
border-top: none;
padding-top: 0;
margin-top: 0;
}
.card-section-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 8px;
}
/* ---------- Badges ---------- */
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
text-transform: uppercase;
white-space: nowrap;
}
.badge-accent {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
.badge-danger {
background: var(--danger-soft);
color: var(--danger-soft-text);
}
.badge-warning {
background: var(--warning-soft);
color: var(--warning-soft-text);
}
.badge-success {
background: var(--success-soft);
color: var(--success-soft-text);
}
.badge-neutral {
background: var(--surface-hover);
color: var(--text-muted);
}
/* ---------- Alerts ---------- */
.alert {
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 13px;
margin-bottom: 14px;
}
.alert-danger {
background: var(--danger-soft);
color: var(--danger-soft-text);
}
.alert-info {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
/* ---------- Empty states ---------- */
.empty-state {
text-align: center;
padding: 48px 24px;
color: var(--text-muted);
font-size: 14px;
}
/* ---------- Top nav (dashboard) ---------- */
.topnav {
position: sticky;
top: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--bg) 85%, transparent);
backdrop-filter: blur(8px);
}
.topnav-inner {
width: 100%;
max-width: 960px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
}
.brand {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
font-weight: 600;
font-size: 15px;
letter-spacing: -0.01em;
}
.brand-mark {
width: 22px;
height: 22px;
border-radius: 6px;
background: linear-gradient(135deg, var(--accent), #a855f7);
flex-shrink: 0;
}
.nav-links {
display: flex;
align-items: center;
gap: 4px;
/* Flex items don't shrink below their content size by default, so without
min-width:0 this row would push .topnav-inner (and the page) wider than
the viewport on narrow screens instead of scrolling internally. */
min-width: 0;
flex: 1 1 auto;
overflow-x: auto;
scrollbar-width: none;
}
.nav-links::-webkit-scrollbar {
display: none;
}
.nav-link {
flex-shrink: 0;
padding: 7px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 500;
color: var(--text-muted);
white-space: nowrap;
transition: background 0.12s ease, color 0.12s ease;
}
.nav-link:hover {
background: var(--surface-hover);
color: var(--text);
}
.nav-link.active {
background: var(--accent-soft);
color: var(--accent-soft-text);
}
.user-menu {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.user-email {
font-size: 13px;
color: var(--text-muted);
}
@media (max-width: 640px) {
.topnav {
padding: 0 12px;
}
.topnav-inner {
gap: 8px;
}
.brand {
font-size: 0;
gap: 0;
}
.user-email {
display: none;
}
}
/* ---------- Auth pages ---------- */
.auth-shell {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.auth-card {
width: 100%;
max-width: 380px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
padding: 32px;
}
.auth-header {
text-align: center;
margin-bottom: 24px;
}
.auth-header .brand-mark {
width: 40px;
height: 40px;
border-radius: 10px;
margin: 0 auto 16px;
}
.auth-title {
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
margin-bottom: 4px;
}
.auth-footer {
text-align: center;
margin-top: 20px;
font-size: 13px;
color: var(--text-muted);
}
.auth-footer a {
color: var(--accent);
font-weight: 500;
}
.auth-footer a:hover {
text-decoration: underline;
}
/* ---------- Tables ---------- */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.data-table th {
text-align: left;
font-weight: 500;
color: var(--text-muted);
padding: 8px 12px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.data-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.data-table tr:last-child td {
border-bottom: none;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
}
/* ---------- Briefing items ---------- */
.briefing-item {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--surface);
}
.briefing-item-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.briefing-item-category {
font-size: 13px;
font-weight: 600;
}
+30
View File
@@ -0,0 +1,30 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "AI Chief of Staff",
description: "Daily AI-written briefings and approve-before-send email drafts.",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable}`}>
<body>{children}</body>
</html>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
export default async function RootPage() {
const session = await getCurrentSession();
redirect(session ? "/briefing" : "/login");
}
+21
View File
@@ -0,0 +1,21 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
// Some third-party mail clients (observed: imapflow, after its own
// socketTimeout fires against a server that accepted a command and never
// replied) throw synchronously from internal setImmediate/nextTick
// cleanup — outside any promise chain or 'error' listener, so neither a
// try/catch nor client.on('error', ...) can catch it. Since mailbox
// sync/connect code already treats a timed-out client as failed and
// moves on, these stray internal errors are safe to log and ignore
// rather than let them crash the whole server over one flaky mailbox.
process.on("uncaughtException", (err) => {
console.error("[process] uncaughtException (ignored, server continues)", err);
});
process.on("unhandledRejection", (reason) => {
console.error("[process] unhandledRejection (ignored, server continues)", reason);
});
const { startScheduler } = await import("@/lib/jobs/scheduler");
startScheduler();
}
}
+76
View File
@@ -0,0 +1,76 @@
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { getAnthropicClient, BRIEFING_MODEL } from "./client";
const briefingItemSchema = z.object({
category: z.string().describe("Short category label, e.g. 'Email', 'Meeting', 'Deadline'"),
priority: z.enum(["high", "medium", "low"]),
summary: z.string().describe("One or two sentence summary of this item and why it matters"),
relatedEmailId: z.string().nullable().describe("The internal email id this item relates to, or null"),
});
export const briefingSchema = z.object({
overallSummary: z.string().describe("A 2-3 sentence overview of the day"),
items: z.array(briefingItemSchema).describe("Prioritized list of items for today, most important first"),
});
export type BriefingResult = z.infer<typeof briefingSchema>;
export interface BriefingContextEmail {
id: string;
from: string | null;
subject: string | null;
snippet: string;
isUnread: boolean;
}
export interface BriefingContextEvent {
summary: string | null;
startTime: Date | null;
endTime: Date | null;
}
const SYSTEM_PROMPT = `You are an executive assistant preparing a concise daily briefing for a busy professional.
Given today's unread emails and upcoming calendar events, produce a prioritized summary.
Focus on what actually needs attention or action — skip routine/automated mail unless it's time-sensitive.
Keep each item's summary short and concrete. Use the email's internal id (given in brackets, e.g. [id:abc123]) as relatedEmailId when an item is about a specific email; otherwise use null.`;
export async function generateBriefing(
emails: BriefingContextEmail[],
events: BriefingContextEvent[],
): Promise<BriefingResult> {
const client = getAnthropicClient();
const emailsText = emails.length
? emails
.map(
(e) =>
`- [id:${e.id}] ${e.isUnread ? "UNREAD" : "read"} from ${e.from ?? "unknown"}: "${e.subject ?? "(no subject)"}" — ${e.snippet}`,
)
.join("\n")
: "No new emails.";
const eventsText = events.length
? events
.map((e) => `- ${e.startTime?.toISOString() ?? "?"} to ${e.endTime?.toISOString() ?? "?"}: ${e.summary ?? "(untitled)"}`)
.join("\n")
: "No upcoming events.";
const message = await client.messages.parse({
model: BRIEFING_MODEL,
max_tokens: 2048,
system: SYSTEM_PROMPT,
output_config: { effort: "medium", format: zodOutputFormat(briefingSchema) },
messages: [
{
role: "user",
content: `Today's unread/new emails:\n${emailsText}\n\nUpcoming calendar events:\n${eventsText}`,
},
],
});
if (!message.parsed_output) {
throw new Error("Claude did not return a parsed briefing");
}
return message.parsed_output;
}
+18
View File
@@ -0,0 +1,18 @@
import Anthropic from "@anthropic-ai/sdk";
let client: Anthropic | null = null;
/** Lazily constructs the Anthropic client so a missing API key only breaks AI-dependent routes, not the whole app. */
export function getAnthropicClient(): Anthropic {
if (!process.env.ANTHROPIC_API_KEY) {
throw new Error(
"ANTHROPIC_API_KEY is not set — add it to .env.local to enable briefing/draft generation",
);
}
if (!client) {
client = new Anthropic();
}
return client;
}
export const BRIEFING_MODEL = "claude-sonnet-5";
+43
View File
@@ -0,0 +1,43 @@
import { z } from "zod";
import { zodOutputFormat } from "@anthropic-ai/sdk/helpers/zod";
import { getAnthropicClient, BRIEFING_MODEL } from "./client";
export const draftReplySchema = z.object({
subject: z.string().describe("The reply subject line, typically 'Re: <original subject>'"),
body: z.string().describe("The reply body text, written in a professional, concise tone"),
});
export type DraftReplyResult = z.infer<typeof draftReplySchema>;
const SYSTEM_PROMPT = `You are drafting an email reply on behalf of a busy professional.
Write a concise, professional reply to the email below. Do not include a greeting placeholder like "[Name]" —
write it as ready-to-send text, but the user will review and edit before anything is sent.
Never invent facts, commitments, or dates not present in the original email or given context.`;
export interface DraftReplyContextEmail {
from: string | null;
subject: string | null;
bodyText: string;
}
export async function generateDraftReply(email: DraftReplyContextEmail): Promise<DraftReplyResult> {
const client = getAnthropicClient();
const message = await client.messages.parse({
model: BRIEFING_MODEL,
max_tokens: 1024,
system: SYSTEM_PROMPT,
output_config: { effort: "medium", format: zodOutputFormat(draftReplySchema) },
messages: [
{
role: "user",
content: `Original email from ${email.from ?? "unknown sender"}, subject "${email.subject ?? "(no subject)"}":\n\n${email.bodyText}`,
},
],
});
if (!message.parsed_output) {
throw new Error("Claude did not return a parsed draft reply");
}
return message.parsed_output;
}
+13
View File
@@ -0,0 +1,13 @@
import argon2 from "argon2";
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, { type: argon2.argon2id });
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
return await argon2.verify(hash, password);
} catch {
return false;
}
}
+75
View File
@@ -0,0 +1,75 @@
import crypto from "node:crypto";
import { cookies } from "next/headers";
import { eq } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { sessions, users } from "@/lib/db/schema";
const SESSION_COOKIE = "session";
const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
function hashToken(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
export async function createSession(userId: string): Promise<string> {
const token = crypto.randomBytes(32).toString("base64url");
const tokenHash = hashToken(token);
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await db.insert(sessions).values({ tokenHash, userId, expiresAt });
return token;
}
export async function validateSessionToken(token: string) {
const tokenHash = hashToken(token);
const rows = await db
.select({ session: sessions, user: users })
.from(sessions)
.innerJoin(users, eq(sessions.userId, users.id))
.where(eq(sessions.tokenHash, tokenHash))
.limit(1);
const row = rows[0];
if (!row) return null;
if (row.session.expiresAt.getTime() < Date.now()) {
await db.delete(sessions).where(eq(sessions.tokenHash, tokenHash));
return null;
}
return row;
}
export async function destroySessionToken(token: string): Promise<void> {
await db.delete(sessions).where(eq(sessions.tokenHash, hashToken(token)));
}
export async function setSessionCookie(token: string): Promise<void> {
const cookieStore = await cookies();
cookieStore.set(SESSION_COOKIE, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: SESSION_TTL_MS / 1000,
});
}
export async function clearSessionCookie(): Promise<void> {
const cookieStore = await cookies();
cookieStore.delete(SESSION_COOKIE);
}
export async function getSessionToken(): Promise<string | undefined> {
const cookieStore = await cookies();
return cookieStore.get(SESSION_COOKIE)?.value;
}
/** Reads the session cookie and validates it against the DB. Returns null if absent/invalid/expired. */
export async function getCurrentSession() {
const token = await getSessionToken();
if (!token) return null;
return validateSessionToken(token);
}
export function getSessionCookieName(): string {
return SESSION_COOKIE;
}
+90
View File
@@ -0,0 +1,90 @@
import { createDAVClient } from "tsdav";
import ICAL from "ical.js";
import { classifyConnectionError, type ConnectionTestResult } from "../mail/connection-errors";
export interface CalDavConfig {
serverUrl: string;
username: string;
password: string;
}
async function buildClient(config: CalDavConfig, timeoutMs: number) {
return createDAVClient({
serverUrl: config.serverUrl,
credentials: { username: config.username, password: config.password },
authMethod: "Basic",
defaultAccountType: "caldav",
fetchOptions: { signal: AbortSignal.timeout(timeoutMs) },
});
}
// Bounded so a slow/unresponsive CalDAV server can't hang the connect-test
// request indefinitely — tsdav's underlying fetch() has no timeout by default.
const TEST_TIMEOUT_MS = 10_000;
const SYNC_TIMEOUT_MS = 30_000;
export async function testCalDavConnection(config: CalDavConfig): Promise<ConnectionTestResult> {
try {
const client = await buildClient(config, TEST_TIMEOUT_MS);
await client.fetchCalendars();
return { ok: true };
} catch (err) {
return { ok: false, ...classifyConnectionError(err) };
}
}
export interface FetchedCalendarEvent {
uid: string;
summary: string;
startTime: Date | null;
endTime: Date | null;
allDay: boolean;
rawIcs: string;
}
/** Fetches events across all calendars within [start, end]. */
export async function fetchUpcomingEvents(
config: CalDavConfig,
start: Date,
end: Date,
): Promise<FetchedCalendarEvent[]> {
const client = await buildClient(config, SYNC_TIMEOUT_MS);
const calendars = await client.fetchCalendars();
const events: FetchedCalendarEvent[] = [];
for (const calendar of calendars) {
const objects = await client.fetchCalendarObjects({
calendar,
timeRange: { start: start.toISOString(), end: end.toISOString() },
});
for (const obj of objects) {
if (!obj.data) continue;
events.push(...parseIcsEvents(obj.data));
}
}
return events;
}
export function parseIcsEvents(rawIcs: string): FetchedCalendarEvent[] {
try {
const jcalData = ICAL.parse(rawIcs);
const comp = new ICAL.Component(jcalData);
const vevents = comp.getAllSubcomponents("vevent");
return vevents.map((vevent) => {
const event = new ICAL.Event(vevent);
return {
uid: event.uid,
summary: event.summary ?? "(no title)",
startTime: event.startDate ? event.startDate.toJSDate() : null,
endTime: event.endDate ? event.endDate.toJSDate() : null,
allDay: event.startDate?.isDate ?? false,
rawIcs,
};
});
} catch {
// A single malformed ICS blob shouldn't take down the whole sync.
return [];
}
}
@@ -0,0 +1,48 @@
import { describe, it, expect } from "vitest";
import crypto from "node:crypto";
import { encryptCredential, decryptCredential, rotateCredential } from "../credentials";
const KEY_A = crypto.randomBytes(32);
const KEY_B = crypto.randomBytes(32);
describe("credential encryption", () => {
it("round-trips plaintext through encrypt/decrypt", () => {
const encoded = encryptCredential("hunter2", KEY_A);
expect(decryptCredential(encoded, KEY_A)).toBe("hunter2");
});
it("uses a fresh IV per call", () => {
const a = JSON.parse(encryptCredential("same-value", KEY_A));
const b = JSON.parse(encryptCredential("same-value", KEY_A));
expect(a.iv).not.toBe(b.iv);
expect(a.ciphertext).not.toBe(b.ciphertext);
});
it("throws when decrypting with the wrong key", () => {
const encoded = encryptCredential("hunter2", KEY_A);
expect(() => decryptCredential(encoded, KEY_B)).toThrow();
});
it("throws when ciphertext has been tampered with", () => {
const payload = JSON.parse(encryptCredential("hunter2", KEY_A));
const ciphertextBytes = Buffer.from(payload.ciphertext, "base64");
ciphertextBytes[0] ^= 0xff;
payload.ciphertext = ciphertextBytes.toString("base64");
expect(() => decryptCredential(JSON.stringify(payload), KEY_A)).toThrow();
});
it("throws when the auth tag has been tampered with", () => {
const payload = JSON.parse(encryptCredential("hunter2", KEY_A));
const tagBytes = Buffer.from(payload.authTag, "base64");
tagBytes[0] ^= 0xff;
payload.authTag = tagBytes.toString("base64");
expect(() => decryptCredential(JSON.stringify(payload), KEY_A)).toThrow();
});
it("rotates a value from one key to another", () => {
const encoded = encryptCredential("hunter2", KEY_A);
const rotated = rotateCredential(encoded, KEY_A, KEY_B);
expect(decryptCredential(rotated, KEY_B)).toBe("hunter2");
expect(() => decryptCredential(rotated, KEY_A)).toThrow();
});
});
+56
View File
@@ -0,0 +1,56 @@
import crypto from "node:crypto";
const ALGORITHM = "aes-256-gcm";
const IV_LENGTH = 12;
interface EncryptedPayload {
iv: string;
ciphertext: string;
authTag: string;
}
function loadKey(): Buffer {
const raw = process.env.CREDENTIALS_ENCRYPTION_KEY;
if (!raw) {
throw new Error(
"CREDENTIALS_ENCRYPTION_KEY is not set — generate one with scripts/generate-key.ts",
);
}
const key = Buffer.from(raw, "base64");
if (key.length !== 32) {
throw new Error("CREDENTIALS_ENCRYPTION_KEY must decode to exactly 32 bytes");
}
return key;
}
export function encryptCredential(plaintext: string, key: Buffer = loadKey()): string {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload: EncryptedPayload = {
iv: iv.toString("base64"),
ciphertext: ciphertext.toString("base64"),
authTag: authTag.toString("base64"),
};
return JSON.stringify(payload);
}
export function decryptCredential(encoded: string, key: Buffer = loadKey()): string {
const payload: EncryptedPayload = JSON.parse(encoded);
const iv = Buffer.from(payload.iv, "base64");
const ciphertext = Buffer.from(payload.ciphertext, "base64");
const authTag = Buffer.from(payload.authTag, "base64");
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
return plaintext.toString("utf8");
}
/** Re-encrypts a single stored value under a new key — used by the rotation script. */
export function rotateCredential(encoded: string, oldKey: Buffer, newKey: Buffer): string {
const plaintext = decryptCredential(encoded, oldKey);
return encryptCredential(plaintext, newKey);
}
+15
View File
@@ -0,0 +1,15 @@
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import path from "node:path";
import fs from "node:fs";
import * as schema from "./schema";
const dataDir = process.env.DATA_DIR ?? path.join(process.cwd(), "data");
fs.mkdirSync(dataDir, { recursive: true });
const sqlite = new Database(path.join(dataDir, "db.sqlite"));
sqlite.pragma("journal_mode = WAL");
sqlite.pragma("foreign_keys = ON");
export const db = drizzle(sqlite, { schema });
export type DB = typeof db;
@@ -0,0 +1,112 @@
CREATE TABLE `briefings` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`briefing_date` text NOT NULL,
`overall_summary` text NOT NULL,
`items_json` text NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `calendar_events` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`mailbox_credential_id` text NOT NULL,
`uid` text NOT NULL,
`summary` text,
`start_time` integer,
`end_time` integer,
`all_day` integer DEFAULT false,
`raw_ics` text,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`mailbox_credential_id`) REFERENCES `mailbox_credentials`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `email_drafts` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`source_email_id` text NOT NULL,
`generated_subject` text NOT NULL,
`generated_body` text NOT NULL,
`edited_body` text,
`status` text DEFAULT 'pending' NOT NULL,
`model_used` text NOT NULL,
`sent_at` integer,
`sent_message_id` text,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`source_email_id`) REFERENCES `emails`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `emails` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`mailbox_credential_id` text NOT NULL,
`imap_uid` integer NOT NULL,
`message_id` text,
`subject` text,
`from_address` text,
`to_address` text,
`date_received` integer,
`snippet` text,
`body_text` text,
`is_unread` integer DEFAULT true,
`in_reply_to` text,
`references_header` text,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`mailbox_credential_id`) REFERENCES `mailbox_credentials`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `mailbox_credentials` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`imap_host` text,
`imap_port` integer,
`imap_secure` integer DEFAULT true,
`imap_user` text,
`imap_password_enc` text,
`imap_verified_at` integer,
`smtp_host` text,
`smtp_port` integer,
`smtp_secure` integer DEFAULT true,
`smtp_user` text,
`smtp_password_enc` text,
`smtp_verified_at` integer,
`caldav_url` text,
`caldav_user` text,
`caldav_password_enc` text,
`caldav_verified_at` integer,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `sessions` (
`token_hash` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`expires_at` integer NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `sync_log` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`job_type` text NOT NULL,
`status` text DEFAULT 'running' NOT NULL,
`started_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL,
`finished_at` integer,
`error` text,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `users` (
`id` text PRIMARY KEY NOT NULL,
`email` text NOT NULL,
`password_hash` text NOT NULL,
`timezone` text DEFAULT 'UTC' NOT NULL,
`created_at` integer DEFAULT (unixepoch('subsec') * 1000) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);
@@ -0,0 +1,797 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e0b92f05-90cc-423b-9f08-b1a7c9283615",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"briefings": {
"name": "briefings",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"briefing_date": {
"name": "briefing_date",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"overall_summary": {
"name": "overall_summary",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"items_json": {
"name": "items_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"briefings_user_id_users_id_fk": {
"name": "briefings_user_id_users_id_fk",
"tableFrom": "briefings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"calendar_events": {
"name": "calendar_events",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mailbox_credential_id": {
"name": "mailbox_credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"uid": {
"name": "uid",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"summary": {
"name": "summary",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"start_time": {
"name": "start_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"end_time": {
"name": "end_time",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"all_day": {
"name": "all_day",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": false
},
"raw_ics": {
"name": "raw_ics",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"calendar_events_user_id_users_id_fk": {
"name": "calendar_events_user_id_users_id_fk",
"tableFrom": "calendar_events",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"calendar_events_mailbox_credential_id_mailbox_credentials_id_fk": {
"name": "calendar_events_mailbox_credential_id_mailbox_credentials_id_fk",
"tableFrom": "calendar_events",
"tableTo": "mailbox_credentials",
"columnsFrom": [
"mailbox_credential_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"email_drafts": {
"name": "email_drafts",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"source_email_id": {
"name": "source_email_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_subject": {
"name": "generated_subject",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generated_body": {
"name": "generated_body",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"edited_body": {
"name": "edited_body",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'pending'"
},
"model_used": {
"name": "model_used",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"sent_at": {
"name": "sent_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"sent_message_id": {
"name": "sent_message_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"email_drafts_user_id_users_id_fk": {
"name": "email_drafts_user_id_users_id_fk",
"tableFrom": "email_drafts",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"email_drafts_source_email_id_emails_id_fk": {
"name": "email_drafts_source_email_id_emails_id_fk",
"tableFrom": "email_drafts",
"tableTo": "emails",
"columnsFrom": [
"source_email_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"emails": {
"name": "emails",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"mailbox_credential_id": {
"name": "mailbox_credential_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"imap_uid": {
"name": "imap_uid",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"message_id": {
"name": "message_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"subject": {
"name": "subject",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"from_address": {
"name": "from_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"to_address": {
"name": "to_address",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"date_received": {
"name": "date_received",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"snippet": {
"name": "snippet",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"body_text": {
"name": "body_text",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_unread": {
"name": "is_unread",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"in_reply_to": {
"name": "in_reply_to",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"references_header": {
"name": "references_header",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"emails_user_id_users_id_fk": {
"name": "emails_user_id_users_id_fk",
"tableFrom": "emails",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"emails_mailbox_credential_id_mailbox_credentials_id_fk": {
"name": "emails_mailbox_credential_id_mailbox_credentials_id_fk",
"tableFrom": "emails",
"tableTo": "mailbox_credentials",
"columnsFrom": [
"mailbox_credential_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"mailbox_credentials": {
"name": "mailbox_credentials",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"imap_host": {
"name": "imap_host",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"imap_port": {
"name": "imap_port",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"imap_secure": {
"name": "imap_secure",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"imap_user": {
"name": "imap_user",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"imap_password_enc": {
"name": "imap_password_enc",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"imap_verified_at": {
"name": "imap_verified_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"smtp_host": {
"name": "smtp_host",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"smtp_port": {
"name": "smtp_port",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"smtp_secure": {
"name": "smtp_secure",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": true
},
"smtp_user": {
"name": "smtp_user",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"smtp_password_enc": {
"name": "smtp_password_enc",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"smtp_verified_at": {
"name": "smtp_verified_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caldav_url": {
"name": "caldav_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caldav_user": {
"name": "caldav_user",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caldav_password_enc": {
"name": "caldav_password_enc",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caldav_verified_at": {
"name": "caldav_verified_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"mailbox_credentials_user_id_users_id_fk": {
"name": "mailbox_credentials_user_id_users_id_fk",
"tableFrom": "mailbox_credentials",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sessions": {
"name": "sessions",
"columns": {
"token_hash": {
"name": "token_hash",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"sync_log": {
"name": "sync_log",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"job_type": {
"name": "job_type",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'running'"
},
"started_at": {
"name": "started_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
},
"finished_at": {
"name": "finished_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"error": {
"name": "error",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {
"sync_log_user_id_users_id_fk": {
"name": "sync_log_user_id_users_id_fk",
"tableFrom": "sync_log",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"timezone": {
"name": "timezone",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'UTC'"
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(unixepoch('subsec') * 1000)"
}
},
"indexes": {
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1785056971175,
"tag": "0000_overrated_star_brand",
"breakpoints": true
}
]
}
+150
View File
@@ -0,0 +1,150 @@
import { sql } from "drizzle-orm";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
const id = () =>
text("id")
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
const timestamps = {
createdAt: integer("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
};
export const users = sqliteTable("users", {
id: id(),
email: text("email").notNull().unique(),
passwordHash: text("password_hash").notNull(),
timezone: text("timezone").notNull().default("UTC"),
...timestamps,
});
export const sessions = sqliteTable("sessions", {
// primary key is the SHA-256 hash of the raw session token — the raw
// token only ever lives in the client's httpOnly cookie.
tokenHash: text("token_hash").primaryKey(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expiresAt: integer("expires_at", { mode: "timestamp_ms" }).notNull(),
...timestamps,
});
export const mailboxCredentials = sqliteTable("mailbox_credentials", {
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
imapHost: text("imap_host"),
imapPort: integer("imap_port"),
imapSecure: integer("imap_secure", { mode: "boolean" }).default(true),
imapUser: text("imap_user"),
imapPasswordEnc: text("imap_password_enc"), // JSON: {iv, ciphertext, authTag}
imapVerifiedAt: integer("imap_verified_at", { mode: "timestamp_ms" }),
smtpHost: text("smtp_host"),
smtpPort: integer("smtp_port"),
smtpSecure: integer("smtp_secure", { mode: "boolean" }).default(true),
smtpUser: text("smtp_user"),
smtpPasswordEnc: text("smtp_password_enc"),
smtpVerifiedAt: integer("smtp_verified_at", { mode: "timestamp_ms" }),
caldavUrl: text("caldav_url"),
caldavUser: text("caldav_user"),
caldavPasswordEnc: text("caldav_password_enc"),
caldavVerifiedAt: integer("caldav_verified_at", { mode: "timestamp_ms" }),
...timestamps,
});
export const emails = sqliteTable("emails", {
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
mailboxCredentialId: text("mailbox_credential_id")
.notNull()
.references(() => mailboxCredentials.id, { onDelete: "cascade" }),
imapUid: integer("imap_uid").notNull(),
messageId: text("message_id"),
subject: text("subject"),
fromAddress: text("from_address"),
toAddress: text("to_address"),
dateReceived: integer("date_received", { mode: "timestamp_ms" }),
snippet: text("snippet"),
bodyText: text("body_text"),
isUnread: integer("is_unread", { mode: "boolean" }).default(true),
inReplyTo: text("in_reply_to"),
referencesHeader: text("references_header"),
...timestamps,
});
export const calendarEvents = sqliteTable("calendar_events", {
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
mailboxCredentialId: text("mailbox_credential_id")
.notNull()
.references(() => mailboxCredentials.id, { onDelete: "cascade" }),
uid: text("uid").notNull(),
summary: text("summary"),
startTime: integer("start_time", { mode: "timestamp_ms" }),
endTime: integer("end_time", { mode: "timestamp_ms" }),
allDay: integer("all_day", { mode: "boolean" }).default(false),
rawIcs: text("raw_ics"),
...timestamps,
});
export const briefings = sqliteTable("briefings", {
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
briefingDate: text("briefing_date").notNull(), // YYYY-MM-DD in user's timezone
overallSummary: text("overall_summary").notNull(),
itemsJson: text("items_json").notNull(),
...timestamps,
});
export const emailDrafts = sqliteTable("email_drafts", {
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
sourceEmailId: text("source_email_id")
.notNull()
.references(() => emails.id, { onDelete: "cascade" }),
generatedSubject: text("generated_subject").notNull(),
generatedBody: text("generated_body").notNull(),
editedBody: text("edited_body"),
status: text("status", {
enum: ["pending", "edited", "approved", "sent", "rejected"],
})
.notNull()
.default("pending"),
modelUsed: text("model_used").notNull(),
sentAt: integer("sent_at", { mode: "timestamp_ms" }),
sentMessageId: text("sent_message_id"),
...timestamps,
});
export const syncLog = sqliteTable("sync_log", {
id: id(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
jobType: text("job_type", { enum: ["sync", "briefing"] }).notNull(),
status: text("status", {
enum: ["running", "succeeded", "failed"],
})
.notNull()
.default("running"),
startedAt: integer("started_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch('subsec') * 1000)`),
finishedAt: integer("finished_at", { mode: "timestamp_ms" }),
error: text("error"),
});
+82
View File
@@ -0,0 +1,82 @@
import { and, eq, gte, isNotNull } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { users, emails, calendarEvents, briefings } from "@/lib/db/schema";
import { generateBriefing } from "@/lib/ai/briefing";
function todayDateString(timezone: string): string {
try {
return new Intl.DateTimeFormat("en-CA", {
timeZone: timezone,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(new Date());
} catch {
// Invalid/unsupported timezone string — fall back to UTC rather than throwing.
return new Date().toISOString().slice(0, 10);
}
}
export async function runBriefingForUser(userId: string, timezone: string, force = false) {
const dateStr = todayDateString(timezone);
if (!force) {
const existing = await db.query.briefings.findFirst({
where: (b, { and, eq }) => and(eq(b.userId, userId), eq(b.briefingDate, dateStr)),
});
if (existing) return existing;
}
const unreadEmails = await db
.select()
.from(emails)
.where(and(eq(emails.userId, userId), eq(emails.isUnread, true)))
.limit(30);
const now = new Date();
const upcomingEvents = await db
.select()
.from(calendarEvents)
.where(and(eq(calendarEvents.userId, userId), isNotNull(calendarEvents.startTime), gte(calendarEvents.startTime, now)))
.limit(30);
const result = await generateBriefing(
unreadEmails.map((e) => ({
id: e.id,
from: e.fromAddress,
subject: e.subject,
snippet: e.snippet ?? "",
isUnread: e.isUnread ?? true,
})),
upcomingEvents.map((e) => ({
summary: e.summary,
startTime: e.startTime,
endTime: e.endTime,
})),
);
await db.delete(briefings).where(and(eq(briefings.userId, userId), eq(briefings.briefingDate, dateStr)));
const [row] = await db
.insert(briefings)
.values({
userId,
briefingDate: dateStr,
overallSummary: result.overallSummary,
itemsJson: JSON.stringify(result.items),
})
.returning();
return row;
}
export async function runBriefingForAllUsers() {
const allUsers = await db.select().from(users);
for (const user of allUsers) {
try {
await runBriefingForUser(user.id, user.timezone);
} catch (err) {
console.error(`[briefing job] failed for user ${user.id}`, err);
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import cron from "node-cron";
import { runSyncForAllUsers } from "./syncJob";
import { runBriefingForAllUsers } from "./briefingJob";
let started = false;
/** Registers cron jobs exactly once per server boot — call from instrumentation.ts's register(). */
export function startScheduler() {
if (started) return;
started = true;
// Every 20 minutes: fetch new mail + calendar events for every connected mailbox.
cron.schedule("*/20 * * * *", () => {
runSyncForAllUsers().catch((err) => {
console.error("[scheduler] sync job failed", err);
});
});
// Once a day at 07:00 UTC: generate the daily briefing (a fixed UTC time
// for the MVP — per-user timezone-aware scheduling is a later refinement).
cron.schedule("0 7 * * *", () => {
runBriefingForAllUsers().catch((err) => {
console.error("[scheduler] briefing job failed", err);
});
});
console.log("[scheduler] cron jobs registered");
}
+197
View File
@@ -0,0 +1,197 @@
import { and, eq, gte, lte, sql, gt } from "drizzle-orm";
import { db } from "@/lib/db/client";
import { mailboxCredentials, emails, calendarEvents, syncLog } from "@/lib/db/schema";
import { decryptCredential } from "@/lib/crypto/credentials";
import { fetchNewMessages } from "@/lib/mail/imap";
import { fetchUpcomingEvents } from "@/lib/calendar/caldav";
import { withTimeout } from "@/lib/timeout";
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
const STALE_RUN_THRESHOLD_MS = 30 * 60 * 1000;
/**
* Marks a sync as started for this user, unless one is already running (and
* not stale) — guards against overlapping runs when a sync takes longer than
* the cron interval.
*/
async function claimSyncSlot(userId: string, jobType: "sync" | "briefing") {
const staleThreshold = new Date(Date.now() - STALE_RUN_THRESHOLD_MS);
const running = await db
.select()
.from(syncLog)
.where(
and(
eq(syncLog.userId, userId),
eq(syncLog.jobType, jobType),
eq(syncLog.status, "running"),
gt(syncLog.startedAt, staleThreshold),
),
);
if (running.length > 0) return null;
const [row] = await db
.insert(syncLog)
.values({ userId, jobType, status: "running" })
.returning();
return row;
}
async function finishSyncSlot(logId: string, status: "succeeded" | "failed", note?: string) {
await db
.update(syncLog)
.set({
status,
finishedAt: new Date(),
error: note ?? null,
})
.where(eq(syncLog.id, logId));
}
type MailboxCredential = typeof mailboxCredentials.$inferSelect;
async function syncImap(cred: MailboxCredential): Promise<number> {
if (!cred.imapVerifiedAt || !cred.imapHost || !cred.imapPort || !cred.imapUser || !cred.imapPasswordEnc) {
return 0;
}
const password = decryptCredential(cred.imapPasswordEnc);
const config = {
host: cred.imapHost,
port: cred.imapPort,
secure: cred.imapSecure ?? true,
auth: { user: cred.imapUser, pass: password },
};
const [{ maxUid }] = await db
.select({ maxUid: sql<number>`COALESCE(MAX(${emails.imapUid}), 0)` })
.from(emails)
.where(eq(emails.mailboxCredentialId, cred.id));
const { messages } = await fetchNewMessages(config, maxUid);
if (messages.length === 0) return 0;
await db.insert(emails).values(
messages.map((m) => ({
userId: cred.userId,
mailboxCredentialId: cred.id,
imapUid: m.uid,
messageId: m.messageId,
subject: m.subject,
fromAddress: m.from,
toAddress: m.to,
dateReceived: m.date,
snippet: m.snippet,
bodyText: m.bodyText,
isUnread: m.isUnread,
inReplyTo: m.inReplyTo,
referencesHeader: m.references,
})),
);
return messages.length;
}
async function syncCalDav(cred: MailboxCredential): Promise<number> {
if (!cred.caldavVerifiedAt || !cred.caldavUrl || !cred.caldavUser || !cred.caldavPasswordEnc) {
return 0;
}
const password = decryptCredential(cred.caldavPasswordEnc);
const config = { serverUrl: cred.caldavUrl, username: cred.caldavUser, password };
const start = new Date();
start.setDate(start.getDate() - 7);
const end = new Date();
end.setDate(end.getDate() + 30);
const events = await fetchUpcomingEvents(config, start, end);
// Re-fetch-and-replace within the window rather than tracking a watermark —
// CalDAV events can be edited/moved in place, unlike append-only IMAP UIDs.
await db
.delete(calendarEvents)
.where(
and(
eq(calendarEvents.mailboxCredentialId, cred.id),
gte(calendarEvents.startTime, start),
lte(calendarEvents.startTime, end),
),
);
if (events.length > 0) {
await db.insert(calendarEvents).values(
events.map((e) => ({
userId: cred.userId,
mailboxCredentialId: cred.id,
uid: e.uid,
summary: e.summary,
startTime: e.startTime,
endTime: e.endTime,
allDay: e.allDay,
rawIcs: e.rawIcs,
})),
);
}
return events.length;
}
export interface SyncResult {
mailboxCredentialId: string;
skipped: boolean;
newEmails?: number;
calendarEvents?: number;
error?: string;
}
// Hard ceiling on each protocol's sync, independent of whatever timeout the
// underlying client thinks it configured — some IMAP servers accept a
// command and then simply never reply (observed against Ethereal's test
// IMAP server hanging on LIST), which no per-command timeout option catches.
const PROTOCOL_SYNC_TIMEOUT_MS = 60_000;
export async function runSyncForMailbox(cred: MailboxCredential): Promise<SyncResult> {
const slot = await claimSyncSlot(cred.userId, "sync");
if (!slot) {
return { mailboxCredentialId: cred.id, skipped: true };
}
const [imapResult, caldavResult] = await Promise.allSettled([
withTimeout(syncImap(cred), PROTOCOL_SYNC_TIMEOUT_MS, "IMAP sync"),
withTimeout(syncCalDav(cred), PROTOCOL_SYNC_TIMEOUT_MS, "CalDAV sync"),
]);
const newEmails = imapResult.status === "fulfilled" ? imapResult.value : undefined;
const events = caldavResult.status === "fulfilled" ? caldavResult.value : undefined;
const errors = [
imapResult.status === "rejected" ? `IMAP: ${errorMessage(imapResult.reason)}` : null,
caldavResult.status === "rejected" ? `CalDAV: ${errorMessage(caldavResult.reason)}` : null,
].filter((e): e is string => e !== null);
// Only treat the run as a hard failure if every protocol that was
// configured for this mailbox failed — partial success (e.g. IMAP synced
// fine, CalDAV timed out) still counts as a completed run, but the partial
// error is still recorded for visibility on the sync debug page.
const bothFailed = imapResult.status === "rejected" && caldavResult.status === "rejected";
await finishSyncSlot(slot.id, bothFailed ? "failed" : "succeeded", errors.length > 0 ? errors.join("; ") : undefined);
return {
mailboxCredentialId: cred.id,
skipped: false,
newEmails,
calendarEvents: events,
error: errors.length > 0 ? errors.join("; ") : undefined,
};
}
export async function runSyncForAllUsers(): Promise<SyncResult[]> {
const creds = await db.select().from(mailboxCredentials);
const results: SyncResult[] = [];
for (const cred of creds) {
results.push(await runSyncForMailbox(cred));
}
return results;
}
+38
View File
@@ -0,0 +1,38 @@
export type ConnectionErrorReason = "auth" | "tls" | "timeout" | "unknown";
export type ConnectionTestResult =
| { ok: true }
| { ok: false; reason: ConnectionErrorReason; message: string };
/** Classifies a raw connection error into an actionable category for the "connect mailbox" UI. */
export function classifyConnectionError(err: unknown): { reason: ConnectionErrorReason; message: string } {
const message = err instanceof Error ? err.message : String(err);
const lower = message.toLowerCase();
// imapflow flags auth failures explicitly rather than putting "auth" in the
// message (e.g. a bare "Command failed" for a rejected LOGIN).
if (err instanceof Error && "authenticationFailed" in err && (err as { authenticationFailed?: boolean }).authenticationFailed) {
return { reason: "auth", message };
}
if (
lower.includes("auth") ||
lower.includes("credentials") ||
lower.includes("login") ||
lower.includes("invalid username or password")
) {
return { reason: "auth", message };
}
if (lower.includes("certificate") || lower.includes("tls") || lower.includes("ssl") || lower.includes("self signed")) {
return { reason: "tls", message };
}
if (
lower.includes("timeout") ||
lower.includes("econnrefused") ||
lower.includes("enotfound") ||
lower.includes("ehostunreach")
) {
return { reason: "timeout", message };
}
return { reason: "unknown", message };
}
+129
View File
@@ -0,0 +1,129 @@
import { ImapFlow } from "imapflow";
import { classifyConnectionError, type ConnectionTestResult } from "./connection-errors";
export interface ImapConfig {
host: string;
port: number;
secure: boolean;
auth: { user: string; pass: string };
}
// The connect-test flow is a synchronous HTTP request — fail fast rather than
// inheriting imapflow's defaults (90s connection timeout, 5min socket timeout),
// which would otherwise leave the request hanging against a slow/dead server.
const TEST_CONNECTION_TIMEOUT_MS = 10_000;
const TEST_GREETING_TIMEOUT_MS = 8_000;
/**
* ImapFlow is an EventEmitter that can emit 'error' asynchronously (e.g. a
* socket-level failure after connect() already resolved/rejected). Node
* crashes the whole process on an unhandled 'error' event, so every client
* must have a listener attached — even just to log and ignore it.
*/
function silenceAsyncErrors(client: ImapFlow): void {
client.on("error", (err) => {
console.error("[imap] async client error (ignored)", err);
});
}
export async function testImapConnection(config: ImapConfig): Promise<ConnectionTestResult> {
const client = new ImapFlow({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
logger: false,
connectionTimeout: TEST_CONNECTION_TIMEOUT_MS,
greetingTimeout: TEST_GREETING_TIMEOUT_MS,
socketTimeout: TEST_CONNECTION_TIMEOUT_MS,
});
silenceAsyncErrors(client);
try {
await client.connect();
await client.logout();
return { ok: true };
} catch (err) {
return { ok: false, ...classifyConnectionError(err) };
} finally {
client.close();
}
}
export interface FetchedEmail {
uid: number;
messageId?: string;
subject?: string;
from?: string;
to?: string;
date?: Date;
snippet: string;
bodyText: string;
inReplyTo?: string;
references?: string;
isUnread: boolean;
}
/** Fetches unseen messages from INBOX since a given UID watermark (exclusive). */
export async function fetchNewMessages(
config: ImapConfig,
sinceUid: number,
): Promise<{ messages: FetchedEmail[]; highestUid: number }> {
const client = new ImapFlow({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
logger: false,
connectionTimeout: 20_000,
greetingTimeout: 10_000,
socketTimeout: 120_000,
});
silenceAsyncErrors(client);
const messages: FetchedEmail[] = [];
let highestUid = sinceUid;
await client.connect();
try {
const lock = await client.getMailboxLock("INBOX");
try {
const range = `${sinceUid + 1}:*`;
for await (const msg of client.fetch(
{ uid: range },
{ envelope: true, source: true, flags: true, uid: true },
)) {
if (msg.uid <= sinceUid) continue;
highestUid = Math.max(highestUid, msg.uid);
const source = msg.source?.toString("utf8") ?? "";
messages.push({
uid: msg.uid,
messageId: msg.envelope?.messageId ?? undefined,
subject: msg.envelope?.subject ?? undefined,
from: msg.envelope?.from?.[0]?.address ?? undefined,
to: msg.envelope?.to?.[0]?.address ?? undefined,
date: msg.envelope?.date ?? undefined,
snippet: extractSnippet(source),
bodyText: source,
inReplyTo: msg.envelope?.inReplyTo ?? undefined,
references: undefined,
isUnread: !msg.flags?.has("\\Seen"),
});
}
} finally {
lock.release();
}
} finally {
await client.logout();
}
return { messages, highestUid };
}
function extractSnippet(rawSource: string, maxLength = 280): string {
const bodyStart = rawSource.indexOf("\r\n\r\n");
const body = bodyStart >= 0 ? rawSource.slice(bodyStart + 4) : rawSource;
const collapsed = body.replace(/\s+/g, " ").trim();
return collapsed.slice(0, maxLength);
}
+75
View File
@@ -0,0 +1,75 @@
import nodemailer from "nodemailer";
import { classifyConnectionError, type ConnectionTestResult } from "./connection-errors";
export interface SmtpConfig {
host: string;
port: number;
secure: boolean;
auth: { user: string; pass: string };
}
// Bounded so a slow/dead SMTP server can't hang the request indefinitely —
// nodemailer's own defaults run into the minutes.
const CONNECTION_TIMEOUT_MS = 10_000;
const GREETING_TIMEOUT_MS = 8_000;
const SOCKET_TIMEOUT_MS = 15_000;
function buildTransport(config: SmtpConfig) {
const transport = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: config.auth,
connectionTimeout: CONNECTION_TIMEOUT_MS,
greetingTimeout: GREETING_TIMEOUT_MS,
socketTimeout: SOCKET_TIMEOUT_MS,
});
// nodemailer's transport is an EventEmitter that can emit 'error'
// asynchronously outside the verify()/sendMail() promise — an unhandled
// 'error' event crashes the whole Node process, so it always needs a listener.
transport.on("error", (err) => {
console.error("[smtp] async transport error (ignored)", err);
});
return transport;
}
export async function testSmtpConnection(config: SmtpConfig): Promise<ConnectionTestResult> {
const transport = buildTransport(config);
try {
await transport.verify();
return { ok: true };
} catch (err) {
return { ok: false, ...classifyConnectionError(err) };
} finally {
transport.close();
}
}
export interface SendReplyInput {
fromAddress: string;
toAddress: string;
subject: string;
bodyText: string;
inReplyTo?: string;
references?: string;
}
export async function sendReply(
config: SmtpConfig,
input: SendReplyInput,
): Promise<{ messageId: string }> {
const transport = buildTransport(config);
try {
const info = await transport.sendMail({
from: input.fromAddress,
to: input.toAddress,
subject: input.subject,
text: input.bodyText,
inReplyTo: input.inReplyTo,
references: input.references,
});
return { messageId: info.messageId };
} finally {
transport.close();
}
}
+25
View File
@@ -0,0 +1,25 @@
/**
* Races a promise against a hard deadline. A safety net for third-party
* clients whose own timeout options don't reliably fire against every
* server — some IMAP/CalDAV servers accept a command and then simply never
* reply, which is exactly the kind of self-hosted-server flakiness this
* product has to tolerate (see the mailbox-connect risk notes).
*/
export function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error(`${label} timed out after ${timeoutMs}ms`));
}, timeoutMs);
promise.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(err) => {
clearTimeout(timer);
reject(err);
},
);
});
}
+34
View File
@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from "vitest/config";
import path from "node:path";
export default defineConfig({
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
test: {
environment: "node",
},
});