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:
+44
@@ -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
|
||||
@@ -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 -->
|
||||
@@ -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.
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Generated
+8955
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"));
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
{" — "}“{draft.sourceEmail.subject}”
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1785056971175,
|
||||
"tag": "0000_overrated_star_brand",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user