CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

fix: sidebar re-expand, silent token refresh, working Gmail/Outlook connect + gap audit #4061

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/dreamy-wozniak-hlrxofmainopened Jun 13, 2026
14 changed files+23240−30
ModifiedCLAUDE.md+3−2View fileUnifiedSplit
656656| 36 | **Sidebar collapse was a dead end** — clicking the `‹` toggle collapsed the nav to 64px (`overflow-hidden`), but the full-size "AlecRae" wordmark still rendered and pushed the `›` expand button off the visible edge, so there was no way to re-expand (owner-reported "press left arrow to minimize, can't bring it back") | MEDIUM | 2026-06-13 | FIXED 2026-06-13 — `layout.tsx` hides the wordmark when collapsed so the toggle stays visible/centered; added `⌘\`/`Ctrl\` keyboard toggle as a guaranteed path back |
657657| 37 | **"Invalid or expired bearer token" mid-session** — access tokens live 15 min and the API issues a 7-day refresh token, but the web client discarded the refresh token and never refreshed; after 15 min idle every call 401'd and the user was silently logged out with no recovery | HIGH | 2026-06-13 | FIXED 2026-06-13 — new `apps/web/lib/auth-token.ts` stores the refresh token and silently renews on a 401 (single-flight) then retries once; wired into `api.ts` + `api-features.ts` fetch wrappers, login/register/passkey/Google flows, and the Google callback now forwards the refresh token. **Infra caveat:** a stable `JWT_SECRET` (≥32 chars) must be set on the box or every restart still invalidates all tokens at once. |
658658| 38 | **Connect Gmail/Outlook completely broken** (the path to importing inboxes) — onboarding used a relative `/v1/connect/gmail` (hit the web host), via a top-level redirect (no Bearer header → 401), to a route requiring the unsatisfiable `accounts:write` scope (→ 403) | HIGH | 2026-06-13 | FIXED 2026-06-13 — `GET /v1/connect/gmail\|outlook` now returns the OAuth consent URL as JSON via authenticated fetch (signed `state` carries identity to the public callback; no token in any URL) and uses the satisfiable `account:manage` scope; onboarding fetches the URL then navigates (`connect.ts`, web `connectApi`, `onboarding/page.tsx`) |
659| 39 | **Docs vs. reality drift** — CLAUDE.md claims "~99% launch-ready / all features complete," true for *backend code* but false for *reachable product*: ~96 API route groups exist, the web app exposes a fraction. No admin console (the one `/admin` page is unlinked/ungated/wired to nothing), and mailbox provisioning, Google-Workspace bulk import, org/team/invite/SSO, and import jobs are all **backend-only with no UI** (import workers are also stubs). See `PRODUCT_GAP_AUDIT.md` (2026-06-13). | HIGH | 2026-06-13 | IN PROGRESS — closing frontend-first in 3 steps (admin console → workspace setup → real import). **(1/3) DONE 2026-06-13:** real role-gated admin console at `(dashboard)/admin` wired to all 8 `/v1/admin/*` endpoints (overview stats, users, domains, messages, events, DLQ with clear actions); old unlinked static `/admin` stub removed; sidebar shows "Admin" for owner/admin. **(2/3) DONE 2026-06-13:** Workspace setup page at `(dashboard)/workspace` (Mailboxes: provision/list/remove native addresses on a verified domain; Team: create org, invite users, roles, pending invitations), sidebar shows "Workspace" for owner/admin. **This required fixing a systemic scope/auth trap (#40 below) that had silently 403/401'd the entire management surface — domains, mailboxes, org/team — out of the web app.** Deferred: bulk Google-Workspace directory import (its admin-OAuth start/callback need backend wiring — same redirect trap as #38). Next: (3) real import workers. |
659| 39 | **Docs vs. reality drift** — CLAUDE.md claims "~99% launch-ready / all features complete," true for *backend code* but false for *reachable product*: ~96 API route groups exist, the web app exposes a fraction. No admin console (the one `/admin` page is unlinked/ungated/wired to nothing), and mailbox provisioning, Google-Workspace bulk import, org/team/invite/SSO, and import jobs are all **backend-only with no UI** (import workers are also stubs). See `PRODUCT_GAP_AUDIT.md` (2026-06-13). | HIGH | 2026-06-13 | IN PROGRESS — closing frontend-first in 3 steps (admin console → workspace setup → real import). **(1/3) DONE 2026-06-13:** real role-gated admin console at `(dashboard)/admin` wired to all 8 `/v1/admin/*` endpoints (overview stats, users, domains, messages, events, DLQ with clear actions); old unlinked static `/admin` stub removed; sidebar shows "Admin" for owner/admin. **(2/3) DONE 2026-06-13:** Workspace setup page at `(dashboard)/workspace` (Mailboxes: provision/list/remove native addresses on a verified domain; Team: create org, invite users, roles, pending invitations), sidebar shows "Workspace" for owner/admin. **This required fixing a systemic scope/auth trap (#40 below) that had silently 403/401'd the entire management surface — domains, mailboxes, org/team — out of the web app.** Deferred: bulk Google-Workspace directory import (its admin-OAuth start/callback need backend wiring — same redirect trap as #38). **(3/3) DONE 2026-06-13:** real MBOX/EML import — Craig-authorized data-model change (`0003_lucky_squirrel_girl.sql`: `emails.domain_id` nullable + `source` column) lets connected/imported mail live in the unified `emails` table; new `received-email-store.ts` (parse via `@alecrae/email-parser`, dedup by Message-ID, `domainId` null, `source` tag) + MBOX/EML workers now actually parse + store; Import tab in `(dashboard)/workspace` (upload `.mbox`/`.eml`, job list). Gmail/Outlook history backfill fails honestly (no longer fake-completes) pending the sync-engine persistence fix (#41). **Run `bun run db:migrate` on the box before this works in prod.** |
660| 41 | **Connected-account mail is never persisted (empty inbox after connect)** — the Gmail/Outlook sync engine fetches + parses but its store is a stub: `fetchAndStoreGmailMessage` (apps/api/src/sync/engine.ts) and the Outlook loop only `console.log` instead of writing to `emails`. So connecting Gmail/Outlook syncs nothing, and direct history import for those providers can't work either. | HIGH | 2026-06-13 | OPEN — the data model now supports it (issue #39's `0003` migration: nullable `emails.domain_id` + `source`), and `received-email-store.ts` is the ready-made sink. Fix = persist via `storeReceivedEmail({ accountId: account.userId, source, ... })` in the engine (Gmail needs base64url body extraction from `payload.parts`; Outlook already has `body.content`), thread the tenant accountId, then wire the Gmail/Outlook import workers to call the engine. Deferred from #39's step 3 because it can't be verified without live OAuth — needs an OAuth-tested change. |
660661| 40 | **Systemic scope/auth trap blocked the whole management surface from the web** — (a) session JWTs only ever carried `messages:* + account:manage`, but `domains`/`mailboxes`/`workspace-import`/`organizations`/`import` routes require `domains:manage`/`account:read`/`team:manage`/`import:*` — scopes no session token (and for `account:read`/`import:*`, no API key either) ever had → blanket 403; (b) `/v1/mailboxes` had NO `authMiddleware` mount at all and `/v1/domains` (bare list/create) was only covered by `/v1/domains/*` (which doesn't match the bare path) → 401 with no auth context. So even the "working" Domains page actually 401'd. | HIGH | 2026-06-13 | FIXED 2026-06-13 — session tokens now carry role-derived scopes (`createAccessToken``scopesForRole`: owner/admin get domains:manage, account:read, team:manage, analytics:read, webhooks:manage, api_keys:manage, import:read/write; member keeps the prior baseline + account:read; viewer read-only). All handlers stay account-scoped so an owner only reaches their OWN account; cross-account `/v1/admin/*` stays role-gated via `requireAdmin` (NOT widened). Added the missing `authMiddleware` mounts for bare `/v1/domains` and `/v1/mailboxes`(+`/*`). 143/143 api tests green. |
661662
662663---
807808 surface exists in this repo).
808809
809810
810**Last updated:** 2026-06-13 05:04 UTC
811**Last updated:** 2026-06-13 05:46 UTC
811812**Current phase:** Phase 1 — Ready for Beta Launch
812813**Current focus:** Feature-complete build (84 features, 90 routes, 61 schemas, 290+ endpoints). Tier 6-8 AI platform features complete. Google sign-in + Vapron platform integration landed (PR #48). **Vapron is the permanent platform** (AI gateway, email, object storage, hosting/deploy) — Cloudflare/Vercel/Neon were always interim scaffolding until Vapron was built, and the app migrates onto Vapron as the target infra. The Vapron client has been rebuilt against the published tRPC API (issue #19 fixed). The off-stack AWS EKS deploy pipeline has been removed. Production deployment awaiting Craig's Vapron + infra setup.
813814**Build completion:** TIER 1-4 (36/36) + 7 bonus + 31 advanced (S10/10 + A7/7 + B8/8 + C6/10) + 20 expansion (Tier 5) + 9 platform (Tier 6) + 6 intelligence (Tier 7) + 6 deep AI (Tier 8)
ModifiedPRODUCT_GAP_AUDIT.md+6−3View fileUnifiedSplit
5050| **Provision mailboxes on your own domain** (the core Workspace move) | ✅ live (`/v1/mailboxes`) | ✅ `(dashboard)/workspace` → Mailboxes | **WORKING** (built 2026-06-13) |
5151| **Bulk import a Google Workspace** (admin OAuth → list users → provision up to 1000) | ⚠️ live API, OAuth start/callback need wiring | ❌ none | **DEFERRED** — needs the same OAuth-redirect fix as connect (#38) |
5252| **Organizations / teams** — create org, invite users, roles, audit log, SSO | ✅ live (`/v1/organizations`, 12+ endpoints) | ✅ `(dashboard)/workspace` → Team | **WORKING** (built 2026-06-13) |
53| **Import jobs** (Gmail/Outlook/MBOX/EML mailbox migration) | ⚠️ routes live, **workers are stubs** | ❌ none | **STUB + no screen** — jobs mark "completed" without importing any messages |
53| **Import jobs** (MBOX/EML mailbox migration) | ✅ real (parse + store, deduped) | ✅ `(dashboard)/workspace` → Import | **WORKING** (built 2026-06-13) — needs `db:migrate` on the box |
54| **Import jobs** (Gmail/Outlook history backfill) | ⚠️ depends on sync-engine persistence (stub) | ✅ upload UI present | **HONEST-FAIL** — blocked on #41 (engine stores nothing); connected accounts also show empty inbox until then |
5455
5556**So today, to actually provision a mailbox, bulk-import a Workspace, or invite a team member, you'd have to call the API directly** (e.g. with the seeded API key). There is no screen for any of it. That's why "the business side" feels unset-up: the engine is built, the dashboard for it isn't.
5657
7273
73741.**Admin console page** — DONE 2026-06-13. Real role-gated `(dashboard)/admin` wired to all 8 `/v1/admin/*` endpoints (overview stats, users, domains, messages, events, dead-letter queue with clear actions). The old unlinked static `/admin` stub was removed; the sidebar now shows "Admin" for owner/admin. Directly answers "how do I know I have admin / that things are set up."
74752.**Workspace setup flow** — DONE 2026-06-13 (mailboxes + team). New `(dashboard)/workspace` page: provision/list/remove native mailboxes on a verified domain (`/v1/mailboxes`) and create org / invite users / manage roles + pending invitations (`/v1/organizations`). Required fixing a **systemic scope/auth trap** first: session tokens carried only `messages:* + account:manage`, so `domains`/`mailboxes`/`org`/`import` routes (which need `domains:manage`/`account:read`/`team:manage`/`import:*`) blanket-403'd, and `/v1/mailboxes` + bare `/v1/domains` had no auth mount (401). Fixed at token issuance (role-derived scopes) + added the missing mounts. **Deferred:** bulk Google-Workspace directory import — its admin-OAuth start/callback need the same backend wiring as the connect redirect trap (#38).
753. **Make import real** — replace the stub import workers with actual message ingestion (the sync engine already exists for live connect; reuse it for backfill). ~2–3 days. **(next)**
763.**Make import real** — PARTIAL DONE 2026-06-13. Authorized data-model change (`0003`: `emails.domain_id` nullable + `source`) so connected/imported mail fits the unified table. **MBOX/EML import now genuinely parses + stores** (deduped) with an upload UI in Workspace → Import. Gmail/Outlook history backfill is honest-failing pending the **sync-engine persistence fix (known issue #41)** — the engine fetches but never writes to `emails`, which is also why a freshly connected Gmail/Outlook account shows an empty inbox. That fix needs live-OAuth verification, so it was split out rather than shipped unverified.
77
78**Discovered in step 3:** connected-account mail persistence (#41) is a real, previously-unrecorded gap — live sync stores nothing. The data model + sink (`received-email-store.ts`) are now in place for it.
76794. Then chip at (C)/(D) by user demand.
7780
7881None of this is blocked on new backend or new dependencies — it's wiring screens onto endpoints that are already mounted and tested.
7982
8083---
8184
82_Last updated: 2026-06-13 05:04 UTC_
85_Last updated: 2026-06-13 05:46 UTC_
Modifiedapps/api/package.json+1−0View fileUnifiedSplit
2020 "@alecrae/crypto": "workspace:*",
2121 "@alecrae/db": "workspace:*",
2222 "@alecrae/dns": "workspace:*",
23 "@alecrae/email-parser": "workspace:*",
2324 "@alecrae/mta": "workspace:*",
2425 "@alecrae/reputation": "workspace:*",
2526 "@alecrae/security": "workspace:*",
Addedapps/api/src/lib/received-email-store.ts+144−0View fileUnifiedSplit
1/**
2 * received-email-store — persist mail synced/imported from a connected
3 * EXTERNAL account (Gmail/Outlook/MBOX/EML) into the unified `emails` table.
4 *
5 * Such mail isn't addressed to one of our hosted sending domains, so its
6 * `domainId` is null (the column was made nullable for exactly this). Inserts
7 * are idempotent: a message already stored for the account (same Message-ID)
8 * is skipped, so re-running an import never duplicates.
9 */
10
11import crypto from "node:crypto";
12import { and, eq } from "drizzle-orm";
13import { getDatabase, emails } from "@alecrae/db";
14import type { ParsedEmail } from "@alecrae/email-parser";
15
16export interface ReceivedAddress {
17 address: string;
18 name?: string | null;
19}
20
21export interface ReceivedEmailInput {
22 accountId: string;
23 /** Provenance: "gmail" | "outlook" | "mbox" | "eml". */
24 source: string;
25 from: ReceivedAddress;
26 to: ReceivedAddress[];
27 cc?: ReceivedAddress[];
28 subject: string;
29 textBody?: string | null;
30 htmlBody?: string | null;
31 messageId?: string | null;
32 inReplyTo?: string | null;
33 references?: string[] | null;
34 receivedAt?: Date;
35}
36
37function genId(): string {
38 return crypto.randomUUID().replace(/-/g, "");
39}
40
41function normalizeAddr(a: ReceivedAddress): { address: string; name?: string } {
42 return a.name ? { address: a.address, name: a.name } : { address: a.address };
43}
44
45/**
46 * Insert a received/imported message into `emails`. Returns `{ stored: false }`
47 * when the message was already present (deduped by accountId + Message-ID).
48 */
49export async function storeReceivedEmail(
50 input: ReceivedEmailInput,
51): Promise<{ stored: boolean; id: string | null }> {
52 const db = getDatabase();
53 const realMessageId = input.messageId?.trim();
54 const messageId = realMessageId && realMessageId.length > 0 ? realMessageId : `<${genId()}@import>`;
55
56 // Dedup only when we have a real Message-ID (synthetic ids are unique anyway).
57 if (realMessageId) {
58 const [existing] = await db
59 .select({ id: emails.id })
60 .from(emails)
61 .where(and(eq(emails.accountId, input.accountId), eq(emails.messageId, messageId)))
62 .limit(1);
63 if (existing) return { stored: false, id: existing.id };
64 }
65
66 const id = genId();
67 const now = new Date();
68 const received = input.receivedAt ?? now;
69 const cc = input.cc && input.cc.length > 0 ? input.cc.map(normalizeAddr) : null;
70 const refs = input.references && input.references.length > 0 ? input.references : null;
71
72 await db.insert(emails).values({
73 id,
74 accountId: input.accountId,
75 domainId: null,
76 messageId,
77 fromAddress: input.from.address || "unknown@unknown",
78 fromName: input.from.name ?? null,
79 toAddresses: input.to.map(normalizeAddr),
80 ccAddresses: cc,
81 subject: input.subject?.trim() || "(no subject)",
82 textBody: input.textBody ?? null,
83 htmlBody: input.htmlBody ?? null,
84 inReplyTo: input.inReplyTo ?? null,
85 references: refs,
86 status: "delivered",
87 tags: ["inbox", `import:${input.source}`],
88 source: input.source,
89 metadata: { receivedAt: received.toISOString(), imported: "true" },
90 createdAt: received,
91 updatedAt: now,
92 });
93
94 return { stored: true, id };
95}
96
97/** Map a parsed RFC 5322 message to the store input. */
98export function parsedToReceived(parsed: ParsedEmail, accountId: string, source: string): ReceivedEmailInput {
99 const input: ReceivedEmailInput = {
100 accountId,
101 source,
102 from: { address: parsed.from.address, name: parsed.from.name ?? null },
103 to: parsed.to.map((a) => ({ address: a.address, name: a.name ?? null })),
104 cc: parsed.cc.map((a) => ({ address: a.address, name: a.name ?? null })),
105 subject: parsed.subject,
106 textBody: parsed.textBody ?? null,
107 htmlBody: parsed.htmlBody ?? null,
108 messageId: parsed.messageId || null,
109 inReplyTo: parsed.inReplyTo ?? null,
110 references: [...parsed.references],
111 };
112 if (parsed.date) input.receivedAt = parsed.date;
113 return input;
114}
115
116/**
117 * Split an mbox file into individual raw RFC 5322 messages.
118 *
119 * mbox delimits messages with an envelope line beginning `From ` (the
120 * "From_" postmark), which is NOT part of the message itself and is dropped.
121 * If no postmark is found the whole content is treated as a single message.
122 * Pure — exported for unit tests.
123 */
124export function splitMboxMessages(content: string): string[] {
125 const lines = content.replace(/\r\n/g, "\n").split("\n");
126 const messages: string[] = [];
127 let current: string[] | null = null;
128 let sawPostmark = false;
129
130 for (const line of lines) {
131 if (/^From .+/.test(line)) {
132 sawPostmark = true;
133 if (current && current.length > 0) messages.push(current.join("\n").trim());
134 current = [];
135 } else if (current) {
136 current.push(line);
137 }
138 }
139 if (current && current.length > 0) messages.push(current.join("\n").trim());
140
141 const nonEmpty = messages.filter((m) => m.length > 0);
142 if (!sawPostmark && content.trim().length > 0) return [content.trim()];
143 return nonEmpty;
144}
Modifiedapps/api/src/routes/import.ts+50−20View fileUnifiedSplit
2727 type ImportJob,
2828 type ImportJobProgress,
2929} from "@alecrae/db";
30import { parseEmail } from "@alecrae/email-parser";
31import {
32 storeReceivedEmail,
33 parsedToReceived,
34 splitMboxMessages,
35} from "../lib/received-email-store.js";
3036
3137// ─── Types ───────────────────────────────────────────────────────────────────
3238
229235
230236 // Read file and start parsing
231237 const content = await file.text();
232 runWorker(job.id, () => startMboxImport(job.id, content));
238 runWorker(job.id, () => startMboxImport(job.id, auth.accountId, content));
233239
234240 return c.json({
235241 data: {
262268 skipped: 0,
263269 };
264270
265 // Process EML files (synchronously — small uploads)
271 // Process EML files (synchronously — small uploads). Each .eml is a raw
272 // RFC 5322 message: parse it and store it in the inbox (deduped).
266273 for (const file of files) {
267274 if (file instanceof File && file.name.endsWith(".eml")) {
268275 try {
269 // In production: parse EML with @alecrae/email-parser and store
270 progress.processed++;
276 const raw = await file.text();
277 const parsed = parseEmail(raw);
278 const { stored } = await storeReceivedEmail(
279 parsedToReceived(parsed, auth.accountId, "eml"),
280 );
281 if (stored) progress.processed++;
282 else progress.skipped++;
271283 } catch {
272284 progress.failed++;
273285 }
360372
361373// ─── Import Workers (simplified — production: BullMQ) ────────────────────────
362374
375// Gmail/Outlook history backfill rides on the sync engine, whose message
376// PERSISTENCE is still a stub (see known issue: fetchAndStoreGmailMessage logs
377// instead of writing). Rather than fake-complete an import that stores nothing,
378// these fail honestly until the engine persists. File-based import (MBOX/EML)
379// is fully implemented below and is the supported path today.
380const PROVIDER_BACKFILL_UNAVAILABLE =
381 "Direct mailbox history import isn't available yet. File-based import (MBOX/EML) works today, " +
382 "and your connected account will sync going forward.";
383
363384async function startGmailImport(
364385 jobId: string,
365386 _options: z.infer<typeof GmailImportSchema>,
366387): Promise<void> {
367 await updateJob(jobId, { status: "running" });
368 // In production: use the sync engine to batch-fetch all messages
369 // from the connected Gmail account via API, paginating through results.
370 // Each message gets stored in our DB + indexed for search.
371 await updateJob(jobId, { status: "completed", completedAt: new Date() });
388 await updateJob(jobId, {
389 status: "failed",
390 error: PROVIDER_BACKFILL_UNAVAILABLE,
391 completedAt: new Date(),
392 });
372393}
373394
374395async function startOutlookImport(
375396 jobId: string,
376397 _options: z.infer<typeof OutlookImportSchema>,
377398): Promise<void> {
378 await updateJob(jobId, { status: "running" });
379 // Similar to Gmail: use Graph API delta queries to fetch all messages
380 await updateJob(jobId, { status: "completed", completedAt: new Date() });
399 await updateJob(jobId, {
400 status: "failed",
401 error: PROVIDER_BACKFILL_UNAVAILABLE,
402 completedAt: new Date(),
403 });
381404}
382405
383async function startMboxImport(jobId: string, content: string): Promise<void> {
406async function startMboxImport(
407 jobId: string,
408 accountId: string,
409 content: string,
410): Promise<void> {
384411 await updateJob(jobId, { status: "running" });
385412
386 // Simple MBOX parser: messages are separated by lines starting with "From "
387 const messages = content.split(/^From /gm).filter(Boolean);
413 const rawMessages = splitMboxMessages(content);
388414 const progress: ImportJobProgress = {
389 total: messages.length,
415 total: rawMessages.length,
390416 processed: 0,
391417 failed: 0,
392418 skipped: 0,
393419 };
420 await updateJob(jobId, { progress });
394421
395 for (const _msg of messages) {
422 for (const raw of rawMessages) {
396423 try {
397 // In production: parse each message with @alecrae/email-parser
398 // and store in DB + index for search
399 progress.processed++;
424 const parsed = parseEmail(raw);
425 const { stored } = await storeReceivedEmail(
426 parsedToReceived(parsed, accountId, "mbox"),
427 );
428 if (stored) progress.processed++;
429 else progress.skipped++; // already imported (deduped by Message-ID)
400430 } catch {
401431 progress.failed++;
402432 }
Addedapps/api/tests/import-store.test.ts+64−0View fileUnifiedSplit
1import { describe, it, expect } from "vitest";
2import { parseEmail } from "@alecrae/email-parser";
3import {
4 splitMboxMessages,
5 parsedToReceived,
6} from "../src/lib/received-email-store.js";
7
8const SAMPLE_EML = [
9 "From: Alice Example <alice@example.com>",
10 "To: Bob <bob@example.org>",
11 "Cc: carol@example.net",
12 "Subject: Quarterly numbers",
13 "Message-ID: <msg-123@example.com>",
14 "Date: Mon, 01 Jan 2024 10:00:00 +0000",
15 "",
16 "Hi Bob, the numbers are attached. Thanks!",
17].join("\n");
18
19function mboxWith(...messages: string[]): string {
20 return messages
21 .map((m, i) => `From sender${i}@example.com Mon Jan 01 10:00:00 2024\n${m}`)
22 .join("\n");
23}
24
25describe("splitMboxMessages", () => {
26 it("splits an mbox into individual messages and drops the From_ postmark", () => {
27 const mbox = mboxWith(SAMPLE_EML, SAMPLE_EML.replace("Quarterly numbers", "Second message"));
28 const parts = splitMboxMessages(mbox);
29 expect(parts).toHaveLength(2);
30 for (const p of parts) {
31 expect(p).toContain("Subject:");
32 // The "From " postmark line must not leak into the parsed message.
33 expect(p.startsWith("From ")).toBe(false);
34 }
35 expect(parts[1]).toContain("Second message");
36 });
37
38 it("treats content with no postmark as a single message", () => {
39 const parts = splitMboxMessages(SAMPLE_EML);
40 expect(parts).toHaveLength(1);
41 expect(parts[0]).toContain("Quarterly numbers");
42 });
43
44 it("returns nothing for empty content", () => {
45 expect(splitMboxMessages("")).toEqual([]);
46 expect(splitMboxMessages(" \n ")).toEqual([]);
47 });
48});
49
50describe("parseEmail + parsedToReceived", () => {
51 it("maps a parsed RFC 5322 message to the store input", () => {
52 const parsed = parseEmail(SAMPLE_EML);
53 const input = parsedToReceived(parsed, "acct_1", "eml");
54
55 expect(input.accountId).toBe("acct_1");
56 expect(input.source).toBe("eml");
57 expect(input.from.address).toBe("alice@example.com");
58 expect(input.to[0]?.address).toBe("bob@example.org");
59 expect(input.subject).toBe("Quarterly numbers");
60 expect(input.messageId).toContain("msg-123@example.com");
61 expect(input.textBody ?? "").toContain("the numbers are attached");
62 expect(input.receivedAt).toBeInstanceOf(Date);
63 });
64});
Modifiedapps/web/app/(dashboard)/workspace/page.tsx+148−2View fileUnifiedSplit
2525 domainsApi,
2626 mailboxesApi,
2727 organizationsApi,
28 importApi,
2829 type Domain,
2930 type Mailbox,
3031 type Organization,
3132 type OrgMember,
3233 type OrgInvitation,
3334 type OrgRole,
35 type ImportJobSummary,
3436} from "../../../lib/api";
3537
36type Tab = "mailboxes" | "team";
38type Tab = "mailboxes" | "team" | "import";
3739
3840export default function WorkspacePage(): ReactNode {
3941 const [role, setRole] = useState<string | null>(null);
7678 [
7779 { id: "mailboxes", label: "Mailboxes" },
7880 { id: "team", label: "Team" },
81 { id: "import", label: "Import" },
7982 ] as { id: Tab; label: string }[]
8083 ).map((t) => (
8184 <Box
9598 ))}
9699 </Box>
97100
98 {tab === "mailboxes" ? <MailboxesSection /> : <TeamSection />}
101 {tab === "mailboxes" && <MailboxesSection />}
102 {tab === "team" && <TeamSection />}
103 {tab === "import" && <ImportSection />}
99104 </Box>
100105 );
101106}
605610 </Box>
606611 );
607612}
613
614// ─── Import ──────────────────────────────────────────────────────────────────
615
616function ImportSection(): ReactNode {
617 const [jobs, setJobs] = useState<ImportJobSummary[] | null>(null);
618 const [busy, setBusy] = useState(false);
619 const [error, setError] = useState<string | null>(null);
620 const [ok, setOk] = useState<string | null>(null);
621
622 const loadJobs = useCallback(() => {
623 importApi
624 .jobs()
625 .then((res) => setJobs(res.data))
626 .catch((e: unknown) => setError(errMsg(e)));
627 }, []);
628
629 useEffect(() => {
630 loadJobs();
631 }, [loadJobs]);
632
633 const onMbox = async (file: File): Promise<void> => {
634 setBusy(true);
635 setError(null);
636 setOk(null);
637 try {
638 await importApi.mbox(file);
639 setOk(`Import of ${file.name} started — progress appears below.`);
640 // The MBOX worker runs in the background; give it a moment, then refresh.
641 setTimeout(loadJobs, 1500);
642 } catch (e) {
643 setError(errMsg(e));
644 } finally {
645 setBusy(false);
646 }
647 };
648
649 const onEml = async (files: File[]): Promise<void> => {
650 setBusy(true);
651 setError(null);
652 setOk(null);
653 try {
654 const res = await importApi.eml(files);
655 const p = res.data.progress;
656 setOk(`Imported ${p.processed}, skipped ${p.skipped}, failed ${p.failed}.`);
657 loadJobs();
658 } catch (e) {
659 setError(errMsg(e));
660 } finally {
661 setBusy(false);
662 }
663 };
664
665 return (
666 <Box className="space-y-6">
667 <Card>
668 <CardContent>
669 <Text variant="heading-sm" className="mb-2">
670 Import your mail
671 </Text>
672 <Text variant="body-md" muted className="mb-4">
673 Bring in an existing mailbox export. Drop in an <Text as="span" variant="body-md" className="font-medium">.mbox</Text> file
674 (Apple Mail, Thunderbird, Google Takeout) or one or more <Text as="span" variant="body-md" className="font-medium">.eml</Text> files.
675 Re-importing the same messages is safe — duplicates are skipped.
676 </Text>
677
678 <Box className="flex flex-wrap gap-6">
679 <Box>
680 <Text variant="caption" muted className="mb-1 block">
681 MBOX file
682 </Text>
683 <Box
684 as="input"
685 type="file"
686 accept=".mbox,.mbx"
687 disabled={busy}
688 aria-label="Upload MBOX file"
689 onChange={(e) => {
690 const f = (e.target as HTMLInputElement).files?.[0];
691 if (f) void onMbox(f);
692 (e.target as HTMLInputElement).value = "";
693 }}
694 className="text-body-sm text-content-secondary"
695 />
696 </Box>
697 <Box>
698 <Text variant="caption" muted className="mb-1 block">
699 EML file(s)
700 </Text>
701 <Box
702 as="input"
703 type="file"
704 accept=".eml"
705 multiple
706 disabled={busy}
707 aria-label="Upload EML files"
708 onChange={(e) => {
709 const files = Array.from((e.target as HTMLInputElement).files ?? []);
710 if (files.length > 0) void onEml(files);
711 (e.target as HTMLInputElement).value = "";
712 }}
713 className="text-body-sm text-content-secondary"
714 />
715 </Box>
716 </Box>
717 {error && <Notice tone="error">{error}</Notice>}
718 {ok && <Notice tone="success">{ok}</Notice>}
719 </CardContent>
720 </Card>
721
722 <Card>
723 <CardContent>
724 <Box className="flex items-center justify-between mb-3">
725 <Text variant="heading-sm">Import jobs</Text>
726 <Button variant="ghost" size="sm" onClick={loadJobs}>
727 Refresh
728 </Button>
729 </Box>
730 {jobs === null && <Notice tone="info">Loading…</Notice>}
731 {jobs && jobs.length === 0 && <Notice tone="info">No imports yet.</Notice>}
732 <Box className="divide-y divide-border">
733 {jobs?.map((j) => (
734 <Box key={j.jobId} className="flex items-center justify-between py-2 gap-3">
735 <Box className="min-w-0">
736 <Text variant="body-sm" className="font-medium capitalize">
737 {j.source}
738 </Text>
739 <Text variant="caption" muted className="block">
740 {j.progress.processed} imported · {j.progress.skipped} skipped · {j.progress.failed} failed
741 </Text>
742 </Box>
743 <Text as="span" variant="caption" className="capitalize text-content-secondary">
744 {j.status}
745 </Text>
746 </Box>
747 ))}
748 </Box>
749 </CardContent>
750 </Card>
751 </Box>
752 );
753}
Modifiedapps/web/lib/api.ts+58−0View fileUnifiedSplit
699699 },
700700};
701701
702// ─── Import (mailbox migration) ──────────────────────────────────────────────
703
704/** Multipart upload with Bearer auth + one silent refresh-and-retry on 401.
705 * Kept separate from apiFetch because FormData must NOT carry a JSON
706 * Content-Type (the browser sets the multipart boundary itself). */
707async function uploadFetch<T>(path: string, body: FormData, retried = false): Promise<T> {
708 const token = getAccessToken();
709 const res = await fetch(`${API_BASE}${path}`, {
710 method: "POST",
711 headers: token ? { Authorization: `Bearer ${token}` } : {},
712 body,
713 });
714
715 if (res.status === 401 && !retried && getRefreshToken()) {
716 const fresh = await refreshSession();
717 if (fresh) return uploadFetch<T>(path, body, true);
718 redirectToLogin();
719 }
720
721 if (!res.ok) {
722 const errorBody = (await res.json().catch(() => null)) as ApiError | null;
723 throw new Error(errorBody?.error?.message ?? `Upload failed: ${res.status}`);
724 }
725 return res.json() as Promise<T>;
726}
727
728export interface ImportProgress {
729 total: number;
730 processed: number;
731 failed: number;
732 skipped: number;
733}
734
735export interface ImportJobSummary {
736 jobId: string;
737 source: string;
738 status: string;
739 progress: ImportProgress;
740 startedAt: string;
741 completedAt: string | null;
742}
743
744export const importApi = {
745 mbox(file: File): Promise<{ data: { jobId: string; status: string } }> {
746 const fd = new FormData();
747 fd.append("file", file);
748 return uploadFetch("/v1/import/mbox", fd);
749 },
750 eml(files: File[]): Promise<{ data: { jobId: string; status: string; progress: ImportProgress } }> {
751 const fd = new FormData();
752 for (const f of files) fd.append("files", f);
753 return uploadFetch("/v1/import/eml", fd);
754 },
755 jobs(): Promise<{ data: ImportJobSummary[] }> {
756 return apiFetch("/v1/import/jobs");
757 },
758};
759
702760// ─── Messages ──────────────────────────────────────────────────────────────
703761
704762export const messagesApi = {
Modifiedbun.lock+1−0View fileUnifiedSplit
4848 "@alecrae/crypto": "workspace:*",
4949 "@alecrae/db": "workspace:*",
5050 "@alecrae/dns": "workspace:*",
51 "@alecrae/email-parser": "workspace:*",
5152 "@alecrae/mta": "workspace:*",
5253 "@alecrae/reputation": "workspace:*",
5354 "@alecrae/security": "workspace:*",
Addedpackages/db/src/migrations/0003_lucky_squirrel_girl.sql+2−0View fileUnifiedSplit
1ALTER TABLE "emails" ALTER COLUMN "domain_id" DROP NOT NULL;--> statement-breakpoint
2ALTER TABLE "emails" ADD COLUMN "source" text;
\ No newline at end of file
Addedpackages/db/src/migrations/meta/0003_snapshot.json+22730−0View fileUnifiedSplit
Large file (22,731 lines). Load full file
Modifiedpackages/db/src/migrations/meta/_journal.json+7−0View fileUnifiedSplit
2222 "when": 1781177896919,
2323 "tag": "0002_goofy_scarecrow",
2424 "breakpoints": true
25 },
26 {
27 "idx": 3,
28 "version": "7",
29 "when": 1781328721917,
30 "tag": "0003_lucky_squirrel_girl",
31 "breakpoints": true
2532 }
2633 ]
2734}
\ No newline at end of file
Modifiedpackages/db/src/schema/emails.ts+12−3View fileUnifiedSplit
5353 accountId: text("account_id")
5454 .notNull()
5555 .references(() => accounts.id, { onDelete: "cascade" }),
56 domainId: text("domain_id")
57 .notNull()
58 .references(() => domains.id, { onDelete: "restrict" }),
56 // Nullable: mail synced/imported from a connected EXTERNAL account
57 // (Gmail/Outlook/MBOX/EML) is addressed to an external mailbox, not one of
58 // our hosted sending domains, so it has no domainId. Mail sent or received
59 // through a hosted domain still sets it.
60 domainId: text("domain_id").references(() => domains.id, {
61 onDelete: "restrict",
62 }),
5963
6064 // Envelope
6165 messageId: text("message_id").notNull(),
8690 // Status
8791 status: emailStatusEnum("status").notNull().default("queued"),
8892
93 /** Provenance of the message. "outbound" for mail we send, "inbound" for
94 * mail received via our MTA, or an import/sync source for connected
95 * external accounts ("gmail"/"outlook"/"mbox"/"eml"). Null on legacy rows. */
96 source: text("source"),
97
8998 // Metadata
9099 tags: jsonb("tags").notNull().$type<string[]>().default([]),
91100 metadata: jsonb("metadata").$type<Record<string, string>>(),
Addedpackages/email-parser/src/index.ts+14−0View fileUnifiedSplit
1/**
2 * @alecrae/email-parser — RFC 5322 email parsing + a structured document model.
3 *
4 * Public entry point (package.json `main` → dist/index.js).
5 */
6
7export { parseEmail, parseAddressList, decodeEncodedWords } from "./parser.js";
8export type {
9 ParsedEmail,
10 ParsedAddress,
11 ParsedAttachment,
12 MimePart,
13 EmailBuildOptions,
14} from "./types.js";
015
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts