Build AI Chief of Staff MVP: auth, mailbox sync, briefings, draft replies

Scaffolds a Next.js app with email/password auth, encrypted IMAP/SMTP/CalDAV
mailbox connections, a background sync job, Claude-generated daily briefings,
and an approve-before-send draft reply flow. Includes a clean design system
(light/dark) and deployment config (systemd unit, credential rotation script).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-07-26 10:54:34 +00:00
1 parent facd4d71bd
commit bcf0e86618
65 files changed
+13413 -1

No files matched your search

@@ -0,0 +1,33 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function GenerateBriefingButton() {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onClick() {
setPending(true);
setError(null);
const res = await fetch("/api/briefing/generate", { method: "POST" });
const data = await res.json().catch(() => ({}));
setPending(false);
if (!res.ok) {
setError(data.error ?? "Failed to generate briefing");
return;
}
router.refresh();
}
return (
<div className="field" style={{ marginBottom: 24 }}>
<button onClick={onClick} className="btn btn-primary" disabled={pending}>
{pending ? "Generating…" : "Generate today's briefing"}
</button>
{error && <div className="alert alert-danger" style={{ marginTop: 10 }}>{error}</div>}
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import type { BriefingResult } from "@/lib/ai/briefing";
import { GenerateBriefingButton } from "./generate-button";
const PRIORITY_BADGE: Record<string, string> = {
high: "badge-danger",
medium: "badge-warning",
low: "badge-neutral",
};
export default async function BriefingPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const briefing = await db.query.briefings.findFirst({
where: (b, { eq }) => eq(b.userId, session.user.id),
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
const items: BriefingResult["items"] = briefing ? JSON.parse(briefing.itemsJson) : [];
return (
<div className="page">
<div className="page-title">Today&apos;s briefing</div>
<p className="page-subtitle">A prioritized summary of what needs your attention.</p>
<GenerateBriefingButton />
{!briefing && (
<div className="empty-state card">
No briefing generated yet — connect a mailbox, sync, then generate one.
</div>
)}
{briefing && (
<>
<div className="card" style={{ marginBottom: 20 }}>
<p style={{ fontSize: 15 }}>{briefing.overallSummary}</p>
<p className="faint" style={{ marginTop: 10 }}>
Generated {briefing.createdAt.toLocaleString()} · {briefing.briefingDate}
</p>
</div>
<div className="stack">
{items.map((item, i) => (
<div key={i} className="briefing-item">
<div className="briefing-item-head">
<span className="briefing-item-category">{item.category}</span>
<span className={`badge ${PRIORITY_BADGE[item.priority] ?? "badge-neutral"}`}>
{item.priority}
</span>
</div>
<p className="muted" style={{ color: "var(--text)" }}>{item.summary}</p>
</div>
))}
</div>
</>
)}
</div>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export interface DraftCardData {
id: string;
generatedSubject: string;
generatedBody: string;
editedBody: string | null;
status: string;
sourceEmail: {
fromAddress: string | null;
subject: string | null;
snippet: string | null;
} | null;
}
export function DraftCard({ draft }: { draft: DraftCardData }) {
const router = useRouter();
const [subject, setSubject] = useState(draft.generatedSubject);
const [body, setBody] = useState(draft.editedBody ?? draft.generatedBody);
const [pending, setPending] = useState<"send" | "reject" | null>(null);
const [error, setError] = useState<string | null>(null);
async function onSend() {
setPending("send");
setError(null);
const res = await fetch(`/api/drafts/${draft.id}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ subject, body }),
});
const data = await res.json().catch(() => ({}));
setPending(null);
if (!res.ok) {
setError(data.error ?? "Failed to send");
return;
}
router.refresh();
}
async function onReject() {
setPending("reject");
setError(null);
const res = await fetch(`/api/drafts/${draft.id}/reject`, { method: "POST" });
setPending(null);
if (!res.ok) {
setError("Failed to reject");
return;
}
router.refresh();
}
return (
<div className="card">
{draft.sourceEmail && (
<div className="card-section" style={{ borderTop: "none", paddingTop: 0, marginTop: 0, marginBottom: 16 }}>
<p className="muted">
Replying to <strong style={{ color: "var(--text)" }}>{draft.sourceEmail.fromAddress}</strong>
{" — "}&ldquo;{draft.sourceEmail.subject}&rdquo;
</p>
{draft.sourceEmail.snippet && <p className="faint" style={{ marginTop: 4 }}>{draft.sourceEmail.snippet}</p>}
</div>
)}
<div className="field">
<label>Subject</label>
<input value={subject} onChange={(e) => setSubject(e.target.value)} className="input" />
</div>
<div className="field">
<label>Body</label>
<textarea
value={body}
onChange={(e) => setBody(e.target.value)}
rows={8}
className="textarea"
/>
</div>
{error && <div className="alert alert-danger">{error}</div>}
<div style={{ display: "flex", gap: 8 }}>
<button onClick={onSend} className="btn btn-primary" disabled={pending !== null}>
{pending === "send" ? "Sending…" : "Send"}
</button>
<button onClick={onReject} className="btn" disabled={pending !== null}>
{pending === "reject" ? "Rejecting…" : "Reject"}
</button>
</div>
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { redirect } from "next/navigation";
import { and, desc, eq, inArray } from "drizzle-orm";
import { getCurrentSession } from "@/lib/auth/session";
import { db } from "@/lib/db/client";
import { emailDrafts, emails } from "@/lib/db/schema";
import { DraftCard } from "./draft-card";
export default async function DraftsPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const rows = await db
.select({ draft: emailDrafts, email: emails })
.from(emailDrafts)
.leftJoin(emails, eq(emailDrafts.sourceEmailId, emails.id))
.where(and(eq(emailDrafts.userId, session.user.id), inArray(emailDrafts.status, ["pending", "edited"])))
.orderBy(desc(emailDrafts.createdAt));
return (
<div className="page">
<div className="page-title">Draft replies</div>
<p className="page-subtitle">Review and edit before anything is sent — nothing goes out automatically.</p>
{rows.length === 0 && (
<div className="empty-state card">
No pending drafts. Generate one from an email on the <a href="/sync" style={{ color: "var(--accent)", fontWeight: 500 }}>sync debug page</a>.
</div>
)}
<div className="stack">
{rows.map(({ draft, email }) => (
<DraftCard
key={draft.id}
draft={{
id: draft.id,
generatedSubject: draft.generatedSubject,
generatedBody: draft.generatedBody,
editedBody: draft.editedBody,
status: draft.status,
sourceEmail: email
? { fromAddress: email.fromAddress, subject: email.subject, snippet: email.snippet }
: null,
}}
/>
))}
</div>
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { redirect } from "next/navigation";
import { getCurrentSession } from "@/lib/auth/session";
import { LogoutButton } from "./logout-button";
import { NavLinks } from "./nav-links";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getCurrentSession();
if (!session) {
redirect("/login");
}
return (
<div style={{ display: "flex", flexDirection: "column", minHeight: "100%", flex: 1 }}>
<header className="topnav">
<div className="topnav-inner">
<div className="brand">
<div className="brand-mark" />
Chief of Staff
</div>
<NavLinks />
<div className="user-menu">
<span className="user-email">{session.user.email}</span>
<LogoutButton />
</div>
</div>
</header>
<main style={{ flex: 1 }}>{children}</main>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { useRouter } from "next/navigation";
export function LogoutButton() {
const router = useRouter();
async function onClick() {
await fetch("/api/auth/logout", { method: "POST" });
router.push("/login");
router.refresh();
}
return (
<button onClick={onClick} className="btn btn-subtle btn-sm">
Log out
</button>
);
}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { usePathname } from "next/navigation";
const LINKS = [
{ href: "/briefing", label: "Briefing" },
{ href: "/drafts", label: "Drafts" },
{ href: "/settings/mailbox", label: "Mailbox" },
{ href: "/sync", label: "Sync debug" },
];
export function NavLinks() {
const pathname = usePathname();
return (
<div className="nav-links">
{LINKS.map((link) => (
<a
key={link.href}
href={link.href}
className={`nav-link${pathname === link.href ? " active" : ""}`}
>
{link.label}
</a>
))}
</div>
);
}
@@ -0,0 +1,273 @@
"use client";
import { useEffect, useState } from "react";
type ProtocolResult = { ok: true } | { ok: false; reason: string; message: string };
interface ConnectResponse {
results?: {
imap?: ProtocolResult;
smtp?: ProtocolResult;
caldav?: ProtocolResult;
};
error?: string;
}
interface StatusResponse {
connected: boolean;
imap?: { host: string; user: string } | null;
smtp?: { host: string; user: string } | null;
caldav?: { url: string; user: string } | null;
}
function ResultBadge({ result }: { result?: ProtocolResult }) {
if (!result) return null;
if (result.ok) return <span className="badge badge-success">Connected</span>;
return (
<div className="alert alert-danger" style={{ marginTop: 8, marginBottom: 0 }}>
{result.reason}: {result.message}
</div>
);
}
export default function MailboxSettingsPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [submitting, setSubmitting] = useState(false);
const [results, setResults] = useState<ConnectResponse["results"]>();
const [error, setError] = useState<string | null>(null);
const [imapEnabled, setImapEnabled] = useState(true);
const [imap, setImap] = useState({ host: "", port: "993", secure: true, user: "", pass: "" });
const [smtpEnabled, setSmtpEnabled] = useState(true);
const [smtp, setSmtp] = useState({ host: "", port: "465", secure: true, user: "", pass: "" });
const [caldavEnabled, setCaldavEnabled] = useState(false);
const [caldav, setCaldav] = useState({ serverUrl: "", username: "", password: "" });
useEffect(() => {
fetch("/api/mailbox/connect")
.then((r) => r.json())
.then(setStatus)
.catch(() => {});
}, []);
async function onSubmit(e: React.FormEvent) {
e.preventDefault();
setSubmitting(true);
setError(null);
setResults(undefined);
const body: Record<string, unknown> = {};
if (imapEnabled) body.imap = { ...imap, port: Number(imap.port) };
if (smtpEnabled) body.smtp = { ...smtp, port: Number(smtp.port) };
if (caldavEnabled) body.caldav = caldav;
const res = await fetch("/api/mailbox/connect", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data: ConnectResponse = await res.json();
setSubmitting(false);
if (!res.ok) {
setError(data.error ?? "Request failed");
return;
}
setResults(data.results);
}
return (
<div className="page">
<div className="page-title">Connect your mailbox</div>
<p className="page-subtitle">
Each protocol is tested before saving — you can save whichever ones succeed and retry the
rest later.
</p>
{status?.connected && (
<div className="alert alert-info stack" style={{ gap: 4 }}>
<strong>Currently saved</strong>
{status.imap && <div>IMAP: {status.imap.user}@{status.imap.host}</div>}
{status.smtp && <div>SMTP: {status.smtp.user}@{status.smtp.host}</div>}
{status.caldav && <div>CalDAV: {status.caldav.user} @ {status.caldav.url}</div>}
</div>
)}
<form onSubmit={onSubmit}>
<div className="card">
<div className="card-section">
<div className="card-section-title">
<label className="checkbox-row">
<input
type="checkbox"
checked={imapEnabled}
onChange={(e) => setImapEnabled(e.target.checked)}
/>
IMAP
</label>
</div>
<div className="field">
<label>Host</label>
<input
placeholder="imap.example.com"
value={imap.host}
onChange={(e) => setImap({ ...imap, host: e.target.value })}
disabled={!imapEnabled}
className="input"
/>
</div>
<div className="field">
<label>Port</label>
<input
value={imap.port}
onChange={(e) => setImap({ ...imap, port: e.target.value })}
disabled={!imapEnabled}
className="input"
/>
</div>
<label className="checkbox-row" style={{ marginBottom: 14 }}>
<input
type="checkbox"
checked={imap.secure}
onChange={(e) => setImap({ ...imap, secure: e.target.checked })}
disabled={!imapEnabled}
/>
Use TLS
</label>
<div className="field">
<label>Username</label>
<input
value={imap.user}
onChange={(e) => setImap({ ...imap, user: e.target.value })}
disabled={!imapEnabled}
className="input"
/>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Password</label>
<input
type="password"
value={imap.pass}
onChange={(e) => setImap({ ...imap, pass: e.target.value })}
disabled={!imapEnabled}
className="input"
/>
</div>
<ResultBadge result={results?.imap} />
</div>
<div className="card-section">
<div className="card-section-title">
<label className="checkbox-row">
<input
type="checkbox"
checked={smtpEnabled}
onChange={(e) => setSmtpEnabled(e.target.checked)}
/>
SMTP
</label>
</div>
<div className="field">
<label>Host</label>
<input
placeholder="smtp.example.com"
value={smtp.host}
onChange={(e) => setSmtp({ ...smtp, host: e.target.value })}
disabled={!smtpEnabled}
className="input"
/>
</div>
<div className="field">
<label>Port</label>
<input
value={smtp.port}
onChange={(e) => setSmtp({ ...smtp, port: e.target.value })}
disabled={!smtpEnabled}
className="input"
/>
</div>
<label className="checkbox-row" style={{ marginBottom: 14 }}>
<input
type="checkbox"
checked={smtp.secure}
onChange={(e) => setSmtp({ ...smtp, secure: e.target.checked })}
disabled={!smtpEnabled}
/>
Use TLS
</label>
<div className="field">
<label>Username</label>
<input
value={smtp.user}
onChange={(e) => setSmtp({ ...smtp, user: e.target.value })}
disabled={!smtpEnabled}
className="input"
/>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Password</label>
<input
type="password"
value={smtp.pass}
onChange={(e) => setSmtp({ ...smtp, pass: e.target.value })}
disabled={!smtpEnabled}
className="input"
/>
</div>
<ResultBadge result={results?.smtp} />
</div>
<div className="card-section">
<div className="card-section-title">
<label className="checkbox-row">
<input
type="checkbox"
checked={caldavEnabled}
onChange={(e) => setCaldavEnabled(e.target.checked)}
/>
CalDAV <span className="faint">(optional)</span>
</label>
</div>
<div className="field">
<label>Server URL</label>
<input
value={caldav.serverUrl}
onChange={(e) => setCaldav({ ...caldav, serverUrl: e.target.value })}
disabled={!caldavEnabled}
className="input"
/>
</div>
<div className="field">
<label>Username</label>
<input
value={caldav.username}
onChange={(e) => setCaldav({ ...caldav, username: e.target.value })}
disabled={!caldavEnabled}
className="input"
/>
</div>
<div className="field" style={{ marginBottom: 0 }}>
<label>Password</label>
<input
type="password"
value={caldav.password}
onChange={(e) => setCaldav({ ...caldav, password: e.target.value })}
disabled={!caldavEnabled}
className="input"
/>
</div>
<ResultBadge result={results?.caldav} />
</div>
</div>
{error && <div className="alert alert-danger" style={{ marginTop: 16 }}>{error}</div>}
<button type="submit" className="btn btn-primary" disabled={submitting} style={{ marginTop: 20 }}>
{submitting ? "Testing connections…" : "Test & save"}
</button>
</form>
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function DraftReplyButton({ emailId }: { emailId: string }) {
const router = useRouter();
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
async function onClick() {
setPending(true);
setError(null);
const res = await fetch("/api/drafts/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ emailId }),
});
const data = await res.json().catch(() => ({}));
setPending(false);
if (!res.ok) {
setError(data.error ?? "Failed");
return;
}
router.push("/drafts");
}
return (
<div>
<button onClick={onClick} className="btn btn-sm" disabled={pending}>
{pending ? "Drafting…" : "Draft reply"}
</button>
{error && <div className="faint" style={{ color: "var(--danger)", marginTop: 4 }}>{error}</div>}
</div>
);
}
+143
View File
@@ -0,0 +1,143 @@
import { desc, eq } from "drizzle-orm";
import { getCurrentSession } from "@/lib/auth/session";
import { redirect } from "next/navigation";
import { db } from "@/lib/db/client";
import { emails, calendarEvents, syncLog } from "@/lib/db/schema";
import { TriggerSyncButton } from "./trigger-button";
import { DraftReplyButton } from "./draft-reply-button";
const STATUS_BADGE: Record<string, string> = {
succeeded: "badge-success",
failed: "badge-danger",
running: "badge-accent",
};
export default async function SyncDebugPage() {
const session = await getCurrentSession();
if (!session) redirect("/login");
const userId = session.user.id;
const [recentEmails, recentEvents, recentLogs] = await Promise.all([
db.select().from(emails).where(eq(emails.userId, userId)).orderBy(desc(emails.dateReceived)).limit(50),
db
.select()
.from(calendarEvents)
.where(eq(calendarEvents.userId, userId))
.orderBy(desc(calendarEvents.startTime))
.limit(50),
db.select().from(syncLog).where(eq(syncLog.userId, userId)).orderBy(desc(syncLog.startedAt)).limit(10),
]);
return (
<div className="page" style={{ maxWidth: 960 }}>
<div className="page-title">Sync debug</div>
<p className="page-subtitle">Raw view of what&apos;s been fetched, for testing.</p>
<TriggerSyncButton />
<div className="stack" style={{ marginTop: 8 }}>
<div>
<div className="card-section-title" style={{ marginTop: 24 }}>Recent sync runs</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Started</th>
<th>Status</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{recentLogs.length === 0 && (
<tr>
<td colSpan={3} className="muted">No sync runs yet.</td>
</tr>
)}
{recentLogs.map((log) => (
<tr key={log.id}>
<td className="muted">{log.startedAt.toLocaleString()}</td>
<td>
<span className={`badge ${STATUS_BADGE[log.status] ?? "badge-neutral"}`}>{log.status}</span>
</td>
<td className="muted">{log.error ?? ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div>
<div className="card-section-title" style={{ marginTop: 16 }}>Emails ({recentEmails.length})</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Date</th>
<th>From</th>
<th>Subject</th>
<th>Unread</th>
<th></th>
</tr>
</thead>
<tbody>
{recentEmails.length === 0 && (
<tr>
<td colSpan={5} className="muted">No emails synced yet.</td>
</tr>
)}
{recentEmails.map((email) => (
<tr key={email.id}>
<td className="muted">{email.dateReceived?.toLocaleString() ?? ""}</td>
<td>{email.fromAddress}</td>
<td>{email.subject}</td>
<td>
{email.isUnread ? (
<span className="badge badge-accent">Unread</span>
) : (
<span className="faint">read</span>
)}
</td>
<td>
<DraftReplyButton emailId={email.id} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div>
<div className="card-section-title" style={{ marginTop: 16 }}>Calendar events ({recentEvents.length})</div>
<div className="table-wrap">
<table className="data-table">
<thead>
<tr>
<th>Start</th>
<th>End</th>
<th>Summary</th>
</tr>
</thead>
<tbody>
{recentEvents.length === 0 && (
<tr>
<td colSpan={3} className="muted">No calendar events synced yet.</td>
</tr>
)}
{recentEvents.map((event) => (
<tr key={event.id}>
<td className="muted">{event.startTime?.toLocaleString() ?? ""}</td>
<td className="muted">{event.endTime?.toLocaleString() ?? ""}</td>
<td>{event.summary}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
export function TriggerSyncButton() {
const router = useRouter();
const [status, setStatus] = useState<string | null>(null);
const [pending, setPending] = useState(false);
async function onClick() {
setPending(true);
setStatus(null);
const res = await fetch("/api/cron/trigger", { method: "POST" });
const data = await res.json();
setPending(false);
if (!res.ok) {
setStatus(`Error: ${data.error}`);
return;
}
const r = data.result;
setStatus(
r.skipped
? "A sync was already running — skipped."
: r.error
? `Failed: ${r.error}`
: `Synced ${r.newEmails} new email(s), ${r.calendarEvents} calendar event(s).`,
);
router.refresh();
}
return (
<div className="field" style={{ marginBottom: 8 }}>
<button onClick={onClick} className="btn btn-primary" disabled={pending}>
{pending ? "Syncing…" : "Trigger sync now"}
</button>
{status && <p className="muted" style={{ marginTop: 8 }}>{status}</p>}
</div>
);
}