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