From 64843d00fc1c091b33bc0cae7fcf977b0198d0e6 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:19:51 +0200 Subject: [PATCH 01/15] docs: add mail integration design spec (DEA-4688) Design for building email access into Shellgate using ImapFlow + Nodemailer, with new email target type, IMAP/SMTP auth, 8 MCP tools, and tabbed dashboard UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-06-09-mail-integration-design.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-09-mail-integration-design.md diff --git a/docs/superpowers/specs/2026-06-09-mail-integration-design.md b/docs/superpowers/specs/2026-06-09-mail-integration-design.md new file mode 100644 index 0000000..c7b4f77 --- /dev/null +++ b/docs/superpowers/specs/2026-06-09-mail-integration-design.md @@ -0,0 +1,294 @@ +# Mail Integration Design + +**Date:** 2026-06-09 +**Linear:** DEA-4688 + +## Goal + +Give Shellgate agents full email access — search, read, send, draft, manage folders and flags — via a unified mail service built into Shellgate. No external services required. + +## Decision: Build in Shellgate with ImapFlow + Nodemailer + +**Rejected alternatives:** +- **EmailEngine** — $995/year, overkill for our usage +- **IMAP API (open-source fork)** — unmaintained, extra infra to run +- **Direct IMAP proxy in gateway** — IMAP is stateful TCP, doesn't fit HTTP proxy model; connect-per-request without pooling is too slow without a proper abstraction + +**Chosen approach:** New `mail` service inside Shellgate using ImapFlow (IMAP) and Nodemailer (SMTP). These are the same battle-tested libraries that power EmailEngine. Connect-per-request in V1, connection pooling as later optimization if needed. + +## Data Model + +### Target type `email` + +The `targets` table gets a new type value `email`. Email targets store IMAP and SMTP server config in the existing `config` JSONB column, plus a dedicated `email` field for the mailbox address. + +``` +targets + type: "email" + slug: "info-at-deal" + email: "info@deal.nl" ← new column, required for email targets + config: { + imap: { host, port, secure }, ← secure: true = SSL/TLS, false = STARTTLS/none + smtp: { host, port, secure } + } +``` + +### Auth method type `imap_smtp` + +New auth method type for email credentials: + +``` +target_auth_methods + type: "imap_smtp" + credential: { + username: "info@deal.nl", + password: "app-password" + } +``` + +Username and password are shared between IMAP and SMTP (standard for all providers). + +### Permissions + +Existing `token_permissions` model — no changes needed. A token gets access to specific email targets the same way it gets access to API/SSH targets. + +### Schema changes + +- Add `email` column to `targets` table (nullable, required when type = `email`) +- Add `imap_smtp` to auth method type enum +- Migration file generated via `npm run db:generate` + +## Routes + +New route tree `src/routes/mail/[target]/` using the same auth flow as gateway routes (`requireBearer` + permission check). Message `[id]` in all routes refers to IMAP UID (stable across sessions), not sequence numbers. + +| Route | Method | Purpose | +|---|---|---| +| `/mail/[target]/search` | POST | Search emails by query, folder, limit | +| `/mail/[target]/message/[id]` | GET | Read full email (body, headers, attachment metadata) | +| `/mail/[target]/message/[id]/attachment/[partId]` | GET | Download specific attachment by MIME part ID | +| `/mail/[target]/send` | POST | Send email (approval required) | +| `/mail/[target]/draft` | POST | Create draft in Drafts folder | +| `/mail/[target]/folders` | GET | List all folders/labels | +| `/mail/[target]/move` | POST | Move message to another folder | +| `/mail/[target]/flag` | POST | Set/unset flags (read, starred, etc.) | + +### Request/response shapes + +**POST /mail/[target]/search** +```json +// Request +{ + "folder": "INBOX", + "query": { "from": "klant@example.com", "since": "2026-06-01", "subject": "factuur" }, + "limit": 20 +} +// Response +[ + { "id": "123", "uid": 456, "from": "klant@example.com", "to": ["info@deal.nl"], + "subject": "Factuur maart", "date": "2026-06-01T10:00:00Z", + "flags": ["\\Seen"], "hasAttachments": true } +] +``` + +**GET /mail/[target]/message/[id]** +```json +// Response +{ + "id": "123", "uid": 456, + "from": "klant@example.com", "to": ["info@deal.nl"], "cc": [], + "subject": "Factuur maart", "date": "2026-06-01T10:00:00Z", + "text": "Platte tekst body", "html": "

HTML body

", + "flags": ["\\Seen"], + "attachments": [ + { "partId": "2", "filename": "factuur-maart.pdf", "contentType": "application/pdf", "size": 84210 } + ] +} +``` + +**GET /mail/[target]/message/[id]/attachment/[partId]** +- Returns raw binary with `Content-Type` and `Content-Disposition` headers +- Same pattern as `api_download` for Linear uploads + +**POST /mail/[target]/send** +```json +// Request +{ + "to": ["klant@example.com"], + "cc": [], + "bcc": [], + "subject": "Re: Bestelling #123", + "text": "Platte tekst", + "html": "

HTML body

", + "inReplyTo": "" +} +// Response +{ "messageId": "" } +``` + +**POST /mail/[target]/draft** +```json +// Request +{ + "to": ["klant@example.com"], + "subject": "Concept mail", + "text": "Dit is een draft" +} +// Response +{ "uid": 789 } +``` + +**GET /mail/[target]/folders** +```json +// Response +[ + { "path": "INBOX", "name": "Inbox", "specialUse": "\\Inbox", "delimiter": "/" }, + { "path": "Drafts", "name": "Drafts", "specialUse": "\\Drafts", "delimiter": "/" }, + { "path": "Sent", "name": "Sent", "specialUse": "\\Sent", "delimiter": "/" } +] +``` + +**POST /mail/[target]/move** +```json +{ "id": "123", "from": "INBOX", "to": "Archive" } +``` + +**POST /mail/[target]/flag** +```json +{ "id": "123", "folder": "INBOX", "add": ["\\Seen"], "remove": ["\\Flagged"] } +``` + +### Approval flow for send + +`mail_send` uses the same approval pattern as guarded `api_request` calls. First call returns `{ "status": "approval_required", "reason": "About to send email to klant@example.com" }`. Agent must re-call with `approved: true` after user confirms. + +## MCP Tools + +| Tool | Route | Description | +|---|---|---| +| `mail_search` | POST /search | Search emails in a mailbox | +| `mail_read` | GET /message/[id] | Read full email message | +| `mail_attachment` | GET /message/[id]/attachment/[partId] | Download email attachment | +| `mail_send` | POST /send | Send email (requires approval) | +| `mail_draft` | POST /draft | Create draft email | +| `mail_folders` | GET /folders | List mailbox folders | +| `mail_move` | POST /move | Move email to folder | +| `mail_flag` | POST /flag | Set/unset email flags | + +## Service Layer + +New service: `src/lib/server/services/mail.ts` + +### Connection strategy + +Connect-per-request in V1: +- ImapFlow: connect → authenticate → action → disconnect +- Nodemailer: createTransport → sendMail → close + +No persistent connection pool. IMAP connections timeout after inactivity anyway, and agent usage patterns are low-frequency (not 100 req/sec). Pool can be added later as optimization. + +### Service functions + +``` +search(config, auth, { folder, query, limit }) + → ImapFlow: connect → openBox → search → fetch headers → disconnect + +getMessage(config, auth, { id, folder }) + → ImapFlow: connect → openBox → fetch full message → disconnect + +getAttachment(config, auth, { id, partId, folder }) + → ImapFlow: connect → openBox → fetch MIME part → disconnect + +send(config, auth, { to, cc, bcc, subject, text, html, inReplyTo }) + → Nodemailer: createTransport(smtp config) → sendMail → close + +createDraft(config, auth, { to, subject, text, html }) + → Nodemailer: build MIME message + → ImapFlow: connect → append to Drafts folder → disconnect + +listFolders(config, auth) + → ImapFlow: connect → list → disconnect + +moveMessage(config, auth, { id, from, to }) + → ImapFlow: connect → openBox → move → disconnect + +flagMessage(config, auth, { id, folder, add, remove }) + → ImapFlow: connect → openBox → store flags → disconnect +``` + +### Error handling + +| Error | HTTP status | Message | +|---|---|---| +| Connection failed | 502 | "Could not connect to IMAP/SMTP server" | +| Auth failed | 401 | "IMAP/SMTP authentication failed" | +| Timeout | 504 | "IMAP operation timed out" (30s default) | +| Message not found | 404 | "Message not found" | +| Folder not found | 404 | "Folder not found" | + +### Audit logging + +Every mail action logged to `audit_logs` with: +- `type: "mail"` +- `action: "search" | "read" | "send" | "draft" | "folders" | "move" | "flag" | "attachment"` +- `targetSlug` +- `tokenId` + +## Dashboard UI + +### Target list — tabbed view + +`/targets` page changes from flat list to tabbed layout using shadcn-svelte `Tabs`: + +- `Tabs.Root` with `defaultValue="api"` +- Three `Tabs.Trigger`: **API (12)**, **SSH (4)**, **Email (3)** — count via `Badge` component +- `Tabs.Content` per type with filtered target list +- Existing data, no extra queries — counts from `.filter()` on loaded targets + +### Email target form + +On `/targets/new` and `/targets/[slug]` when type is `email`: + +**Target fields:** +- Name, slug (existing) +- Email address (new, required for email type) +- IMAP: host, port, secure (SSL/TLS | STARTTLS | None) +- SMTP: host, port, secure (SSL/TLS | STARTTLS | None) + +**Auth method (`imap_smtp`):** +- Username/email +- Password +- **Test connection** button — attempts IMAP login + SMTP login, shows success or error + +### Permissions, audit logs + +No UI changes needed — email targets appear in existing permission checkboxes and audit log viewer. + +## Bootstrap Integration + +Email targets appear in the existing `targets` array with the `email` field included: + +```json +{ + "targets": [ + { "slug": "linear-api", "type": "api", "description": "Linear API" }, + { "slug": "deal-nl-server", "type": "ssh", "description": "Deal.nl server" }, + { "slug": "info-at-deal", "type": "email", "email": "info@deal.nl", "description": "Hoofdmailbox Deal.nl" } + ] +} +``` + +No config or credentials exposed in bootstrap. Agents see slug, type, email address, and description. + +The SessionStart hook picks up email targets automatically — no separate changes needed. + +## Out of Scope (V1) + +- Connection pooling (optimize later if needed) +- Gmail OAuth2 / Microsoft Graph API backends (V2 — add as alternative auth types behind the same service interface) +- Webhooks/push notifications for new mail +- Full-text search indexing (rely on IMAP server-side search) +- Attachments on send/draft +- Provider preset buttons in dashboard (Gmail, MS365 quick-fill) +- Mailbox browser in dashboard +- Per-folder permissions From 756f195566be3ead32f294e6ded86e1b2c34015a Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:22:02 +0200 Subject: [PATCH 02/15] docs: fix spec issues found in review (DEA-4688) Address review findings: fix bootstrap field names (name not description), add EmailConfig type union, explicit audit type extension, hardcoded approval for mail_send, hooks.server.ts auth bypass, npm dependencies, and files-to-modify checklist. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-06-09-mail-integration-design.md | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-06-09-mail-integration-design.md b/docs/superpowers/specs/2026-06-09-mail-integration-design.md index c7b4f77..2bc3876 100644 --- a/docs/superpowers/specs/2026-06-09-mail-integration-design.md +++ b/docs/superpowers/specs/2026-06-09-mail-integration-design.md @@ -55,9 +55,18 @@ Existing `token_permissions` model — no changes needed. A token gets access to ### Schema changes - Add `email` column to `targets` table (nullable, required when type = `email`) -- Add `imap_smtp` to auth method type enum +- Extend `targets.type` TypeScript union from `"api" | "ssh"` to `"api" | "ssh" | "email"` +- Create `EmailConfig` type and update `config` column type to `SshConfig | EmailConfig | null` +- Add `"imap_smtp"` to `VALID_TYPES` in `auth-methods.ts` + new `credentialHint` branch +- Add `"mail"` to audit log `type` union in schema and `audit.ts` service - Migration file generated via `npm run db:generate` +### Dependencies + +```bash +npm install imapflow nodemailer @types/nodemailer +``` + ## Routes New route tree `src/routes/mail/[target]/` using the same auth flow as gateway routes (`requireBearer` + permission check). Message `[id]` in all routes refers to IMAP UID (stable across sessions), not sequence numbers. @@ -160,7 +169,7 @@ New route tree `src/routes/mail/[target]/` using the same auth flow as gateway r ### Approval flow for send -`mail_send` uses the same approval pattern as guarded `api_request` calls. First call returns `{ "status": "approval_required", "reason": "About to send email to klant@example.com" }`. Agent must re-call with `approved: true` after user confirms. +`mail_send` hardcodes an approval requirement (does NOT use the guard engine, which is designed for HTTP proxy requests). First call always returns `{ "status": "approval_required", "reason": "About to send email to klant@example.com" }`. Agent must re-call with `approved: true` after user confirms. ## MCP Tools @@ -271,17 +280,37 @@ Email targets appear in the existing `targets` array with the `email` field incl ```json { "targets": [ - { "slug": "linear-api", "type": "api", "description": "Linear API" }, - { "slug": "deal-nl-server", "type": "ssh", "description": "Deal.nl server" }, - { "slug": "info-at-deal", "type": "email", "email": "info@deal.nl", "description": "Hoofdmailbox Deal.nl" } + { "slug": "linear-api", "type": "api", "name": "Linear API" }, + { "slug": "deal-nl-server", "type": "ssh", "name": "Deal.nl server" }, + { "slug": "info-at-deal", "type": "email", "email": "info@deal.nl", "name": "Hoofdmailbox Deal.nl" } ] } ``` -No config or credentials exposed in bootstrap. Agents see slug, type, email address, and description. +No config or credentials exposed in bootstrap. Agents see slug, type, name, and email address (for email targets only). The SessionStart hook picks up email targets automatically — no separate changes needed. +### Auth bypass + +`hooks.server.ts` must add `/mail/` to the list of path prefixes that skip dashboard session auth (alongside `/api/`, `/gateway/`, `/ssh/`, `/discovery/`). + +## Files to Modify + +| File | Change | +|---|---| +| `src/lib/server/db/schema.ts` | Add `email` column, extend type union, add `EmailConfig` type, extend audit type | +| `src/lib/server/services/auth-methods.ts` | Add `imap_smtp` to `VALID_TYPES`, new `credentialHint` branch | +| `src/lib/server/services/audit.ts` | Extend type union with `"mail"` | +| `src/lib/server/services/mail.ts` | **New** — all IMAP/SMTP logic | +| `src/routes/mail/[target]/` | **New** — all mail route handlers | +| `src/lib/server/mcp/server.ts` | Register 8 new mail tools | +| `src/lib/server/mcp/tools/mail-*.ts` | **New** — MCP tool handlers | +| `src/routes/bootstrap/` | Include `email` field for email targets | +| `src/hooks.server.ts` | Add `/mail/` to auth bypass list | +| `src/routes/(app)/targets/` | Tabbed view, email target form, test connection | +| `package.json` | Add `imapflow`, `nodemailer`, `@types/nodemailer` | + ## Out of Scope (V1) - Connection pooling (optimize later if needed) From bd2257a17bd0df45819c1ded59dddf6bd1620d35 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:30:20 +0200 Subject: [PATCH 03/15] docs: add mail integration implementation plan (DEA-4688) 12-task plan covering schema, service, routes, MCP tools, dashboard UI, bootstrap, and manual integration testing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../plans/2026-06-09-mail-integration.md | 1787 +++++++++++++++++ 1 file changed, 1787 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-09-mail-integration.md diff --git a/docs/superpowers/plans/2026-06-09-mail-integration.md b/docs/superpowers/plans/2026-06-09-mail-integration.md new file mode 100644 index 0000000..c077321 --- /dev/null +++ b/docs/superpowers/plans/2026-06-09-mail-integration.md @@ -0,0 +1,1787 @@ +# Mail Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give Shellgate agents full email access (search, read, send, draft, folders, move, flag, attachments) via ImapFlow + Nodemailer built directly into Shellgate. + +**Architecture:** New target type `email` with IMAP/SMTP config stored in existing `config` JSONB column. New `mail` service handles all IMAP/SMTP connections. New `/mail/[target]/` routes and 8 MCP tools expose the functionality. Dashboard gets tabbed target list and email-specific forms. + +**Tech Stack:** ImapFlow (IMAP), Nodemailer (SMTP), SvelteKit routes, Drizzle ORM, shadcn-svelte Tabs + +**Spec:** `docs/superpowers/specs/2026-06-09-mail-integration-design.md` + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `src/lib/server/db/schema.ts` | Add `EmailConfig` type, `email` column, extend type unions | +| `src/lib/server/services/targets.ts` | Extend create/update for email type + validation | +| `src/lib/server/services/auth-methods.ts` | Add `imap_smtp` type + credential hint | +| `src/lib/server/services/audit.ts` | Add `"mail"` to type union | +| `src/lib/server/services/mail.ts` | **New** — all IMAP/SMTP logic (search, read, send, draft, folders, move, flag, attachment) | +| `src/routes/mail/[target]/search/+server.ts` | **New** — POST search endpoint | +| `src/routes/mail/[target]/message/[id]/+server.ts` | **New** — GET read endpoint | +| `src/routes/mail/[target]/message/[id]/attachment/[partId]/+server.ts` | **New** — GET attachment endpoint | +| `src/routes/mail/[target]/send/+server.ts` | **New** — POST send endpoint | +| `src/routes/mail/[target]/draft/+server.ts` | **New** — POST draft endpoint | +| `src/routes/mail/[target]/folders/+server.ts` | **New** — GET folders endpoint | +| `src/routes/mail/[target]/move/+server.ts` | **New** — POST move endpoint | +| `src/routes/mail/[target]/flag/+server.ts` | **New** — POST flag endpoint | +| `src/lib/server/mcp/tools/mail.ts` | **New** — all 8 MCP mail tool handlers | +| `src/lib/server/mcp/server.ts` | Register 8 mail tools + import | +| `src/lib/server/mcp/tools/bootstrap.ts` | Add `email` field for email targets | +| `src/hooks.server.ts` | Add `/mail/` to auth bypass list | +| `src/routes/(app)/targets/+page.server.ts` | Add email type to create action | +| `src/routes/(app)/targets/+page.svelte` | Tabbed target list + email create form | +| `src/routes/(app)/targets/[slug]/+page.server.ts` | Add email config update action | +| `src/routes/(app)/targets/[slug]/+page.svelte` | Email config display + edit | + +--- + +### Task 1: Install dependencies and extend schema + +**Files:** +- Modify: `package.json` +- Modify: `src/lib/server/db/schema.ts:33-53,107-136` +- Modify: `src/lib/server/services/audit.ts:4-17` + +- [ ] **Step 1: Install ImapFlow and Nodemailer** + +```bash +npm install imapflow nodemailer +npm install -D @types/nodemailer +``` + +- [ ] **Step 2: Add EmailConfig type and extend schema** + +In `src/lib/server/db/schema.ts`, after the `SshConfig` type (line 37), add: + +```typescript +export type EmailConfig = { + imap: { host: string; port: number; secure: boolean }; + smtp: { host: string; port: number; secure: boolean }; +}; +``` + +Update line 43 — change the type union: + +```typescript +type: text("type").notNull().$type<"api" | "ssh" | "email">(), +``` + +Update line 45 — extend config type: + +```typescript +config: jsonb("config").$type(), +``` + +Add `email` column after `config` (after line 45): + +```typescript +email: varchar("email", { length: 255 }), +``` + +Update line 119 — extend audit log type: + +```typescript +type: text("type").notNull().$type<"gateway" | "ssh" | "vault" | "mail">(), +``` + +- [ ] **Step 3: Update audit service type** + +In `src/lib/server/services/audit.ts`, update line 9: + +```typescript +type: "gateway" | "ssh" | "vault" | "mail"; +``` + +- [ ] **Step 4: Generate migration** + +```bash +npm run db:generate +``` + +Verify a new SQL file appears in `drizzle/` that adds the `email` column. + +- [ ] **Step 5: Commit** + +```bash +git add package.json package-lock.json src/lib/server/db/schema.ts src/lib/server/services/audit.ts drizzle/ +git commit -m "feat(mail): extend schema with email target type, EmailConfig, and email column" +``` + +--- + +### Task 2: Extend targets service for email type + +**Files:** +- Modify: `src/lib/server/services/targets.ts:55-109` + +- [ ] **Step 1: Add EmailConfig import and validation function** + +In `src/lib/server/services/targets.ts`, add import at line 4: + +```typescript +import type { SshConfig, EmailConfig } from "../db/schema"; +``` + +(Remove the existing `import type { SshConfig } from "../db/schema";` on line 4.) + +After the `validateSshConfig` function (after line 67), add: + +```typescript +function validateEmailConfig(config: unknown): EmailConfig { + if (!config || typeof config !== "object") { + throw new Error("Email config is required for email targets"); + } + const c = config as Record; + + function validateServer(key: string): { host: string; port: number; secure: boolean } { + const server = c[key]; + if (!server || typeof server !== "object") { + throw new Error(`${key} config is required`); + } + const s = server as Record; + const host = typeof s.host === "string" ? s.host.trim() : ""; + if (!host) throw new Error(`${key} host is required`); + const port = typeof s.port === "number" ? s.port : 993; + if (port < 1 || port > 65535) throw new Error(`${key} port must be between 1 and 65535`); + const secure = typeof s.secure === "boolean" ? s.secure : true; + return { host, port, secure }; + } + + return { + imap: validateServer("imap"), + smtp: validateServer("smtp"), + }; +} +``` + +- [ ] **Step 2: Extend createTarget for email type** + +Update the `createTarget` function signature (line 69): + +```typescript +export async function createTarget(data: { + name: string; + type: "api" | "ssh" | "email"; + base_url?: string | null; + config?: SshConfig | EmailConfig | null; + email?: string | null; +}) { +``` + +Update the type validation (line 78): + +```typescript +if (data.type !== "api" && data.type !== "ssh" && data.type !== "email") { + throw new Error("type must be 'api', 'ssh', or 'email'"); +} +``` + +Add email branch after the SSH branch (after line 92): + +```typescript +} else if (data.type === "email") { + config = validateEmailConfig(data.config); + if (!data.email?.trim()) throw new Error("email address is required for email targets"); +} +``` + +Update the insert values (line 100) to include email: + +```typescript +.values({ name, slug, type: data.type, baseUrl, config, email: data.type === "email" ? data.email!.trim() : null }) +``` + +- [ ] **Step 3: Extend updateTarget for email type** + +Update the `updateTarget` function signature (line 111): + +```typescript +export async function updateTarget( + id: string, + data: { + name?: string; + type?: "api" | "ssh" | "email"; + base_url?: string | null; + config?: SshConfig | EmailConfig | null; + enabled?: boolean; + email?: string | null; + }, +) { +``` + +Update the type validation (line 141): + +```typescript +if (data.type !== "api" && data.type !== "ssh" && data.type !== "email") { + throw new Error("type must be 'api', 'ssh', or 'email'"); +} +``` + +In the config update block (line 159-165), add email config validation: + +```typescript +if (data.config !== undefined) { + if (data.config === null) { + updates.config = null; + } else if ("imap" in data.config && "smtp" in data.config) { + updates.config = validateEmailConfig(data.config); + } else { + updates.config = validateSshConfig(data.config); + } +} + +if (data.email !== undefined) { + updates.email = data.email ? data.email.trim() : null; +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/services/targets.ts +git commit -m "feat(mail): extend targets service with email type, validation, and email field" +``` + +--- + +### Task 3: Extend auth methods for imap_smtp type + +**Files:** +- Modify: `src/lib/server/services/auth-methods.ts:5-56` + +- [ ] **Step 1: Add imap_smtp to VALID_TYPES** + +In `src/lib/server/services/auth-methods.ts`, update line 5: + +```typescript +const VALID_TYPES = ["bearer", "basic", "custom_header", "query_param", "ssh_key", "jwt_es256", "oauth2_refresh_token", "json_body", "imap_smtp"]; +``` + +- [ ] **Step 2: Add credentialHint branch for imap_smtp** + +In `computeCredentialHint`, before the default fallback (before line 55), add: + +```typescript +if (type === "imap_smtp") { + try { + const config = JSON.parse(credential); + if (config.username) return `IMAP/SMTP ••• ${config.username}`; + return "IMAP/SMTP credentials"; + } catch { + return "IMAP/SMTP (invalid config)"; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/server/services/auth-methods.ts +git commit -m "feat(mail): add imap_smtp auth method type with credential hint" +``` + +--- + +### Task 4: Build mail service + +**Files:** +- Create: `src/lib/server/services/mail.ts` + +- [ ] **Step 1: Create mail service with IMAP connection helper** + +Create `src/lib/server/services/mail.ts`: + +```typescript +import { ImapFlow } from "imapflow"; +import nodemailer from "nodemailer"; +import type { EmailConfig } from "../db/schema"; + +interface MailAuth { + username: string; + password: string; +} + +function createImapClient(config: EmailConfig, auth: MailAuth): ImapFlow { + return new ImapFlow({ + host: config.imap.host, + port: config.imap.port, + secure: config.imap.secure, + auth: { user: auth.username, pass: auth.password }, + logger: false, + }); +} + +function createSmtpTransport(config: EmailConfig, auth: MailAuth) { + return nodemailer.createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: config.smtp.secure, + auth: { user: auth.username, pass: auth.password }, + }); +} + +function parseCredential(credential: string): MailAuth { + const parsed = JSON.parse(credential); + return { username: parsed.username, password: parsed.password }; +} + +export async function listFolders(config: EmailConfig, credential: string) { + const auth = parseCredential(credential); + const client = createImapClient(config, auth); + try { + await client.connect(); + const folders = await client.list(); + return folders.map((f) => ({ + path: f.path, + name: f.name, + specialUse: f.specialUse ?? null, + delimiter: f.delimiter, + })); + } finally { + await client.logout().catch(() => {}); + } +} + +export async function search( + config: EmailConfig, + credential: string, + params: { folder?: string; query?: Record; limit?: number }, +) { + const auth = parseCredential(credential); + const client = createImapClient(config, auth); + const folder = params.folder || "INBOX"; + const limit = params.limit || 20; + + try { + await client.connect(); + const lock = await client.getMailboxLock(folder); + try { + const searchCriteria: Record = {}; + if (params.query?.from) searchCriteria.from = params.query.from; + if (params.query?.to) searchCriteria.to = params.query.to; + if (params.query?.subject) searchCriteria.subject = params.query.subject; + if (params.query?.since) searchCriteria.since = params.query.since; + if (params.query?.before) searchCriteria.before = params.query.before; + if (params.query?.text) searchCriteria.body = params.query.text; + + const hasSearch = Object.keys(searchCriteria).length > 0; + const uids = hasSearch + ? await client.search(searchCriteria, { uid: true }) + : await client.search({ all: true }, { uid: true }); + + const limitedUids = uids.slice(-limit).reverse(); + if (limitedUids.length === 0) return []; + + const results: Array> = []; + for await (const msg of client.fetch(limitedUids, { + uid: true, + envelope: true, + flags: true, + bodyStructure: true, + })) { + const hasAttachments = msg.bodyStructure?.childNodes?.some( + (n: { disposition?: string }) => n.disposition === "attachment", + ) ?? false; + + results.push({ + uid: msg.uid, + from: msg.envelope.from?.[0]?.address ?? null, + to: msg.envelope.to?.map((a: { address?: string }) => a.address) ?? [], + subject: msg.envelope.subject ?? null, + date: msg.envelope.date?.toISOString() ?? null, + flags: Array.from(msg.flags), + hasAttachments, + }); + } + return results; + } finally { + lock.release(); + } + } finally { + await client.logout().catch(() => {}); + } +} + +export async function getMessage( + config: EmailConfig, + credential: string, + params: { uid: number; folder?: string }, +) { + const auth = parseCredential(credential); + const client = createImapClient(config, auth); + const folder = params.folder || "INBOX"; + + try { + await client.connect(); + const lock = await client.getMailboxLock(folder); + try { + const msg = await client.fetchOne(params.uid, { + uid: true, + envelope: true, + flags: true, + bodyStructure: true, + source: true, + }); + + if (!msg) return null; + + const { simpleParser } = await import("mailparser"); + const parsed = await simpleParser(msg.source); + + const attachments = (parsed.attachments ?? []).map((a, i) => ({ + partId: String(i + 1), + filename: a.filename ?? null, + contentType: a.contentType, + size: a.size, + })); + + return { + uid: msg.uid, + from: msg.envelope.from?.[0]?.address ?? null, + to: msg.envelope.to?.map((a: { address?: string }) => a.address) ?? [], + cc: msg.envelope.cc?.map((a: { address?: string }) => a.address) ?? [], + subject: msg.envelope.subject ?? null, + date: msg.envelope.date?.toISOString() ?? null, + text: parsed.text ?? null, + html: parsed.html ?? null, + flags: Array.from(msg.flags), + attachments, + }; + } finally { + lock.release(); + } + } finally { + await client.logout().catch(() => {}); + } +} + +export async function getAttachment( + config: EmailConfig, + credential: string, + params: { uid: number; partId: string; folder?: string }, +) { + const auth = parseCredential(credential); + const client = createImapClient(config, auth); + const folder = params.folder || "INBOX"; + const partIndex = parseInt(params.partId, 10) - 1; + + try { + await client.connect(); + const lock = await client.getMailboxLock(folder); + try { + const msg = await client.fetchOne(params.uid, { uid: true, source: true }); + if (!msg) return null; + + const { simpleParser } = await import("mailparser"); + const parsed = await simpleParser(msg.source); + const attachment = parsed.attachments?.[partIndex]; + if (!attachment) return null; + + return { + content: attachment.content, + contentType: attachment.contentType, + filename: attachment.filename ?? null, + }; + } finally { + lock.release(); + } + } finally { + await client.logout().catch(() => {}); + } +} + +export async function send( + config: EmailConfig, + credential: string, + params: { + to: string[]; + cc?: string[]; + bcc?: string[]; + subject: string; + text?: string; + html?: string; + inReplyTo?: string; + }, +) { + const auth = parseCredential(credential); + const transport = createSmtpTransport(config, auth); + + try { + const result = await transport.sendMail({ + from: auth.username, + to: params.to, + cc: params.cc, + bcc: params.bcc, + subject: params.subject, + text: params.text, + html: params.html, + inReplyTo: params.inReplyTo, + }); + return { messageId: result.messageId }; + } finally { + transport.close(); + } +} + +export async function createDraft( + config: EmailConfig, + credential: string, + params: { + to?: string[]; + subject?: string; + text?: string; + html?: string; + }, +) { + const auth = parseCredential(credential); + + // Build raw MIME message using Nodemailer + const transport = nodemailer.createTransport({ streamTransport: true }); + const message = await transport.sendMail({ + from: auth.username, + to: params.to, + subject: params.subject, + text: params.text, + html: params.html, + }); + const rawMessage = message.message as unknown as Buffer; + + // Append to Drafts folder via IMAP + const client = createImapClient(config, auth); + try { + await client.connect(); + + // Find Drafts folder + const folders = await client.list(); + const draftsFolder = folders.find((f) => f.specialUse === "\\Drafts"); + const draftsPath = draftsFolder?.path ?? "Drafts"; + + const result = await client.append(draftsPath, rawMessage, ["\\Draft"]); + return { uid: result.uid ?? null }; + } finally { + await client.logout().catch(() => {}); + } +} + +export async function moveMessage( + config: EmailConfig, + credential: string, + params: { uid: number; from: string; to: string }, +) { + const auth = parseCredential(credential); + const client = createImapClient(config, auth); + + try { + await client.connect(); + const lock = await client.getMailboxLock(params.from); + try { + await client.messageMove(params.uid, params.to, { uid: true }); + } finally { + lock.release(); + } + } finally { + await client.logout().catch(() => {}); + } +} + +export async function flagMessage( + config: EmailConfig, + credential: string, + params: { uid: number; folder: string; add?: string[]; remove?: string[] }, +) { + const auth = parseCredential(credential); + const client = createImapClient(config, auth); + + try { + await client.connect(); + const lock = await client.getMailboxLock(params.folder); + try { + if (params.add?.length) { + await client.messageFlagsAdd(params.uid, params.add, { uid: true }); + } + if (params.remove?.length) { + await client.messageFlagsRemove(params.uid, params.remove, { uid: true }); + } + } finally { + lock.release(); + } + } finally { + await client.logout().catch(() => {}); + } +} + +export async function testConnection( + config: EmailConfig, + credential: string, +): Promise<{ imap: boolean; smtp: boolean; error?: string }> { + const auth = parseCredential(credential); + const results = { imap: false, smtp: false }; + + try { + const client = createImapClient(config, auth); + await client.connect(); + results.imap = true; + await client.logout().catch(() => {}); + } catch (err) { + return { ...results, error: `IMAP: ${err instanceof Error ? err.message : "connection failed"}` }; + } + + try { + const transport = createSmtpTransport(config, auth); + await transport.verify(); + results.smtp = true; + transport.close(); + } catch (err) { + return { ...results, error: `SMTP: ${err instanceof Error ? err.message : "connection failed"}` }; + } + + return results; +} +``` + +- [ ] **Step 2: Install mailparser for email parsing** + +```bash +npm install mailparser +npm install -D @types/mailparser +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/lib/server/services/mail.ts package.json package-lock.json +git commit -m "feat(mail): add mail service with ImapFlow + Nodemailer (search, read, send, draft, folders, move, flag, attachment)" +``` + +--- + +### Task 5: Add mail route handlers + +**Files:** +- Create: `src/routes/mail/[target]/search/+server.ts` +- Create: `src/routes/mail/[target]/message/[id]/+server.ts` +- Create: `src/routes/mail/[target]/message/[id]/attachment/[partId]/+server.ts` +- Create: `src/routes/mail/[target]/send/+server.ts` +- Create: `src/routes/mail/[target]/draft/+server.ts` +- Create: `src/routes/mail/[target]/folders/+server.ts` +- Create: `src/routes/mail/[target]/move/+server.ts` +- Create: `src/routes/mail/[target]/flag/+server.ts` +- Modify: `src/hooks.server.ts:27-42` + +- [ ] **Step 1: Add /mail/ to auth bypass in hooks.server.ts** + +In `src/hooks.server.ts`, add after line 30 (`pathname.startsWith("/ssh/") ||`): + +```typescript +pathname.startsWith("/mail/") || +``` + +- [ ] **Step 2: Create shared mail route helper** + +Create `src/routes/mail/resolve.ts`: + +```typescript +import { error } from "@sveltejs/kit"; +import { requireBearer } from "$lib/server/api-auth"; +import { getTargetBySlug } from "$lib/server/services/targets"; +import { hasPermission } from "$lib/server/services/permissions"; +import { getDefaultAuthMethod } from "$lib/server/services/auth-methods"; +import type { EmailConfig, Token } from "$lib/server/db/schema"; + +export async function resolveMailTarget(request: Request, targetSlug: string) { + const token = await requireBearer(request); + + const target = await getTargetBySlug(targetSlug); + if (!target || !target.enabled) throw error(404, "Target not found"); + if (target.type !== "email") throw error(400, "Target is not an email target"); + + const permitted = await hasPermission(token.id, target.id); + if (!permitted) throw error(403, "Forbidden"); + + const config = target.config as EmailConfig | null; + if (!config?.imap?.host || !config?.smtp?.host) throw error(400, "Target has no email configuration"); + + const authMethod = await getDefaultAuthMethod(target.id); + if (!authMethod || authMethod.type !== "imap_smtp") throw error(400, "Target has no IMAP/SMTP credentials configured"); + + return { token, target, config, credential: authMethod.credential }; +} +``` + +- [ ] **Step 3: Create search route** + +Create `src/routes/mail/[target]/search/+server.ts`: + +```typescript +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { search } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; +import { getClientAddress } from "$lib/server/api-auth"; + +export const POST: RequestHandler = async ({ params, request }) => { + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + const clientIp = getClientAddress(request); + const start = Date.now(); + + try { + const body = await request.json(); + const results = await search(config, credential, { + folder: body.folder, + query: body.query, + limit: body.limit, + }); + + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "search", path: body.folder ?? "INBOX", + statusCode: 200, clientIp, durationMs: Date.now() - start, + }); + + return json(results); + } catch (err) { + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "search", path: null, + statusCode: 502, clientIp, durationMs: Date.now() - start, + }); + throw error(502, err instanceof Error ? err.message : "IMAP operation failed"); + } +}; +``` + +- [ ] **Step 4: Create message read route** + +Create `src/routes/mail/[target]/message/[id]/+server.ts`: + +```typescript +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../../resolve"; +import { getMessage } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; +import { getClientAddress } from "$lib/server/api-auth"; + +export const GET: RequestHandler = async ({ params, request, url }) => { + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + const clientIp = getClientAddress(request); + const uid = parseInt(params.id, 10); + const folder = url.searchParams.get("folder") ?? "INBOX"; + const start = Date.now(); + + try { + const message = await getMessage(config, credential, { uid, folder }); + if (!message) throw error(404, "Message not found"); + + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "read", path: `${folder}/${uid}`, + statusCode: 200, clientIp, durationMs: Date.now() - start, + }); + + return json(message); + } catch (err) { + if ((err as { status?: number }).status === 404) throw err; + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "read", path: `${folder}/${uid}`, + statusCode: 502, clientIp, durationMs: Date.now() - start, + }); + throw error(502, err instanceof Error ? err.message : "IMAP operation failed"); + } +}; +``` + +- [ ] **Step 5: Create attachment route** + +Create `src/routes/mail/[target]/message/[id]/attachment/[partId]/+server.ts`: + +```typescript +import { error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../../../../resolve"; +import { getAttachment } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; +import { getClientAddress } from "$lib/server/api-auth"; + +export const GET: RequestHandler = async ({ params, request, url }) => { + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + const clientIp = getClientAddress(request); + const uid = parseInt(params.id, 10); + const folder = url.searchParams.get("folder") ?? "INBOX"; + const start = Date.now(); + + try { + const attachment = await getAttachment(config, credential, { uid, partId: params.partId, folder }); + if (!attachment) throw error(404, "Attachment not found"); + + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "attachment", path: `${folder}/${uid}/${params.partId}`, + statusCode: 200, clientIp, durationMs: Date.now() - start, + }); + + return new Response(attachment.content, { + headers: { + "Content-Type": attachment.contentType, + "Content-Disposition": `attachment; filename="${attachment.filename ?? "attachment"}"`, + }, + }); + } catch (err) { + if ((err as { status?: number }).status === 404) throw err; + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "attachment", path: `${folder}/${uid}/${params.partId}`, + statusCode: 502, clientIp, durationMs: Date.now() - start, + }); + throw error(502, err instanceof Error ? err.message : "IMAP operation failed"); + } +}; +``` + +- [ ] **Step 6: Create send route with hardcoded approval** + +Create `src/routes/mail/[target]/send/+server.ts`: + +```typescript +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { send } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; +import { getClientAddress } from "$lib/server/api-auth"; + +export const POST: RequestHandler = async ({ params, request }) => { + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + const clientIp = getClientAddress(request); + const body = await request.json(); + const start = Date.now(); + + const approved = request.headers.get("X-Shellgate-Approved") === "true"; + if (!approved) { + const recipients = [ + ...(body.to ?? []), + ...(body.cc ?? []), + ...(body.bcc ?? []), + ].join(", "); + + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "send", path: recipients, + statusCode: 202, clientIp, durationMs: null, + guardAction: "approval_required", + guardReason: `Send email to ${recipients}`, + }); + + return json({ + status: "approval_required", + reason: `About to send email to ${recipients}`, + request: { target: params.target, ...body }, + next_action: "STOP. Present the email details to the user (recipients, subject, body preview). Wait for explicit approval. Only then re-call with X-Shellgate-Approved: true header.", + }, { status: 202 }); + } + + try { + const result = await send(config, credential, { + to: body.to, + cc: body.cc, + bcc: body.bcc, + subject: body.subject, + text: body.text, + html: body.html, + inReplyTo: body.inReplyTo, + }); + + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "send", path: body.to?.join(", ") ?? "", + statusCode: 200, clientIp, durationMs: Date.now() - start, + guardAction: "approved", + }); + + return json(result); + } catch (err) { + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "send", path: body.to?.join(", ") ?? "", + statusCode: 502, clientIp, durationMs: Date.now() - start, + guardAction: "approved", + }); + throw error(502, err instanceof Error ? err.message : "SMTP send failed"); + } +}; +``` + +- [ ] **Step 7: Create draft route** + +Create `src/routes/mail/[target]/draft/+server.ts`: + +```typescript +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { createDraft } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; +import { getClientAddress } from "$lib/server/api-auth"; + +export const POST: RequestHandler = async ({ params, request }) => { + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + const clientIp = getClientAddress(request); + const body = await request.json(); + const start = Date.now(); + + try { + const result = await createDraft(config, credential, { + to: body.to, + subject: body.subject, + text: body.text, + html: body.html, + }); + + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "draft", path: body.subject ?? "", + statusCode: 200, clientIp, durationMs: Date.now() - start, + }); + + return json(result); + } catch (err) { + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "draft", path: null, + statusCode: 502, clientIp, durationMs: Date.now() - start, + }); + throw error(502, err instanceof Error ? err.message : "Draft creation failed"); + } +}; +``` + +- [ ] **Step 8: Create folders route** + +Create `src/routes/mail/[target]/folders/+server.ts`: + +```typescript +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { listFolders } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; +import { getClientAddress } from "$lib/server/api-auth"; + +export const GET: RequestHandler = async ({ params, request }) => { + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + const clientIp = getClientAddress(request); + const start = Date.now(); + + try { + const folders = await listFolders(config, credential); + + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "folders", path: null, + statusCode: 200, clientIp, durationMs: Date.now() - start, + }); + + return json(folders); + } catch (err) { + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "folders", path: null, + statusCode: 502, clientIp, durationMs: Date.now() - start, + }); + throw error(502, err instanceof Error ? err.message : "IMAP operation failed"); + } +}; +``` + +- [ ] **Step 9: Create move route** + +Create `src/routes/mail/[target]/move/+server.ts`: + +```typescript +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { moveMessage } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; +import { getClientAddress } from "$lib/server/api-auth"; + +export const POST: RequestHandler = async ({ params, request }) => { + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + const clientIp = getClientAddress(request); + const body = await request.json(); + const uid = parseInt(body.id, 10); + const start = Date.now(); + + try { + await moveMessage(config, credential, { uid, from: body.from, to: body.to }); + + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "move", path: `${body.from} → ${body.to}`, + statusCode: 200, clientIp, durationMs: Date.now() - start, + }); + + return json({ ok: true }); + } catch (err) { + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "move", path: null, + statusCode: 502, clientIp, durationMs: Date.now() - start, + }); + throw error(502, err instanceof Error ? err.message : "IMAP operation failed"); + } +}; +``` + +- [ ] **Step 10: Create flag route** + +Create `src/routes/mail/[target]/flag/+server.ts`: + +```typescript +import { json, error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { flagMessage } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; +import { getClientAddress } from "$lib/server/api-auth"; + +export const POST: RequestHandler = async ({ params, request }) => { + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + const clientIp = getClientAddress(request); + const body = await request.json(); + const uid = parseInt(body.id, 10); + const start = Date.now(); + + try { + await flagMessage(config, credential, { + uid, + folder: body.folder ?? "INBOX", + add: body.add, + remove: body.remove, + }); + + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "flag", path: `${body.folder ?? "INBOX"}/${uid}`, + statusCode: 200, clientIp, durationMs: Date.now() - start, + }); + + return json({ ok: true }); + } catch (err) { + logRequest({ + tokenId: token.id, tokenName: token.name, + targetId: target.id, targetSlug: target.slug, + type: "mail", method: "flag", path: null, + statusCode: 502, clientIp, durationMs: Date.now() - start, + }); + throw error(502, err instanceof Error ? err.message : "IMAP operation failed"); + } +}; +``` + +- [ ] **Step 11: Commit** + +```bash +git add src/hooks.server.ts src/routes/mail/ +git commit -m "feat(mail): add all mail route handlers (search, read, attachment, send, draft, folders, move, flag)" +``` + +--- + +### Task 6: Add MCP mail tools + +**Files:** +- Create: `src/lib/server/mcp/tools/mail.ts` +- Modify: `src/lib/server/mcp/server.ts` + +- [ ] **Step 1: Create MCP mail tool handlers** + +Create `src/lib/server/mcp/tools/mail.ts`: + +```typescript +import type { Token } from "$lib/server/db/schema"; +import type { EmailConfig } from "$lib/server/db/schema"; +import { getTargetBySlug } from "$lib/server/services/targets"; +import { hasPermission } from "$lib/server/services/permissions"; +import { getDefaultAuthMethod } from "$lib/server/services/auth-methods"; +import { logRequest } from "$lib/server/services/audit"; +import * as mailService from "$lib/server/services/mail"; + +async function resolveEmailTarget(token: Token, targetSlug: string) { + const target = await getTargetBySlug(targetSlug); + if (!target || !target.enabled) return { error: "target not found" }; + if (target.type !== "email") return { error: "target is not an email target" }; + + const permitted = await hasPermission(token.id, target.id); + if (!permitted) return { error: "forbidden" }; + + const config = target.config as EmailConfig | null; + if (!config?.imap?.host || !config?.smtp?.host) return { error: "target has no email configuration" }; + + const authMethod = await getDefaultAuthMethod(target.id); + if (!authMethod || authMethod.type !== "imap_smtp") return { error: "target has no IMAP/SMTP credentials" }; + + return { target, config, credential: authMethod.credential }; +} + +export async function mailSearch(token: Token, args: { target: string; folder?: string; query?: Record; limit?: number }) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + const start = Date.now(); + + try { + const results = await mailService.search(config, credential, { folder: args.folder, query: args.query, limit: args.limit }); + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "search", path: args.folder ?? "INBOX", statusCode: 200, clientIp: "mcp", durationMs: Date.now() - start }); + return results; + } catch (err) { + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "search", path: null, statusCode: 502, clientIp: "mcp", durationMs: Date.now() - start }); + return { error: err instanceof Error ? err.message : "IMAP operation failed" }; + } +} + +export async function mailRead(token: Token, args: { target: string; uid: number; folder?: string }) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + const start = Date.now(); + + try { + const message = await mailService.getMessage(config, credential, { uid: args.uid, folder: args.folder }); + if (!message) return { error: "message not found" }; + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "read", path: `${args.folder ?? "INBOX"}/${args.uid}`, statusCode: 200, clientIp: "mcp", durationMs: Date.now() - start }); + return message; + } catch (err) { + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "read", path: null, statusCode: 502, clientIp: "mcp", durationMs: Date.now() - start }); + return { error: err instanceof Error ? err.message : "IMAP operation failed" }; + } +} + +export async function mailAttachment(token: Token, args: { target: string; uid: number; partId: string; folder?: string }) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + const start = Date.now(); + + try { + const attachment = await mailService.getAttachment(config, credential, { uid: args.uid, partId: args.partId, folder: args.folder }); + if (!attachment) return { error: "attachment not found" }; + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "attachment", path: `${args.folder ?? "INBOX"}/${args.uid}/${args.partId}`, statusCode: 200, clientIp: "mcp", durationMs: Date.now() - start }); + return { filename: attachment.filename, contentType: attachment.contentType, content: attachment.content.toString("base64") }; + } catch (err) { + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "attachment", path: null, statusCode: 502, clientIp: "mcp", durationMs: Date.now() - start }); + return { error: err instanceof Error ? err.message : "IMAP operation failed" }; + } +} + +export async function mailSend(token: Token, args: { target: string; to: string[]; cc?: string[]; bcc?: string[]; subject: string; text?: string; html?: string; inReplyTo?: string; approved?: boolean }) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + + if (!args.approved) { + const recipients = [...(args.to ?? []), ...(args.cc ?? []), ...(args.bcc ?? [])].join(", "); + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "send", path: recipients, statusCode: 202, clientIp: "mcp", durationMs: null, guardAction: "approval_required", guardReason: `Send email to ${recipients}` }); + return { + status: "approval_required", + reason: `About to send email to ${recipients} with subject "${args.subject}"`, + request: { target: args.target, to: args.to, cc: args.cc, bcc: args.bcc, subject: args.subject }, + next_action: "STOP. Present the email details to the user (recipients, subject, body preview). Wait for explicit approval. Only then re-call this SAME tool with all the SAME parameters AND set approved: true. If the user denies, abort. Never auto-approve.", + }; + } + + const start = Date.now(); + try { + const result = await mailService.send(config, credential, { to: args.to, cc: args.cc, bcc: args.bcc, subject: args.subject, text: args.text, html: args.html, inReplyTo: args.inReplyTo }); + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "send", path: args.to.join(", "), statusCode: 200, clientIp: "mcp", durationMs: Date.now() - start, guardAction: "approved" }); + return result; + } catch (err) { + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "send", path: null, statusCode: 502, clientIp: "mcp", durationMs: Date.now() - start, guardAction: "approved" }); + return { error: err instanceof Error ? err.message : "SMTP send failed" }; + } +} + +export async function mailDraft(token: Token, args: { target: string; to?: string[]; subject?: string; text?: string; html?: string }) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + const start = Date.now(); + + try { + const result = await mailService.createDraft(config, credential, { to: args.to, subject: args.subject, text: args.text, html: args.html }); + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "draft", path: args.subject ?? "", statusCode: 200, clientIp: "mcp", durationMs: Date.now() - start }); + return result; + } catch (err) { + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "draft", path: null, statusCode: 502, clientIp: "mcp", durationMs: Date.now() - start }); + return { error: err instanceof Error ? err.message : "Draft creation failed" }; + } +} + +export async function mailFolders(token: Token, args: { target: string }) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + const start = Date.now(); + + try { + const folders = await mailService.listFolders(config, credential); + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "folders", path: null, statusCode: 200, clientIp: "mcp", durationMs: Date.now() - start }); + return folders; + } catch (err) { + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "folders", path: null, statusCode: 502, clientIp: "mcp", durationMs: Date.now() - start }); + return { error: err instanceof Error ? err.message : "IMAP operation failed" }; + } +} + +export async function mailMove(token: Token, args: { target: string; uid: number; from: string; to: string }) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + const start = Date.now(); + + try { + await mailService.moveMessage(config, credential, { uid: args.uid, from: args.from, to: args.to }); + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "move", path: `${args.from} → ${args.to}`, statusCode: 200, clientIp: "mcp", durationMs: Date.now() - start }); + return { ok: true }; + } catch (err) { + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "move", path: null, statusCode: 502, clientIp: "mcp", durationMs: Date.now() - start }); + return { error: err instanceof Error ? err.message : "IMAP operation failed" }; + } +} + +export async function mailFlag(token: Token, args: { target: string; uid: number; folder?: string; add?: string[]; remove?: string[] }) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + const start = Date.now(); + + try { + await mailService.flagMessage(config, credential, { uid: args.uid, folder: args.folder ?? "INBOX", add: args.add, remove: args.remove }); + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "flag", path: `${args.folder ?? "INBOX"}/${args.uid}`, statusCode: 200, clientIp: "mcp", durationMs: Date.now() - start }); + return { ok: true }; + } catch (err) { + logRequest({ tokenId: token.id, tokenName: token.name, targetId: target.id, targetSlug: target.slug, type: "mail", method: "flag", path: null, statusCode: 502, clientIp: "mcp", durationMs: Date.now() - start }); + return { error: err instanceof Error ? err.message : "IMAP operation failed" }; + } +} +``` + +- [ ] **Step 2: Register mail tools in MCP server** + +In `src/lib/server/mcp/server.ts`, add import after line 16: + +```typescript +import { mailSearch, mailRead, mailAttachment, mailSend, mailDraft, mailFolders, mailMove, mailFlag } from "./tools/mail"; +import type { MailSearchArgs, MailReadArgs, MailAttachmentArgs, MailSendArgs, MailDraftArgs, MailFoldersArgs, MailMoveArgs, MailFlagArgs } from "./tools/mail"; +``` + +Note: The types don't exist yet — we won't add separate interface exports since the args are simple enough to inline. Instead, just import the functions: + +```typescript +import { mailSearch, mailRead, mailAttachment, mailSend, mailDraft, mailFolders, mailMove, mailFlag } from "./tools/mail"; +``` + +After the `vault_search` tool registration (after line 344), add: + +```typescript + server.tool( + "mail_search", + "Search emails in a mailbox. Returns message list with uid, from, to, subject, date, flags.", + { + target: z.string().describe("Email target slug"), + folder: z.string().optional().describe("Folder to search (default: INBOX)"), + query: z.record(z.string(), z.string()).optional().describe("Search criteria: from, to, subject, since, before, text"), + limit: z.number().optional().describe("Max results (default: 20)"), + }, + async (args) => { + const result = await mailSearch(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_read", + "Read a full email message by UID. Returns from, to, cc, subject, date, text, html, flags, and attachment metadata.", + { + target: z.string().describe("Email target slug"), + uid: z.number().describe("Message UID"), + folder: z.string().optional().describe("Folder (default: INBOX)"), + }, + async (args) => { + const result = await mailRead(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_attachment", + "Download an email attachment by UID and part ID. Returns base64-encoded content.", + { + target: z.string().describe("Email target slug"), + uid: z.number().describe("Message UID"), + partId: z.string().describe("Attachment part ID from mail_read response"), + folder: z.string().optional().describe("Folder (default: INBOX)"), + }, + async (args) => { + const result = await mailAttachment(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_send", + "Send an email. Requires approval — first call returns approval_required, re-call with approved: true after user confirms.", + { + target: z.string().describe("Email target slug"), + to: z.array(z.string()).describe("Recipient email addresses"), + cc: z.array(z.string()).optional().describe("CC addresses"), + bcc: z.array(z.string()).optional().describe("BCC addresses"), + subject: z.string().describe("Email subject"), + text: z.string().optional().describe("Plain text body"), + html: z.string().optional().describe("HTML body"), + inReplyTo: z.string().optional().describe("Message-ID to reply to"), + approved: z.preprocess(val => val === "true" || val === true, z.boolean()).optional().describe("Set to true after user approves"), + }, + async (args) => { + const result = await mailSend(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_draft", + "Create a draft email in the Drafts folder.", + { + target: z.string().describe("Email target slug"), + to: z.array(z.string()).optional().describe("Recipient email addresses"), + subject: z.string().optional().describe("Email subject"), + text: z.string().optional().describe("Plain text body"), + html: z.string().optional().describe("HTML body"), + }, + async (args) => { + const result = await mailDraft(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_folders", + "List all folders/labels in the mailbox.", + { + target: z.string().describe("Email target slug"), + }, + async (args) => { + const result = await mailFolders(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_move", + "Move an email to a different folder.", + { + target: z.string().describe("Email target slug"), + uid: z.number().describe("Message UID"), + from: z.string().describe("Source folder"), + to: z.string().describe("Destination folder"), + }, + async (args) => { + const result = await mailMove(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_flag", + "Set or unset flags on an email (e.g. \\Seen, \\Flagged).", + { + target: z.string().describe("Email target slug"), + uid: z.number().describe("Message UID"), + folder: z.string().optional().describe("Folder (default: INBOX)"), + add: z.array(z.string()).optional().describe("Flags to add"), + remove: z.array(z.string()).optional().describe("Flags to remove"), + }, + async (args) => { + const result = await mailFlag(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); +``` + +- [ ] **Step 3: Add mail tools to createMcpToolHandler switch** + +In the `createMcpToolHandler` function, before the `default:` case (before line 396), add: + +```typescript + case "mail_search": + return mailSearch(t, args as unknown as { target: string; folder?: string; query?: Record; limit?: number }); + case "mail_read": + return mailRead(t, args as unknown as { target: string; uid: number; folder?: string }); + case "mail_attachment": + return mailAttachment(t, args as unknown as { target: string; uid: number; partId: string; folder?: string }); + case "mail_send": + return mailSend(t, args as unknown as { target: string; to: string[]; cc?: string[]; bcc?: string[]; subject: string; text?: string; html?: string; inReplyTo?: string; approved?: boolean }); + case "mail_draft": + return mailDraft(t, args as unknown as { target: string; to?: string[]; subject?: string; text?: string; html?: string }); + case "mail_folders": + return mailFolders(t, args as unknown as { target: string }); + case "mail_move": + return mailMove(t, args as unknown as { target: string; uid: number; from: string; to: string }); + case "mail_flag": + return mailFlag(t, args as unknown as { target: string; uid: number; folder?: string; add?: string[]; remove?: string[] }); +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/mcp/tools/mail.ts src/lib/server/mcp/server.ts +git commit -m "feat(mail): register 8 MCP mail tools (search, read, attachment, send, draft, folders, move, flag)" +``` + +--- + +### Task 7: Update bootstrap to include email field + +**Files:** +- Modify: `src/lib/server/mcp/tools/bootstrap.ts:13-29` + +- [ ] **Step 1: Add email field to bootstrap target output** + +In `src/lib/server/mcp/tools/bootstrap.ts`, update the target mapping (lines 18-26): + +```typescript + return { + slug: target.slug, + name: target.name, + type: target.type, + ...(target.type === "api" && { + proxy: `/gateway/${target.slug}`, + baseUrl: target.baseUrl, + }), + ...(target.type === "email" && { + email: target.email, + }), + }; +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/lib/server/mcp/tools/bootstrap.ts +git commit -m "feat(mail): include email address in bootstrap response for email targets" +``` + +--- + +### Task 8: Dashboard — tabbed target list + +**Files:** +- Modify: `src/routes/(app)/targets/+page.svelte` + +- [ ] **Step 1: Read the current targets page** + +Read `src/routes/(app)/targets/+page.svelte` to understand the current layout before making changes. + +- [ ] **Step 2: Add tabbed view with counts** + +Replace the target list section with shadcn-svelte Tabs. The exact implementation depends on the current markup, but the pattern is: + +```svelte + + + + + API {apiTargets.length} + SSH {sshTargets.length} + Email {emailTargets.length} + + + + + + + + + + + +``` + +Adapt to match the existing component structure and styling. The target cards themselves don't change — just the filtering. + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/(app)/targets/+page.svelte +git commit -m "feat(mail): add tabbed target list with type counts (API/SSH/Email)" +``` + +--- + +### Task 9: Dashboard — email target create form + +**Files:** +- Modify: `src/routes/(app)/targets/+page.server.ts:22-49` +- Modify: `src/routes/(app)/targets/+page.svelte` + +- [ ] **Step 1: Extend create action for email type** + +In `src/routes/(app)/targets/+page.server.ts`, update the create action. After the SSH branch (after line 39), add an email branch: + +```typescript + } else if (type === "email") { + const email = data.get("email")?.toString()?.trim() ?? ""; + const imapHost = data.get("imap_host")?.toString()?.trim() ?? ""; + const imapPort = parseInt(data.get("imap_port")?.toString() ?? "993", 10) || 993; + const imapSecure = data.get("imap_secure")?.toString() !== "false"; + const smtpHost = data.get("smtp_host")?.toString()?.trim() ?? ""; + const smtpPort = parseInt(data.get("smtp_port")?.toString() ?? "587", 10) || 587; + const smtpSecure = data.get("smtp_secure")?.toString() === "true"; + if (!email) return fail(400, { error: "Email address is required" }); + if (!imapHost) return fail(400, { error: "IMAP host is required" }); + if (!smtpHost) return fail(400, { error: "SMTP host is required" }); + try { + const target = await createTarget({ + name, type: "email", email, + config: { + imap: { host: imapHost, port: imapPort, secure: imapSecure }, + smtp: { host: smtpHost, port: smtpPort, secure: smtpSecure }, + }, + }); + return { created: { ...target, enabled: target.enabled !== false } }; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : "Failed to create target" }); + } +``` + +Update the type cast on line 25: + +```typescript +const type = (data.get("type")?.toString() ?? "api") as "api" | "ssh" | "email"; +``` + +- [ ] **Step 2: Add email form fields to the create dialog** + +In `src/routes/(app)/targets/+page.svelte`, add form fields that show when type is `email`: + +```svelte +{#if type === "email"} +
+ + +
+
+
+

Incoming (IMAP)

+ + + +
+
+

Outgoing (SMTP)

+ + + +
+
+{/if} +``` + +Adapt to match existing form patterns (class names, component usage). + +- [ ] **Step 3: Add imap_smtp auth method handling to addAuthMethod action** + +In the `addAuthMethod` action, add a branch for `imap_smtp` type (after the `oauth2_refresh_token` branch): + +```typescript + } else if (type === "imap_smtp") { + const username = data.get("credential1")?.toString() ?? ""; + const password = data.get("credential2")?.toString() ?? ""; + if (!username || !password) return fail(400, { error: "Username and password are required" }); + credential = JSON.stringify({ username, password }); +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/routes/(app)/targets/+page.server.ts src/routes/(app)/targets/+page.svelte +git commit -m "feat(mail): add email target create form with IMAP/SMTP config fields" +``` + +--- + +### Task 10: Dashboard — email target detail page + test connection + +**Files:** +- Modify: `src/routes/(app)/targets/[slug]/+page.server.ts` +- Modify: `src/routes/(app)/targets/[slug]/+page.svelte` + +- [ ] **Step 1: Add updateEmailConfig action** + +In `src/routes/(app)/targets/[slug]/+page.server.ts`, add a new action for updating email configuration: + +```typescript + updateEmailConfig: async ({ request, params }) => { + const target = await getTargetBySlug(params.slug); + if (!target) return fail(404, { error: "Target not found" }); + + const data = await request.formData(); + const email = data.get("email")?.toString()?.trim() ?? ""; + const imapHost = data.get("imap_host")?.toString()?.trim() ?? ""; + const imapPort = parseInt(data.get("imap_port")?.toString() ?? "993", 10) || 993; + const imapSecure = data.get("imap_secure")?.toString() !== "false"; + const smtpHost = data.get("smtp_host")?.toString()?.trim() ?? ""; + const smtpPort = parseInt(data.get("smtp_port")?.toString() ?? "587", 10) || 587; + const smtpSecure = data.get("smtp_secure")?.toString() === "true"; + + try { + const result = await updateTarget(target.id, { + email, + config: { + imap: { host: imapHost, port: imapPort, secure: imapSecure }, + smtp: { host: smtpHost, port: smtpPort, secure: smtpSecure }, + }, + }); + if (!result) return fail(404, { error: "Target not found" }); + return { updated: true }; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : "Failed to update" }); + } + }, +``` + +- [ ] **Step 2: Add testConnection action** + +Add a test connection action: + +```typescript + testConnection: async ({ params }) => { + const target = await getTargetBySlug(params.slug); + if (!target) return fail(404, { error: "Target not found" }); + if (target.type !== "email") return fail(400, { error: "Not an email target" }); + + const { getDefaultAuthMethod } = await import("$lib/server/services/auth-methods"); + const { testConnection } = await import("$lib/server/services/mail"); + const { EmailConfig } = await import("$lib/server/db/schema"); + + const authMethod = await getDefaultAuthMethod(target.id); + if (!authMethod || authMethod.type !== "imap_smtp") { + return fail(400, { error: "No IMAP/SMTP credentials configured" }); + } + + const config = target.config as EmailConfig; + const result = await testConnection(config, authMethod.credential); + return { testResult: result }; + }, +``` + +Note: Import `EmailConfig` type properly — use `import type { EmailConfig } from "$lib/server/db/schema"` at the top of the file. + +- [ ] **Step 3: Add email config display to detail page** + +In `src/routes/(app)/targets/[slug]/+page.svelte`, add a section for email targets that shows the IMAP/SMTP config with edit form and test connection button. Follow the existing pattern used for SSH config display and API baseUrl display. + +- [ ] **Step 4: Commit** + +```bash +git add src/routes/(app)/targets/[slug]/+page.server.ts src/routes/(app)/targets/[slug]/+page.svelte +git commit -m "feat(mail): add email config editing and test connection to target detail page" +``` + +--- + +### Task 11: Update AGENTS.md and verify + +**Files:** +- Modify: `AGENTS.md` + +- [ ] **Step 1: Update AGENTS.md** + +Add `/mail/[target]/*` routes to the agent-facing routes table. Add `mail_search`, `mail_read`, `mail_attachment`, `mail_send`, `mail_draft`, `mail_folders`, `mail_move`, `mail_flag` to the MCP tools list. Add `mail` service to the key services table. Add `imap_smtp` to the auth method types list. + +- [ ] **Step 2: Run dev server and verify** + +```bash +npm run dev +``` + +Verify the app starts without errors. Check: +- Migration runs on startup +- `/targets` page loads with tabs +- Can create an email target +- MCP tools are registered (check via MCP inspector or bootstrap response) + +- [ ] **Step 3: Commit** + +```bash +git add AGENTS.md +git commit -m "docs: update AGENTS.md with mail integration routes, tools, and service" +``` + +--- + +### Task 12: Manual integration test + +This task is manual — test with a real email account. + +- [ ] **Step 1: Create email target via dashboard** + +Go to `/targets`, click "Email" tab, create a target with a test email account (e.g., Gmail with app password). + +- [ ] **Step 2: Add imap_smtp auth method** + +On the target detail page, add credentials with type `imap_smtp`. + +- [ ] **Step 3: Test connection** + +Click "Test Connection" — verify both IMAP and SMTP connect successfully. + +- [ ] **Step 4: Test via MCP tools** + +Use Claude Code with the Shellgate MCP server to test: +- `mail_folders` — list folders +- `mail_search` — search INBOX +- `mail_read` — read a specific email +- `mail_draft` — create a draft +- `mail_send` — send a test email (verify approval flow) + +- [ ] **Step 5: Check audit logs** + +Verify all mail operations appear in `/logs` with type `mail`. From b6b61d71c84e60e27d3e80ab6bc752ada1b69040 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:34:20 +0200 Subject: [PATCH 04/15] feat(mail): extend schema with email target type, EmailConfig, and email column - Add EmailConfig type with imap/smtp sub-configs - Extend targets.type to "api" | "ssh" | "email" - Extend targets.config to SshConfig | EmailConfig - Add email varchar(255) column to targets table - Extend audit_logs.type to include "mail" - Add imapflow and nodemailer dependencies - Fix snapshot chain collision (0010/0011 duplicate ids) - Generate migration 0012_happy_pet_avengers Co-Authored-By: Claude Sonnet 4.6 --- drizzle/0012_happy_pet_avengers.sql | 3 + drizzle/meta/0011_snapshot.json | 4 +- drizzle/meta/0012_snapshot.json | 1433 +++++++++++++++++++++++++++ drizzle/meta/_journal.json | 9 +- package-lock.json | 253 +++++ package.json | 3 + src/lib/server/db/schema.ts | 12 +- src/lib/server/services/audit.ts | 2 +- 8 files changed, 1712 insertions(+), 7 deletions(-) create mode 100644 drizzle/0012_happy_pet_avengers.sql create mode 100644 drizzle/meta/0012_snapshot.json diff --git a/drizzle/0012_happy_pet_avengers.sql b/drizzle/0012_happy_pet_avengers.sql new file mode 100644 index 0000000..ca4b280 --- /dev/null +++ b/drizzle/0012_happy_pet_avengers.sql @@ -0,0 +1,3 @@ +ALTER TABLE "skills" ADD COLUMN "last_used_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "targets" ADD COLUMN "email" varchar(255);--> statement-breakpoint +ALTER TABLE "memories" DROP COLUMN "last_used_at"; \ No newline at end of file diff --git a/drizzle/meta/0011_snapshot.json b/drizzle/meta/0011_snapshot.json index 76c9a44..dcfa844 100644 --- a/drizzle/meta/0011_snapshot.json +++ b/drizzle/meta/0011_snapshot.json @@ -1,6 +1,6 @@ { - "id": "5f3c8c75-a843-439a-be44-0dd1de71de5e", - "prevId": "c5c3ef51-3063-47d0-ac56-d4172231d5ac", + "id": "f9751289-acf8-4f8c-a3ec-9be178570abd", + "prevId": "5f3c8c75-a843-439a-be44-0dd1de71de5e", "version": "7", "dialect": "postgresql", "tables": { diff --git a/drizzle/meta/0012_snapshot.json b/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..dd2a03f --- /dev/null +++ b/drizzle/meta/0012_snapshot.json @@ -0,0 +1,1433 @@ +{ + "id": "1ec4e5c2-e32f-4723-bef2-57c380ca2d1e", + "prevId": "f9751289-acf8-4f8c-a3ec-9be178570abd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_id": { + "name": "token_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "token_name": { + "name": "token_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "target_slug": { + "name": "target_slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "client_ip": { + "name": "client_ip", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "guard_action": { + "name": "guard_action", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "guard_reason": { + "name": "guard_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_token_id_idx": { + "name": "audit_logs_token_id_idx", + "columns": [ + { + "expression": "token_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_target_id_idx": { + "name": "audit_logs_target_id_idx", + "columns": [ + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_logs_created_at_idx": { + "name": "audit_logs_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_token_id_tokens_id_fk": { + "name": "audit_logs_token_id_tokens_id_fk", + "tableFrom": "audit_logs", + "tableTo": "tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_logs_target_id_targets_id_fk": { + "name": "audit_logs_target_id_targets_id_fk", + "tableFrom": "audit_logs", + "tableTo": "targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memories": { + "name": "memories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_id": { + "name": "token_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_identifier": { + "name": "user_identifier", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_memories_token": { + "name": "idx_memories_token", + "columns": [ + { + "expression": "token_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_memories_visibility": { + "name": "idx_memories_visibility", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_memories_user": { + "name": "idx_memories_user", + "columns": [ + { + "expression": "user_identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memories_token_id_tokens_id_fk": { + "name": "memories_token_id_tokens_id_fk", + "tableFrom": "memories", + "tableTo": "tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true + }, + "content_md": { + "name": "content_md", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "skills_slug_unique": { + "name": "skills_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.target_auth_methods": { + "name": "target_auth_methods", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential": { + "name": "credential", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_hint": { + "name": "credential_hint", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "target_auth_methods_target_id_targets_id_fk": { + "name": "target_auth_methods_target_id_targets_id_fk", + "tableFrom": "target_auth_methods", + "tableTo": "targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.targets": { + "name": "targets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "targets_slug_unique": { + "name": "targets_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_permissions": { + "name": "token_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_id": { + "name": "token_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "token_permissions_token_id_tokens_id_fk": { + "name": "token_permissions_token_id_tokens_id_fk", + "tableFrom": "token_permissions", + "tableTo": "tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "token_permissions_target_id_targets_id_fk": { + "name": "token_permissions_target_id_targets_id_fk", + "tableFrom": "token_permissions", + "tableTo": "targets", + "columnsFrom": [ + "target_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "token_permissions_token_id_target_id_unique": { + "name": "token_permissions_token_id_target_id_unique", + "nullsNotDistinct": false, + "columns": [ + "token_id", + "target_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_vault_permissions": { + "name": "token_vault_permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_id": { + "name": "token_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "vault_id": { + "name": "vault_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "token_vault_permissions_token_id_tokens_id_fk": { + "name": "token_vault_permissions_token_id_tokens_id_fk", + "tableFrom": "token_vault_permissions", + "tableTo": "tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "token_vault_permissions_vault_id_vaults_id_fk": { + "name": "token_vault_permissions_vault_id_vaults_id_fk", + "tableFrom": "token_vault_permissions", + "tableTo": "vaults", + "columnsFrom": [ + "vault_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "token_vault_permissions_token_id_vault_id_unique": { + "name": "token_vault_permissions_token_id_vault_id_unique", + "nullsNotDistinct": false, + "columns": [ + "token_id", + "vault_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tokens": { + "name": "tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allowed_ips": { + "name": "allowed_ips", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "default_user": { + "name": "default_user", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tokens_token_hash_unique": { + "name": "tokens_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_item_fields": { + "name": "vault_item_fields", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "item_id": { + "name": "item_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sensitive": { + "name": "sensitive", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "vault_item_fields_item_id_vault_items_id_fk": { + "name": "vault_item_fields_item_id_vault_items_id_fk", + "tableFrom": "vault_item_fields", + "tableTo": "vault_items", + "columnsFrom": [ + "item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vault_item_fields_item_id_name_unique": { + "name": "vault_item_fields_item_id_name_unique", + "nullsNotDistinct": false, + "columns": [ + "item_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vault_items": { + "name": "vault_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "vault_id": { + "name": "vault_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_origins": { + "name": "allowed_origins", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "vault_items_vault_id_vaults_id_fk": { + "name": "vault_items_vault_id_vaults_id_fk", + "tableFrom": "vault_items", + "tableTo": "vaults", + "columnsFrom": [ + "vault_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vault_items_vault_id_slug_unique": { + "name": "vault_items_vault_id_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "vault_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.vaults": { + "name": "vaults", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "vaults_slug_unique": { + "name": "vaults_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_endpoints": { + "name": "webhook_endpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token_id": { + "name": "token_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "signature_header": { + "name": "signature_header", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handling_instructions": { + "name": "handling_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_endpoints_token_id_tokens_id_fk": { + "name": "webhook_endpoints_token_id_tokens_id_fk", + "tableFrom": "webhook_endpoints", + "tableTo": "tokens", + "columnsFrom": [ + "token_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "webhook_endpoints_slug_unique": { + "name": "webhook_endpoints_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_events": { + "name": "webhook_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "webhook_events_endpoint_status_idx": { + "name": "webhook_events_endpoint_status_idx", + "columns": [ + { + "expression": "endpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_expires_at_idx": { + "name": "webhook_events_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_events_endpoint_id_webhook_endpoints_id_fk": { + "name": "webhook_events_endpoint_id_webhook_endpoints_id_fk", + "tableFrom": "webhook_events", + "tableTo": "webhook_endpoints", + "columnsFrom": [ + "endpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wiki_pages": { + "name": "wiki_pages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "namespace": { + "name": "namespace", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true, + "default": "'general'" + }, + "slug": { + "name": "slug", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sources": { + "name": "sources", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "status": { + "name": "status", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_by": { + "name": "updated_by", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "uq_wiki_namespace_slug": { + "name": "uq_wiki_namespace_slug", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_wiki_namespace": { + "name": "idx_wiki_namespace", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_wiki_status": { + "name": "idx_wiki_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 4651657..29a5951 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1780411720000, "tag": "0011_skill_last_used_at", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1780990365653, + "tag": "0012_happy_pet_avengers", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 285b4ad..ca1e2ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,9 @@ "bits-ui": "^2.16.3", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", + "imapflow": "^1.3.7", "mode-watcher": "^1.1.0", + "nodemailer": "^8.0.10", "postgres": "^3.4.8", "ssh2": "^1.17.0", "svelte-sonner": "^1.1.0", @@ -30,6 +32,7 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@testcontainers/postgresql": "^11.13.0", "@types/node": "^25.5.0", + "@types/nodemailer": "^8.0.0", "@types/ssh2": "^1.15.5", "drizzle-kit": "^0.31.9", "svelte": "^5.51.0", @@ -1164,6 +1167,12 @@ } } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2130,6 +2139,16 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/nodemailer": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz", + "integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -2318,6 +2337,17 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@zone-eu/mailsplit": { + "version": "5.4.12", + "resolved": "https://registry.npmjs.org/@zone-eu/mailsplit/-/mailsplit-5.4.12.tgz", + "integrity": "sha512-w7Gy+NvjZ0MiXm8F6zfjImAqcTONKDImgWVBjDKQVFUXWuz3VFM5levNArkL2M877ajql5+bkS2pDV56injlmg==", + "license": "(MIT OR EUPL-1.1+)", + "dependencies": { + "libbase64": "1.3.0", + "libmime": "5.3.8", + "libqp": "2.1.1" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2495,6 +2525,15 @@ "dev": true, "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/axobject-query": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", @@ -3547,6 +3586,15 @@ "node": ">= 0.8" } }, + "node_modules/encoding-japanese": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/encoding-japanese/-/encoding-japanese-2.2.0.tgz", + "integrity": "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==", + "license": "MIT", + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", @@ -4176,6 +4224,23 @@ ], "license": "BSD-3-Clause" }, + "node_modules/imapflow": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/imapflow/-/imapflow-1.3.7.tgz", + "integrity": "sha512-9JGZYJyOtT8DluwPYhChOjfxtkWXWYa8JD8ylvFrlGlMvfFhGgdoza4oTVyZ78Sb//cfh0Mo7hXJLke+9cyuGg==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.12", + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libmime": "5.3.8", + "libqp": "2.1.1", + "nodemailer": "8.0.10", + "pino": "10.3.1", + "socks": "2.8.9" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -4390,6 +4455,30 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/libbase64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", + "integrity": "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==", + "license": "MIT" + }, + "node_modules/libmime": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/libmime/-/libmime-5.3.8.tgz", + "integrity": "sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==", + "license": "MIT", + "dependencies": { + "encoding-japanese": "2.2.0", + "iconv-lite": "0.7.2", + "libbase64": "1.3.0", + "libqp": "2.1.1" + } + }, + "node_modules/libqp": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/libqp/-/libqp-2.1.1.tgz", + "integrity": "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==", + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.31.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", @@ -4925,6 +5014,15 @@ "node": ">= 0.6" } }, + "node_modules/nodemailer": { + "version": "8.0.10", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.10.tgz", + "integrity": "sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -4967,6 +5065,15 @@ ], "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -5082,6 +5189,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -5149,6 +5293,22 @@ "dev": true, "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/proper-lockfile": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", @@ -5250,6 +5410,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -5328,6 +5494,15 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -5506,6 +5681,15 @@ ], "license": "MIT" }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -5698,6 +5882,48 @@ "node": ">=18" } }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks/node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5735,6 +5961,15 @@ "dev": true, "license": "ISC" }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/ssh-remote-port-forward": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/ssh-remote-port-forward/-/ssh-remote-port-forward-1.0.4.tgz", @@ -6192,6 +6427,24 @@ "b4a": "^1.6.4" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index 4b05858..bb5d6cc 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@sveltejs/vite-plugin-svelte": "^6.2.4", "@testcontainers/postgresql": "^11.13.0", "@types/node": "^25.5.0", + "@types/nodemailer": "^8.0.0", "@types/ssh2": "^1.15.5", "drizzle-kit": "^0.31.9", "svelte": "^5.51.0", @@ -40,7 +41,9 @@ "bits-ui": "^2.16.3", "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", + "imapflow": "^1.3.7", "mode-watcher": "^1.1.0", + "nodemailer": "^8.0.10", "postgres": "^3.4.8", "ssh2": "^1.17.0", "svelte-sonner": "^1.1.0", diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts index 28712a4..61cc666 100644 --- a/src/lib/server/db/schema.ts +++ b/src/lib/server/db/schema.ts @@ -36,13 +36,19 @@ export type SshConfig = { username: string; }; +export type EmailConfig = { + imap: { host: string; port: number; secure: boolean }; + smtp: { host: string; port: number; secure: boolean }; +}; + export const targets = pgTable("targets", { id: uuid("id").primaryKey().defaultRandom(), name: varchar("name", { length: 255 }).notNull(), slug: varchar("slug", { length: 255 }).notNull().unique(), - type: text("type").notNull().$type<"api" | "ssh">(), + type: text("type").notNull().$type<"api" | "ssh" | "email">(), baseUrl: text("base_url"), - config: jsonb("config").$type(), + config: jsonb("config").$type(), + email: varchar("email", { length: 255 }), enabled: boolean("enabled").notNull().default(true), createdAt: timestamp("created_at", { withTimezone: true }) .notNull() @@ -116,7 +122,7 @@ export const auditLogs = pgTable( onDelete: "set null", }), targetSlug: varchar("target_slug", { length: 255 }), - type: text("type").notNull().$type<"gateway" | "ssh" | "vault">(), + type: text("type").notNull().$type<"gateway" | "ssh" | "vault" | "mail">(), method: text("method"), path: text("path"), statusCode: integer("status_code"), diff --git a/src/lib/server/services/audit.ts b/src/lib/server/services/audit.ts index 356ae29..5f08d26 100644 --- a/src/lib/server/services/audit.ts +++ b/src/lib/server/services/audit.ts @@ -6,7 +6,7 @@ type AuditLogData = { tokenName: string | null; targetId: string | null; targetSlug: string | null; - type: "gateway" | "ssh" | "vault"; + type: "gateway" | "ssh" | "vault" | "mail"; method: string | null; path: string | null; statusCode: number | null; From 316863636643464b1aa7e04e985e45eaa6db69cb Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:34:25 +0200 Subject: [PATCH 05/15] feat(mail): extend targets service with email type, validation, and email field - Import EmailConfig from schema - Add validateEmailConfig: validates imap/smtp objects with host (required), port (1-65535, defaults: 993), and secure (boolean, default true) - Extend createTarget to accept type "email", validate email config, require email field, and include it in insert - Extend updateTarget to accept type "email", validate email config, and handle email field updates Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/services/targets.ts | 82 +++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 12 deletions(-) diff --git a/src/lib/server/services/targets.ts b/src/lib/server/services/targets.ts index c97a2a6..80c584f 100644 --- a/src/lib/server/services/targets.ts +++ b/src/lib/server/services/targets.ts @@ -1,7 +1,7 @@ import { eq, sql } from "drizzle-orm"; import { db } from "../db"; import { targets } from "../db/schema"; -import type { SshConfig } from "../db/schema"; +import type { SshConfig, EmailConfig } from "../db/schema"; import { isUniqueViolation } from "../utils/db-error"; import { validateBaseUrl } from "../utils/url"; @@ -66,21 +66,57 @@ function validateSshConfig(config: unknown): SshConfig { return { host, port, username }; } +function validateEmailConfig(config: unknown): EmailConfig { + if (!config || typeof config !== "object") { + throw new Error("email config is required for email targets"); + } + const c = config as Record; + + // Validate imap + if (!c.imap || typeof c.imap !== "object") { + throw new Error("imap config is required for email targets"); + } + const imap = c.imap as Record; + const imapHost = typeof imap.host === "string" ? imap.host.trim() : ""; + if (!imapHost) throw new Error("imap.host is required for email targets"); + const imapPort = typeof imap.port === "number" ? imap.port : 993; + if (imapPort < 1 || imapPort > 65535) throw new Error("imap.port must be between 1 and 65535"); + const imapSecure = typeof imap.secure === "boolean" ? imap.secure : true; + + // Validate smtp + if (!c.smtp || typeof c.smtp !== "object") { + throw new Error("smtp config is required for email targets"); + } + const smtp = c.smtp as Record; + const smtpHost = typeof smtp.host === "string" ? smtp.host.trim() : ""; + if (!smtpHost) throw new Error("smtp.host is required for email targets"); + const smtpPort = typeof smtp.port === "number" ? smtp.port : 993; + if (smtpPort < 1 || smtpPort > 65535) throw new Error("smtp.port must be between 1 and 65535"); + const smtpSecure = typeof smtp.secure === "boolean" ? smtp.secure : true; + + return { + imap: { host: imapHost, port: imapPort, secure: imapSecure }, + smtp: { host: smtpHost, port: smtpPort, secure: smtpSecure }, + }; +} + export async function createTarget(data: { name: string; - type: "api" | "ssh"; + type: "api" | "ssh" | "email"; base_url?: string | null; - config?: SshConfig | null; + config?: SshConfig | EmailConfig | null; + email?: string | null; }) { const name = data.name.trim(); if (!name) throw new Error("name is required"); - if (data.type !== "api" && data.type !== "ssh") { - throw new Error("type must be 'api' or 'ssh'"); + if (data.type !== "api" && data.type !== "ssh" && data.type !== "email") { + throw new Error("type must be 'api', 'ssh', or 'email'"); } let baseUrl: string | null = null; - let config: SshConfig | null = null; + let config: SshConfig | EmailConfig | null = null; + let email: string | null = null; if (data.type === "api") { baseUrl = data.base_url ?? ""; @@ -89,6 +125,11 @@ export async function createTarget(data: { if (urlError) throw new Error(urlError); } else if (data.type === "ssh") { config = validateSshConfig(data.config); + } else if (data.type === "email") { + config = validateEmailConfig(data.config); + const emailVal = typeof data.email === "string" ? data.email.trim() : ""; + if (!emailVal) throw new Error("email is required for email targets"); + email = emailVal; } const slug = slugify(name); @@ -97,7 +138,7 @@ export async function createTarget(data: { try { const [row] = await db .insert(targets) - .values({ name, slug, type: data.type, baseUrl, config }) + .values({ name, slug, type: data.type, baseUrl, config, email }) .returning(); return row; } catch (err: unknown) { @@ -112,9 +153,10 @@ export async function updateTarget( id: string, data: { name?: string; - type?: "api" | "ssh"; + type?: "api" | "ssh" | "email"; base_url?: string | null; - config?: SshConfig | null; + config?: SshConfig | EmailConfig | null; + email?: string | null; enabled?: boolean; }, ) { @@ -138,8 +180,8 @@ export async function updateTarget( } if (data.type !== undefined) { - if (data.type !== "api" && data.type !== "ssh") { - throw new Error("type must be 'api' or 'ssh'"); + if (data.type !== "api" && data.type !== "ssh" && data.type !== "email") { + throw new Error("type must be 'api', 'ssh', or 'email'"); } updates.type = data.type; } @@ -160,7 +202,23 @@ export async function updateTarget( if (data.config === null) { updates.config = null; } else { - updates.config = validateSshConfig(data.config); + // Determine config type: if config has imap/smtp keys it's EmailConfig + const c = data.config as Record; + if ("imap" in c || "smtp" in c) { + updates.config = validateEmailConfig(data.config); + } else { + updates.config = validateSshConfig(data.config); + } + } + } + + if (data.email !== undefined) { + if (data.email === null) { + updates.email = null; + } else { + const emailVal = data.email.trim(); + if (!emailVal) throw new Error("email must be a non-empty string or null"); + updates.email = emailVal; } } From 6aa8db0d1b55e4199696eeda3642be375e3523cd Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:34:30 +0200 Subject: [PATCH 06/15] feat(mail): add imap_smtp auth method type with credential hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add "imap_smtp" to VALID_TYPES array - Add imap_smtp branch in computeCredentialHint: parse JSON, display "IMAP/SMTP ••• {username}" when username present, fallback to "IMAP/SMTP credentials" Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/services/auth-methods.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/lib/server/services/auth-methods.ts b/src/lib/server/services/auth-methods.ts index 486503f..4d7e12c 100644 --- a/src/lib/server/services/auth-methods.ts +++ b/src/lib/server/services/auth-methods.ts @@ -2,7 +2,7 @@ import { and, eq } from "drizzle-orm"; import { db } from "../db"; import { targetAuthMethods } from "../db/schema"; -const VALID_TYPES = ["bearer", "basic", "custom_header", "query_param", "ssh_key", "jwt_es256", "oauth2_refresh_token", "json_body"]; +const VALID_TYPES = ["bearer", "basic", "custom_header", "query_param", "ssh_key", "jwt_es256", "oauth2_refresh_token", "json_body", "imap_smtp"]; export function computeCredentialHint(credential: string, type?: string): string { if (type === "custom_header") { @@ -52,6 +52,15 @@ export function computeCredentialHint(credential: string, type?: string): string return "JSON Body (invalid)"; } } + if (type === "imap_smtp") { + try { + const config = JSON.parse(credential); + if (config.username) return `IMAP/SMTP ••• ${config.username}`; + return "IMAP/SMTP credentials"; + } catch { + return "IMAP/SMTP credentials"; + } + } if (credential.length < 10) return "••••••••"; return `${credential.slice(0, 3)}••••••••${credential.slice(-4)}`; } From 7fb1b31ae0d1936ff1f0aa52cddc89dcc8335d76 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:38:51 +0200 Subject: [PATCH 07/15] feat(mail): add mail service with ImapFlow + Nodemailer Implements all 9 mail service functions (listFolders, search, getMessage, getAttachment, send, createDraft, moveMessage, flagMessage, testConnection) with connect-per-request pattern and proper mailbox locking. Installs mailparser for MIME parsing. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 281 +++++++++++++++++ package.json | 2 + src/lib/server/services/mail.ts | 526 ++++++++++++++++++++++++++++++++ 3 files changed, 809 insertions(+) create mode 100644 src/lib/server/services/mail.ts diff --git a/package-lock.json b/package-lock.json index ca1e2ad..e44f007 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", "imapflow": "^1.3.7", + "mailparser": "^3.9.9", "mode-watcher": "^1.1.0", "nodemailer": "^8.0.10", "postgres": "^3.4.8", @@ -31,6 +32,7 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@testcontainers/postgresql": "^11.13.0", + "@types/mailparser": "^3.4.6", "@types/node": "^25.5.0", "@types/nodemailer": "^8.0.0", "@types/ssh2": "^1.15.5", @@ -1686,6 +1688,22 @@ "win32" ] }, + "node_modules/@selderee/plugin-htmlparser2": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.12.0.tgz", + "integrity": "sha512-oELmoyA6ML9jDRMV3kgcMQFKxUfBU0yFVn6yTctVaLT5ygXnxH52I3TZEgV9EhXJC68/uFvE5Daj1/25c0Xa/A==", + "license": "MIT", + "dependencies": { + "domelementtype": "~2.3.0", + "domhandler": "~5.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + }, + "peerDependencies": { + "selderee": "~0.12.0" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2129,6 +2147,30 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/mailparser": { + "version": "3.4.6", + "resolved": "https://registry.npmjs.org/@types/mailparser/-/mailparser-3.4.6.tgz", + "integrity": "sha512-wVV3cnIKzxTffaPH8iRnddX1zahbYB1ZEoAxyhoBo3TBCBuK6nZ8M8JYO/RhsCuuBVOw/DEN/t/ENbruwlxn6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "iconv-lite": "^0.6.3" + } + }, + "node_modules/@types/mailparser/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@types/node": { "version": "25.5.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", @@ -3261,6 +3303,15 @@ "node": ">=0.10.0" } }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3402,6 +3453,61 @@ "node": ">=6" } }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/drizzle-kit": { "version": "0.31.9", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz", @@ -3618,6 +3724,18 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -4158,6 +4276,15 @@ "node": ">= 0.4" } }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, "node_modules/hono": { "version": "4.12.15", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.15.tgz", @@ -4167,6 +4294,56 @@ "node": ">=16.9.0" } }, + "node_modules/html-to-text": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/html-to-text/-/html-to-text-10.0.0.tgz", + "integrity": "sha512-2OH59Gtprdczel+7Rxgpz9hGVJREaf8Lt1H4kZwWHpEn70VQKRuMNGsb2eDbwaTzrYzb0hheiOG1P7Dim0B4dQ==", + "license": "MIT", + "dependencies": { + "@selderee/plugin-htmlparser2": "~0.12.0", + "deepmerge-ts": "^7.1.5", + "dom-serializer": "^2.0.0", + "htmlparser2": "^10.1.0", + "selderee": "~0.12.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -4455,6 +4632,15 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/leac": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/leac/-/leac-0.7.0.tgz", + "integrity": "sha512-qMrZeyEekgdRQ9o6a4NAB2EQZrv827GJdn1vnapwSJ90hWRB4TzUSunvacPkxQ2TnNqHNI1/zSt0hlo0crG8Jw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/libbase64": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/libbase64/-/libbase64-1.3.0.tgz", @@ -4728,6 +4914,25 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/linkify-it": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz", + "integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/locate-character": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", @@ -4787,6 +4992,24 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mailparser": { + "version": "3.9.9", + "resolved": "https://registry.npmjs.org/mailparser/-/mailparser-3.9.9.tgz", + "integrity": "sha512-ulZi7h1eKm8WQmXibIgj8dmMQGDQCUS/g+XHkxxjcLDq4Dwn2ppo+0hz5Fi+ltvu4eN7mh3ykIp5RcpiWWav1w==", + "license": "MIT", + "dependencies": { + "@zone-eu/mailsplit": "5.4.12", + "encoding-japanese": "2.2.0", + "he": "1.2.0", + "html-to-text": "10.0.0", + "iconv-lite": "0.7.2", + "libmime": "5.3.8", + "linkify-it": "5.0.1", + "nodemailer": "8.0.10", + "punycode.js": "2.3.1", + "tlds": "1.261.0" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5102,6 +5325,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parseley": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.13.1.tgz", + "integrity": "sha512-uNBJZzmb60l6p6VWLTmevizNAGnE0xoSf1n0B4q3ntegDNzcS68NRCcBDZTcyXHxt2XhBChsCuqj4M+nChvE/A==", + "license": "MIT", + "dependencies": { + "leac": "^0.7.0", + "peberminta": "^0.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -5171,6 +5407,15 @@ "node": ">= 14.16" } }, + "node_modules/peberminta": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.10.0.tgz", + "integrity": "sha512-80B2AsU+I4Qdb0ZAPSfe9UwvGzwkM37IKIFEvdS3D/3Ndgv2bsuJ0bfG1+iEYO+l7Gfd4EUJmuRyq7efLgRMzQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5395,6 +5640,15 @@ "once": "^1.3.1" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -5696,6 +5950,18 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/selderee": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/selderee/-/selderee-0.12.0.tgz", + "integrity": "sha512-b1YMh3+DHZp59DLna3qVwQ5iOla/nrI6mLBNW02XxU77M3046Df6VLkoaJyFz20VsGIG5kkp+FK0kg4K4HnUFw==", + "license": "MIT", + "dependencies": { + "parseley": "~0.13.1" + }, + "funding": { + "url": "https://github.com/sponsors/KillyMXI" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -6505,6 +6771,15 @@ "node": ">=14.0.0" } }, + "node_modules/tlds": { + "version": "1.261.0", + "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.261.0.tgz", + "integrity": "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==", + "license": "MIT", + "bin": { + "tlds": "bin.js" + } + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -6583,6 +6858,12 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/undici": { "version": "7.24.4", "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", diff --git a/package.json b/package.json index bb5d6cc..33e59e7 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@sveltejs/kit": "^2.50.2", "@sveltejs/vite-plugin-svelte": "^6.2.4", "@testcontainers/postgresql": "^11.13.0", + "@types/mailparser": "^3.4.6", "@types/node": "^25.5.0", "@types/nodemailer": "^8.0.0", "@types/ssh2": "^1.15.5", @@ -42,6 +43,7 @@ "clsx": "^2.1.1", "drizzle-orm": "^0.45.1", "imapflow": "^1.3.7", + "mailparser": "^3.9.9", "mode-watcher": "^1.1.0", "nodemailer": "^8.0.10", "postgres": "^3.4.8", diff --git a/src/lib/server/services/mail.ts b/src/lib/server/services/mail.ts new file mode 100644 index 0000000..63fc45e --- /dev/null +++ b/src/lib/server/services/mail.ts @@ -0,0 +1,526 @@ +import { ImapFlow } from "imapflow"; +import nodemailer from "nodemailer"; +import { simpleParser } from "mailparser"; +import type { EmailConfig } from "../db/schema"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface MailCredential { + username: string; + password: string; +} + +export interface FolderInfo { + path: string; + name: string; + delimiter: string; + specialUse?: string; +} + +export interface MessageSummary { + uid: number; + from: string; + to: string; + subject: string; + date: Date | null; + flags: string[]; + hasAttachments: boolean; +} + +export interface AttachmentMeta { + filename: string | null; + contentType: string; + size: number; +} + +export interface FullMessage { + uid: number; + from: string; + to: string; + cc: string; + subject: string; + date: Date | null; + text: string | null; + html: string | null; + flags: string[]; + attachments: AttachmentMeta[]; +} + +export interface AttachmentContent { + content: Buffer; + contentType: string; + filename: string | null; +} + +export interface SendResult { + messageId: string; +} + +export interface TestConnectionResult { + imap: boolean; + smtp: boolean; + error?: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function parseCredential(credential: string): MailCredential { + return JSON.parse(credential) as MailCredential; +} + +function makeImapClient(config: EmailConfig, cred: MailCredential): ImapFlow { + return new ImapFlow({ + host: config.imap.host, + port: config.imap.port, + secure: config.imap.secure, + auth: { user: cred.username, pass: cred.password }, + logger: false, + }); +} + +function envelopeAddresses( + addrs: import("imapflow").MessageAddressObject[] | undefined, +): string { + if (!addrs || addrs.length === 0) return ""; + return addrs + .map((a) => (a.name ? `${a.name} <${a.address ?? ""}>` : (a.address ?? ""))) + .join(", "); +} + +function hasAttachmentInStructure( + structure: import("imapflow").MessageStructureObject | undefined, +): boolean { + if (!structure) return false; + if (structure.disposition === "attachment") return true; + if (structure.childNodes) { + return structure.childNodes.some(hasAttachmentInStructure); + } + return false; +} + +// --------------------------------------------------------------------------- +// 1. listFolders +// --------------------------------------------------------------------------- + +export async function listFolders( + config: EmailConfig, + credential: string, +): Promise { + const cred = parseCredential(credential); + const client = makeImapClient(config, cred); + + try { + await client.connect(); + const mailboxes = await client.list(); + return mailboxes.map((mb) => ({ + path: mb.path, + name: mb.name, + delimiter: mb.delimiter ?? "/", + specialUse: mb.specialUse ?? undefined, + })); + } finally { + await client.logout(); + } +} + +// --------------------------------------------------------------------------- +// 2. search +// --------------------------------------------------------------------------- + +export interface SearchQuery { + folder?: string; + from?: string; + to?: string; + subject?: string; + since?: Date; + before?: Date; + text?: string; + limit?: number; +} + +export async function search( + config: EmailConfig, + credential: string, + query: SearchQuery, +): Promise { + const cred = parseCredential(credential); + const client = makeImapClient(config, cred); + const folder = query.folder ?? "INBOX"; + const limit = query.limit ?? 50; + + try { + await client.connect(); + + const lock = await client.getMailboxLock(folder); + try { + // Build ImapFlow search criteria + const criteria: Record = {}; + if (query.from) criteria.from = query.from; + if (query.to) criteria.to = query.to; + if (query.subject) criteria.subject = query.subject; + if (query.since) criteria.since = query.since; + if (query.before) criteria.before = query.before; + if (query.text) criteria.body = query.text; + + const searchCriteria = + Object.keys(criteria).length > 0 ? criteria : { all: true }; + + const result = await client.search(searchCriteria, { uid: true }); + // search() returns number[] | false + const uids: number[] = result === false ? [] : result; + + if (uids.length === 0) return []; + + // Take the most recent `limit` UIDs (largest UIDs = newest) + const slicedUids = uids.slice(-limit); + + const results: MessageSummary[] = []; + + for await (const msg of client.fetch( + slicedUids, + { uid: true, envelope: true, flags: true, bodyStructure: true }, + )) { + const env = msg.envelope; + const hasAttachments = hasAttachmentInStructure(msg.bodyStructure); + + results.push({ + uid: msg.uid, + from: envelopeAddresses(env?.from), + to: envelopeAddresses(env?.to), + subject: env?.subject ?? "", + date: env?.date ?? null, + flags: [...(msg.flags ?? [])], + hasAttachments, + }); + } + + // Return in descending date order (newest first) + return results.reverse(); + } finally { + lock.release(); + } + } finally { + await client.logout(); + } +} + +// --------------------------------------------------------------------------- +// 3. getMessage +// --------------------------------------------------------------------------- + +export async function getMessage( + config: EmailConfig, + credential: string, + params: { uid: number; folder?: string }, +): Promise { + const cred = parseCredential(credential); + const client = makeImapClient(config, cred); + const folder = params.folder ?? "INBOX"; + + try { + await client.connect(); + + const lock = await client.getMailboxLock(folder); + try { + const msgData = await client.fetchOne( + String(params.uid), + { uid: true, flags: true, source: true }, + { uid: true }, + ); + + if (!msgData || !msgData.source) return null; + + const parsed = await simpleParser(msgData.source, {}); + + return { + uid: msgData.uid, + from: parsed.from + ? parsed.from.value + .map((a) => (a.name ? `${a.name} <${a.address ?? ""}>` : (a.address ?? ""))) + .join(", ") + : "", + to: parsed.to + ? (Array.isArray(parsed.to) ? parsed.to : [parsed.to]) + .flatMap((ao) => ao.value) + .map((a) => (a.name ? `${a.name} <${a.address ?? ""}>` : (a.address ?? ""))) + .join(", ") + : "", + cc: parsed.cc + ? (Array.isArray(parsed.cc) ? parsed.cc : [parsed.cc]) + .flatMap((ao) => ao.value) + .map((a) => (a.name ? `${a.name} <${a.address ?? ""}>` : (a.address ?? ""))) + .join(", ") + : "", + subject: parsed.subject ?? "", + date: parsed.date ?? null, + text: parsed.text ?? null, + html: typeof parsed.html === "string" ? parsed.html : null, + flags: [...(msgData.flags ?? [])], + attachments: (parsed.attachments ?? []).map((a) => ({ + filename: a.filename ?? null, + contentType: a.contentType, + size: a.size ?? 0, + })), + }; + } finally { + lock.release(); + } + } finally { + await client.logout(); + } +} + +// --------------------------------------------------------------------------- +// 4. getAttachment +// --------------------------------------------------------------------------- + +export async function getAttachment( + config: EmailConfig, + credential: string, + params: { uid: number; partId: number; folder?: string }, +): Promise { + const cred = parseCredential(credential); + const client = makeImapClient(config, cred); + const folder = params.folder ?? "INBOX"; + + try { + await client.connect(); + + const lock = await client.getMailboxLock(folder); + try { + const msgData = await client.fetchOne( + String(params.uid), + { uid: true, source: true }, + { uid: true }, + ); + + if (!msgData || !msgData.source) return null; + + const parsed = await simpleParser(msgData.source, {}); + const attachments = parsed.attachments ?? []; + const idx = params.partId - 1; // partId is 1-based + + if (idx < 0 || idx >= attachments.length) return null; + + const att = attachments[idx]; + return { + content: att.content, + contentType: att.contentType, + filename: att.filename ?? null, + }; + } finally { + lock.release(); + } + } finally { + await client.logout(); + } +} + +// --------------------------------------------------------------------------- +// 5. send +// --------------------------------------------------------------------------- + +export interface SendParams { + to: string | string[]; + cc?: string | string[]; + bcc?: string | string[]; + subject: string; + text?: string; + html?: string; + inReplyTo?: string; +} + +export async function send( + config: EmailConfig, + credential: string, + params: SendParams, +): Promise { + const cred = parseCredential(credential); + + const transport = nodemailer.createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: config.smtp.secure, + auth: { user: cred.username, pass: cred.password }, + }); + + try { + const info = await transport.sendMail({ + from: cred.username, + to: params.to, + cc: params.cc, + bcc: params.bcc, + subject: params.subject, + text: params.text, + html: params.html, + inReplyTo: params.inReplyTo, + }); + + return { messageId: info.messageId }; + } finally { + transport.close(); + } +} + +// --------------------------------------------------------------------------- +// 6. createDraft +// --------------------------------------------------------------------------- + +export interface DraftParams { + to: string | string[]; + subject: string; + text?: string; + html?: string; +} + +export async function createDraft( + config: EmailConfig, + credential: string, + params: DraftParams, +): Promise { + const cred = parseCredential(credential); + + // Build raw MIME message with stream transport + const streamTransport = nodemailer.createTransport({ + streamTransport: true, + newline: "unix", + }); + + const info = await streamTransport.sendMail({ + from: cred.username, + to: params.to, + subject: params.subject, + text: params.text, + html: params.html, + }); + + const chunks: Buffer[] = []; + await new Promise((resolve, reject) => { + const stream = info.message as NodeJS.ReadableStream; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", resolve); + stream.on("error", reject); + }); + const rawMessage = Buffer.concat(chunks); + + // Find Drafts folder and append + const client = makeImapClient(config, cred); + try { + await client.connect(); + + const mailboxes = await client.list(); + const draftsMailbox = mailboxes.find( + (mb) => mb.specialUse === "\\Drafts" || /drafts/i.test(mb.name), + ); + const draftsPath = draftsMailbox?.path ?? "Drafts"; + + await client.append(draftsPath, rawMessage, ["\\Draft", "\\Seen"]); + } finally { + await client.logout(); + } +} + +// --------------------------------------------------------------------------- +// 7. moveMessage +// --------------------------------------------------------------------------- + +export async function moveMessage( + config: EmailConfig, + credential: string, + params: { uid: number; from: string; to: string }, +): Promise { + const cred = parseCredential(credential); + const client = makeImapClient(config, cred); + + try { + await client.connect(); + + const lock = await client.getMailboxLock(params.from); + try { + await client.messageMove(String(params.uid), params.to, { uid: true }); + } finally { + lock.release(); + } + } finally { + await client.logout(); + } +} + +// --------------------------------------------------------------------------- +// 8. flagMessage +// --------------------------------------------------------------------------- + +export async function flagMessage( + config: EmailConfig, + credential: string, + params: { uid: number; folder?: string; add?: string[]; remove?: string[] }, +): Promise { + const cred = parseCredential(credential); + const client = makeImapClient(config, cred); + const folder = params.folder ?? "INBOX"; + + try { + await client.connect(); + + const lock = await client.getMailboxLock(folder); + try { + if (params.add && params.add.length > 0) { + await client.messageFlagsAdd(String(params.uid), params.add, { uid: true }); + } + if (params.remove && params.remove.length > 0) { + await client.messageFlagsRemove(String(params.uid), params.remove, { uid: true }); + } + } finally { + lock.release(); + } + } finally { + await client.logout(); + } +} + +// --------------------------------------------------------------------------- +// 9. testConnection +// --------------------------------------------------------------------------- + +export async function testConnection( + config: EmailConfig, + credential: string, +): Promise { + const cred = parseCredential(credential); + let imapOk = false; + let smtpOk = false; + let errorMessage: string | undefined; + + // Test IMAP + const imapClient = makeImapClient(config, cred); + try { + await imapClient.connect(); + await imapClient.logout(); + imapOk = true; + } catch (err) { + errorMessage = err instanceof Error ? err.message : String(err); + } + + // Test SMTP + const smtpTransport = nodemailer.createTransport({ + host: config.smtp.host, + port: config.smtp.port, + secure: config.smtp.secure, + auth: { user: cred.username, pass: cred.password }, + }); + try { + await smtpTransport.verify(); + smtpOk = true; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + errorMessage = errorMessage ? `IMAP: ${errorMessage}; SMTP: ${msg}` : msg; + } finally { + smtpTransport.close(); + } + + return { imap: imapOk, smtp: smtpOk, error: errorMessage }; +} From efc89918158e3c1850c7619d361b98a8138fb49b Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:39:58 +0200 Subject: [PATCH 08/15] fix(mail): fix createDraft return type and make params optional Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/services/mail.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/server/services/mail.ts b/src/lib/server/services/mail.ts index 63fc45e..bcb9e56 100644 --- a/src/lib/server/services/mail.ts +++ b/src/lib/server/services/mail.ts @@ -371,8 +371,8 @@ export async function send( // --------------------------------------------------------------------------- export interface DraftParams { - to: string | string[]; - subject: string; + to?: string | string[]; + subject?: string; text?: string; html?: string; } @@ -381,7 +381,7 @@ export async function createDraft( config: EmailConfig, credential: string, params: DraftParams, -): Promise { +): Promise<{ uid: number | null }> { const cred = parseCredential(credential); // Build raw MIME message with stream transport @@ -418,7 +418,8 @@ export async function createDraft( ); const draftsPath = draftsMailbox?.path ?? "Drafts"; - await client.append(draftsPath, rawMessage, ["\\Draft", "\\Seen"]); + const result = await client.append(draftsPath, rawMessage, ["\\Draft", "\\Seen"]); + return { uid: result?.uid ?? null }; } finally { await client.logout(); } From ed13f9363f562d410d58b9dd9cdd450df21ad045 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:42:48 +0200 Subject: [PATCH 09/15] feat(mail): add all mail route handlers Add /mail/* agent-facing routes (search, getMessage, attachment, send, draft, folders, move, flag), shared resolve helper, and hooks auth bypass. Send route requires X-Shellgate-Approved: true, returns 202 with approval_required payload otherwise. Co-Authored-By: Claude Sonnet 4.6 --- src/hooks.server.ts | 1 + src/routes/mail/[target]/draft/+server.ts | 61 ++++++++++++ src/routes/mail/[target]/flag/+server.ts | 59 ++++++++++++ src/routes/mail/[target]/folders/+server.ts | 47 +++++++++ .../mail/[target]/message/[id]/+server.ts | 69 +++++++++++++ .../[id]/attachment/[partId]/+server.ts | 78 +++++++++++++++ src/routes/mail/[target]/move/+server.ts | 55 +++++++++++ src/routes/mail/[target]/search/+server.ts | 61 ++++++++++++ src/routes/mail/[target]/send/+server.ts | 96 +++++++++++++++++++ src/routes/mail/resolve.ts | 20 ++++ 10 files changed, 547 insertions(+) create mode 100644 src/routes/mail/[target]/draft/+server.ts create mode 100644 src/routes/mail/[target]/flag/+server.ts create mode 100644 src/routes/mail/[target]/folders/+server.ts create mode 100644 src/routes/mail/[target]/message/[id]/+server.ts create mode 100644 src/routes/mail/[target]/message/[id]/attachment/[partId]/+server.ts create mode 100644 src/routes/mail/[target]/move/+server.ts create mode 100644 src/routes/mail/[target]/search/+server.ts create mode 100644 src/routes/mail/[target]/send/+server.ts create mode 100644 src/routes/mail/resolve.ts diff --git a/src/hooks.server.ts b/src/hooks.server.ts index f16484a..5046f98 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -28,6 +28,7 @@ export const handle: Handle = async ({ event, resolve }) => { pathname.startsWith("/api/") || pathname.startsWith("/gateway/") || pathname.startsWith("/ssh/") || + pathname.startsWith("/mail/") || pathname.startsWith("/discovery") || pathname.startsWith("/bootstrap") || pathname.startsWith("/webhooks/") || diff --git a/src/routes/mail/[target]/draft/+server.ts b/src/routes/mail/[target]/draft/+server.ts new file mode 100644 index 0000000..ab426d3 --- /dev/null +++ b/src/routes/mail/[target]/draft/+server.ts @@ -0,0 +1,61 @@ +import { error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { createDraft } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; + +export const POST: RequestHandler = async ({ request, params, getClientAddress }) => { + const start = Date.now(); + const clientIp = getClientAddress(); + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") throw error(400, "Request body is required"); + + const recipients = body.to + ? Array.isArray(body.to) + ? body.to.join(", ") + : String(body.to) + : "(no recipients)"; + + try { + const result = await createDraft(config, credential, { + to: body.to, + subject: body.subject, + text: body.text, + html: body.html, + }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "draft", + path: recipients, + statusCode: 200, + clientIp, + durationMs: Date.now() - start, + }); + + return Response.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to create draft"; + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "draft", + path: recipients, + statusCode: 502, + clientIp, + durationMs: Date.now() - start, + }); + + throw error(502, message); + } +}; diff --git a/src/routes/mail/[target]/flag/+server.ts b/src/routes/mail/[target]/flag/+server.ts new file mode 100644 index 0000000..3518a7b --- /dev/null +++ b/src/routes/mail/[target]/flag/+server.ts @@ -0,0 +1,59 @@ +import { error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { flagMessage } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; + +export const POST: RequestHandler = async ({ request, params, getClientAddress }) => { + const start = Date.now(); + const clientIp = getClientAddress(); + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") throw error(400, "Request body is required"); + if (typeof body.uid !== "number") throw error(400, "uid is required"); + + const folder = typeof body.folder === "string" ? body.folder : undefined; + const pathContext = `${folder ?? "INBOX"}/${body.uid}`; + + try { + await flagMessage(config, credential, { + uid: body.uid, + folder, + add: Array.isArray(body.add) ? body.add : undefined, + remove: Array.isArray(body.remove) ? body.remove : undefined, + }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "flag", + path: pathContext, + statusCode: 200, + clientIp, + durationMs: Date.now() - start, + }); + + return Response.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to flag message"; + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "flag", + path: pathContext, + statusCode: 502, + clientIp, + durationMs: Date.now() - start, + }); + + throw error(502, message); + } +}; diff --git a/src/routes/mail/[target]/folders/+server.ts b/src/routes/mail/[target]/folders/+server.ts new file mode 100644 index 0000000..1e727fc --- /dev/null +++ b/src/routes/mail/[target]/folders/+server.ts @@ -0,0 +1,47 @@ +import { error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { listFolders } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; + +export const GET: RequestHandler = async ({ request, params, getClientAddress }) => { + const start = Date.now(); + const clientIp = getClientAddress(); + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + + try { + const folders = await listFolders(config, credential); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "folders", + path: null, + statusCode: 200, + clientIp, + durationMs: Date.now() - start, + }); + + return Response.json({ folders }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to list folders"; + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "folders", + path: null, + statusCode: 502, + clientIp, + durationMs: Date.now() - start, + }); + + throw error(502, message); + } +}; diff --git a/src/routes/mail/[target]/message/[id]/+server.ts b/src/routes/mail/[target]/message/[id]/+server.ts new file mode 100644 index 0000000..07a0202 --- /dev/null +++ b/src/routes/mail/[target]/message/[id]/+server.ts @@ -0,0 +1,69 @@ +import { error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../../resolve"; +import { getMessage } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; + +export const GET: RequestHandler = async ({ request, params, url, getClientAddress }) => { + const start = Date.now(); + const clientIp = getClientAddress(); + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + + const uid = parseInt(params.id, 10); + if (isNaN(uid)) throw error(400, "Invalid message UID"); + + const folder = url.searchParams.get("folder") ?? undefined; + + try { + const message = await getMessage(config, credential, { uid, folder }); + + if (!message) { + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "read", + path: `${folder ?? "INBOX"}/${uid}`, + statusCode: 404, + clientIp, + durationMs: Date.now() - start, + }); + throw error(404, "Message not found"); + } + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "read", + path: `${folder ?? "INBOX"}/${uid}`, + statusCode: 200, + clientIp, + durationMs: Date.now() - start, + }); + + return Response.json(message); + } catch (err) { + if ((err as { status?: number }).status) throw err; + const message = err instanceof Error ? err.message : "Failed to fetch message"; + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "read", + path: `${folder ?? "INBOX"}/${uid}`, + statusCode: 502, + clientIp, + durationMs: Date.now() - start, + }); + + throw error(502, message); + } +}; diff --git a/src/routes/mail/[target]/message/[id]/attachment/[partId]/+server.ts b/src/routes/mail/[target]/message/[id]/attachment/[partId]/+server.ts new file mode 100644 index 0000000..c19aa4a --- /dev/null +++ b/src/routes/mail/[target]/message/[id]/attachment/[partId]/+server.ts @@ -0,0 +1,78 @@ +import { error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../../../../resolve"; +import { getAttachment } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; + +export const GET: RequestHandler = async ({ request, params, url, getClientAddress }) => { + const start = Date.now(); + const clientIp = getClientAddress(); + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + + const uid = parseInt(params.id, 10); + if (isNaN(uid)) throw error(400, "Invalid message UID"); + + const partId = parseInt(params.partId, 10); + if (isNaN(partId)) throw error(400, "Invalid part ID"); + + const folder = url.searchParams.get("folder") ?? undefined; + + try { + const attachment = await getAttachment(config, credential, { uid, partId, folder }); + + if (!attachment) { + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "attachment", + path: `${folder ?? "INBOX"}/${uid}/attachment/${partId}`, + statusCode: 404, + clientIp, + durationMs: Date.now() - start, + }); + throw error(404, "Attachment not found"); + } + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "attachment", + path: `${folder ?? "INBOX"}/${uid}/attachment/${partId}`, + statusCode: 200, + clientIp, + durationMs: Date.now() - start, + }); + + const filename = attachment.filename ?? `attachment-${partId}`; + return new Response(attachment.content.buffer as ArrayBuffer, { + headers: { + "Content-Type": attachment.contentType, + "Content-Disposition": `attachment; filename="${filename}"`, + }, + }); + } catch (err) { + if ((err as { status?: number }).status) throw err; + const message = err instanceof Error ? err.message : "Failed to fetch attachment"; + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "attachment", + path: `${folder ?? "INBOX"}/${uid}/attachment/${partId}`, + statusCode: 502, + clientIp, + durationMs: Date.now() - start, + }); + + throw error(502, message); + } +}; diff --git a/src/routes/mail/[target]/move/+server.ts b/src/routes/mail/[target]/move/+server.ts new file mode 100644 index 0000000..e9e180e --- /dev/null +++ b/src/routes/mail/[target]/move/+server.ts @@ -0,0 +1,55 @@ +import { error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { moveMessage } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; + +export const POST: RequestHandler = async ({ request, params, getClientAddress }) => { + const start = Date.now(); + const clientIp = getClientAddress(); + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") throw error(400, "Request body is required"); + if (typeof body.uid !== "number") throw error(400, "uid is required"); + if (typeof body.from !== "string" || !body.from) throw error(400, "from folder is required"); + if (typeof body.to !== "string" || !body.to) throw error(400, "to folder is required"); + + const pathContext = `${body.from}/${body.uid} -> ${body.to}`; + + try { + await moveMessage(config, credential, { uid: body.uid, from: body.from, to: body.to }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "move", + path: pathContext, + statusCode: 200, + clientIp, + durationMs: Date.now() - start, + }); + + return Response.json({ success: true }); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to move message"; + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "move", + path: pathContext, + statusCode: 502, + clientIp, + durationMs: Date.now() - start, + }); + + throw error(502, message); + } +}; diff --git a/src/routes/mail/[target]/search/+server.ts b/src/routes/mail/[target]/search/+server.ts new file mode 100644 index 0000000..57bacbb --- /dev/null +++ b/src/routes/mail/[target]/search/+server.ts @@ -0,0 +1,61 @@ +import { error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { search } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; + +export const POST: RequestHandler = async ({ request, params, getClientAddress }) => { + const start = Date.now(); + const clientIp = getClientAddress(); + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") throw error(400, "Request body is required"); + + const query = { + folder: typeof body.folder === "string" ? body.folder : undefined, + from: typeof body.from === "string" ? body.from : undefined, + to: typeof body.to === "string" ? body.to : undefined, + subject: typeof body.subject === "string" ? body.subject : undefined, + since: body.since ? new Date(body.since) : undefined, + before: body.before ? new Date(body.before) : undefined, + text: typeof body.text === "string" ? body.text : undefined, + limit: typeof body.limit === "number" ? body.limit : undefined, + }; + + try { + const messages = await search(config, credential, query); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "search", + path: query.folder ?? "INBOX", + statusCode: 200, + clientIp, + durationMs: Date.now() - start, + }); + + return Response.json({ messages }); + } catch (err) { + const message = err instanceof Error ? err.message : "Mail search failed"; + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "search", + path: query.folder ?? "INBOX", + statusCode: 502, + clientIp, + durationMs: Date.now() - start, + }); + + throw error(502, message); + } +}; diff --git a/src/routes/mail/[target]/send/+server.ts b/src/routes/mail/[target]/send/+server.ts new file mode 100644 index 0000000..d9b8c75 --- /dev/null +++ b/src/routes/mail/[target]/send/+server.ts @@ -0,0 +1,96 @@ +import { error } from "@sveltejs/kit"; +import type { RequestHandler } from "./$types"; +import { resolveMailTarget } from "../../resolve"; +import { send } from "$lib/server/services/mail"; +import { logRequest } from "$lib/server/services/audit"; + +export const POST: RequestHandler = async ({ request, params, getClientAddress }) => { + const start = Date.now(); + const clientIp = getClientAddress(); + const { token, target, config, credential } = await resolveMailTarget(request, params.target); + + const body = await request.json().catch(() => null); + if (!body || typeof body !== "object") throw error(400, "Request body is required"); + if (!body.to) throw error(400, "to is required"); + if (!body.subject) throw error(400, "subject is required"); + + const recipients = Array.isArray(body.to) + ? body.to.join(", ") + : String(body.to); + + const isApproved = request.headers.get("X-Shellgate-Approved") === "true"; + + if (!isApproved) { + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "send", + path: recipients, + statusCode: 202, + clientIp, + durationMs: Date.now() - start, + guardAction: "approval_required", + guardReason: "Sending email requires explicit user approval", + }); + + return Response.json( + { + status: "approval_required", + reason: "Sending email requires explicit user approval", + request: { type: "mail", action: "send", to: body.to, subject: body.subject }, + next_action: + "STOP. Do NOT re-send this request yet. You MUST present the email details (recipients, subject, and body) to the user, explain what will be sent and why, then wait for the user to explicitly reply with approval. Only after the user responds confirming approval may you re-send the exact same request with the header X-Shellgate-Approved: true. If the user denies, abort. Never auto-approve.", + }, + { status: 202 }, + ); + } + + try { + const result = await send(config, credential, { + to: body.to, + cc: body.cc, + bcc: body.bcc, + subject: body.subject, + text: body.text, + html: body.html, + inReplyTo: body.inReplyTo, + }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "send", + path: recipients, + statusCode: 200, + clientIp, + durationMs: Date.now() - start, + guardAction: "approved", + }); + + return Response.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to send email"; + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: "send", + path: recipients, + statusCode: 502, + clientIp, + durationMs: Date.now() - start, + guardAction: "approved", + }); + + throw error(502, message); + } +}; diff --git a/src/routes/mail/resolve.ts b/src/routes/mail/resolve.ts new file mode 100644 index 0000000..2b141d2 --- /dev/null +++ b/src/routes/mail/resolve.ts @@ -0,0 +1,20 @@ +import { error } from "@sveltejs/kit"; +import { requireBearer } from "$lib/server/api-auth"; +import { getTargetBySlug } from "$lib/server/services/targets"; +import { hasPermission } from "$lib/server/services/permissions"; +import { getDefaultAuthMethod } from "$lib/server/services/auth-methods"; +import type { EmailConfig } from "$lib/server/db/schema"; + +export async function resolveMailTarget(request: Request, targetSlug: string) { + const token = await requireBearer(request); + const target = await getTargetBySlug(targetSlug); + if (!target || !target.enabled) throw error(404, "Target not found"); + if (target.type !== "email") throw error(400, "Target is not an email target"); + const permitted = await hasPermission(token.id, target.id); + if (!permitted) throw error(403, "Forbidden"); + const config = target.config as EmailConfig | null; + if (!config?.imap?.host || !config?.smtp?.host) throw error(400, "Target has no email configuration"); + const authMethod = await getDefaultAuthMethod(target.id); + if (!authMethod || authMethod.type !== "imap_smtp") throw error(400, "Target has no IMAP/SMTP credentials configured"); + return { token, target, config, credential: authMethod.credential }; +} From 34f8229b8326317d3002801a9035bcc44e6d7e05 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:45:13 +0200 Subject: [PATCH 10/15] feat(mail): register 8 MCP mail tools Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/mcp/server.ts | 140 ++++++++++ src/lib/server/mcp/tools/mail.ts | 462 +++++++++++++++++++++++++++++++ 2 files changed, 602 insertions(+) create mode 100644 src/lib/server/mcp/tools/mail.ts diff --git a/src/lib/server/mcp/server.ts b/src/lib/server/mcp/server.ts index dd4e72b..fc69415 100644 --- a/src/lib/server/mcp/server.ts +++ b/src/lib/server/mcp/server.ts @@ -14,6 +14,7 @@ import { skillList, skillRead, skillUpsert, skillDelete } from "./tools/skills"; import { memoryList, memoryRead, memoryAdd, memoryDelete } from "./tools/memories"; import { wikiListPages, wikiReadPage, wikiUpsertPage, wikiDeletePage, wikiLintPage } from "./tools/wiki"; import { vaultSearch } from "./tools/vaults"; +import { mailSearch, mailRead, mailAttachment, mailSend, mailDraft, mailFolders, mailMove, mailFlag } from "./tools/mail"; const INSTRUCTIONS = `Shellgate is your shared organization context layer. It complements — does not replace — your native memory, skill, and knowledge systems. Always read and write Shellgate for durable organizational information. @@ -342,6 +343,129 @@ When in doubt, prefer 'user' over 'org' — easier to promote later than to clea return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; } ); + + server.tool( + "mail_search", + "Search emails in a mailbox. Returns message list with uid, from, to, subject, date, flags.", + { + target: z.string().describe("Email target slug"), + folder: z.string().optional().describe("Folder (default: INBOX)"), + query: z.record(z.string(), z.string()).optional().describe("Search criteria: from, to, subject, since, before, text"), + limit: z.number().optional().describe("Max results (default: 20)"), + }, + async (args) => { + const result = await mailSearch(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_read", + "Read a full email message by UID. Returns from, to, cc, subject, date, text, html, flags, and attachment metadata.", + { + target: z.string().describe("Email target slug"), + uid: z.number().describe("Message UID"), + folder: z.string().optional().describe("Folder (default: INBOX)"), + }, + async (args) => { + const result = await mailRead(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_attachment", + "Download an email attachment by UID and part ID. Returns base64-encoded content.", + { + target: z.string().describe("Email target slug"), + uid: z.number().describe("Message UID"), + partId: z.number().describe("Attachment part ID (1-based)"), + folder: z.string().optional().describe("Folder (default: INBOX)"), + }, + async (args) => { + const result = await mailAttachment(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_send", + "Send an email. Requires approval — first call returns approval_required, re-call with approved: true after user confirms.", + { + target: z.string().describe("Email target slug"), + to: z.union([z.string(), z.array(z.string())]).describe("Recipient(s)"), + cc: z.union([z.string(), z.array(z.string())]).optional().describe("CC recipient(s)"), + bcc: z.union([z.string(), z.array(z.string())]).optional().describe("BCC recipient(s)"), + subject: z.string().describe("Email subject"), + text: z.string().optional().describe("Plain text body"), + html: z.string().optional().describe("HTML body"), + inReplyTo: z.string().optional().describe("Message-ID of the email being replied to"), + approved: z.preprocess(val => val === "true" || val === true, z.boolean()).optional().describe("Set to true after user explicitly approves sending"), + }, + async (args) => { + const result = await mailSend(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_draft", + "Create a draft email in the Drafts folder.", + { + target: z.string().describe("Email target slug"), + to: z.union([z.string(), z.array(z.string())]).optional().describe("Recipient(s)"), + subject: z.string().optional().describe("Email subject"), + text: z.string().optional().describe("Plain text body"), + html: z.string().optional().describe("HTML body"), + }, + async (args) => { + const result = await mailDraft(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_folders", + "List all folders/labels in the mailbox.", + { + target: z.string().describe("Email target slug"), + }, + async (args) => { + const result = await mailFolders(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_move", + "Move an email to a different folder.", + { + target: z.string().describe("Email target slug"), + uid: z.number().describe("Message UID"), + from: z.string().describe("Source folder"), + to: z.string().describe("Destination folder"), + }, + async (args) => { + const result = await mailMove(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); + + server.tool( + "mail_flag", + "Set or unset flags on an email (e.g. \\\\Seen, \\\\Flagged).", + { + target: z.string().describe("Email target slug"), + uid: z.number().describe("Message UID"), + folder: z.string().optional().describe("Folder (default: INBOX)"), + add: z.array(z.string()).optional().describe("Flags to add (e.g. [\"\\\\Seen\", \"\\\\Flagged\"])"), + remove: z.array(z.string()).optional().describe("Flags to remove"), + }, + async (args) => { + const result = await mailFlag(token, args); + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; + } + ); } type ToolHandler = (name: string, args: Record) => Promise; @@ -393,6 +517,22 @@ export function createMcpToolHandler(token: TokenLike): ToolHandler { return wikiLintPage(args as unknown as { namespace?: string; slug?: string; title?: string; body?: string; sources?: Array<{ type: string; title?: string; uri?: string; retrievedAt?: string }> }); case "vault_search": return vaultSearch(t, args as unknown as { query: string }); + case "mail_search": + return mailSearch(t, args as unknown as { target: string; folder?: string; query?: Record; limit?: number }); + case "mail_read": + return mailRead(t, args as unknown as { target: string; uid: number; folder?: string }); + case "mail_attachment": + return mailAttachment(t, args as unknown as { target: string; uid: number; partId: number; folder?: string }); + case "mail_send": + return mailSend(t, args as unknown as { target: string; to: string | string[]; cc?: string | string[]; bcc?: string | string[]; subject: string; text?: string; html?: string; inReplyTo?: string; approved?: boolean }); + case "mail_draft": + return mailDraft(t, args as unknown as { target: string; to?: string | string[]; subject?: string; text?: string; html?: string }); + case "mail_folders": + return mailFolders(t, args as unknown as { target: string }); + case "mail_move": + return mailMove(t, args as unknown as { target: string; uid: number; from: string; to: string }); + case "mail_flag": + return mailFlag(t, args as unknown as { target: string; uid: number; folder?: string; add?: string[]; remove?: string[] }); default: throw new Error(`Unknown tool: ${name}`); } diff --git a/src/lib/server/mcp/tools/mail.ts b/src/lib/server/mcp/tools/mail.ts new file mode 100644 index 0000000..66403e7 --- /dev/null +++ b/src/lib/server/mcp/tools/mail.ts @@ -0,0 +1,462 @@ +import type { Token } from "$lib/server/db/schema"; +import type { EmailConfig } from "$lib/server/db/schema"; +import { getTargetBySlug } from "$lib/server/services/targets"; +import { hasPermission } from "$lib/server/services/permissions"; +import { getDefaultAuthMethod } from "$lib/server/services/auth-methods"; +import { logRequest } from "$lib/server/services/audit"; +import * as mail from "$lib/server/services/mail"; + +// --------------------------------------------------------------------------- +// Target resolution +// --------------------------------------------------------------------------- + +async function resolveEmailTarget(token: Token, targetSlug: string) { + const target = await getTargetBySlug(targetSlug); + if (!target || !target.enabled) return { error: "target not found" }; + if (target.type !== "email") return { error: "target is not an email target" }; + const permitted = await hasPermission(token.id, target.id); + if (!permitted) return { error: "forbidden" }; + const config = target.config as EmailConfig | null; + if (!config?.imap?.host || !config?.smtp?.host) return { error: "target has no email configuration" }; + const authMethod = await getDefaultAuthMethod(target.id); + if (!authMethod || authMethod.type !== "imap_smtp") return { error: "target has no IMAP/SMTP credentials" }; + return { target, config, credential: authMethod.credential }; +} + +// --------------------------------------------------------------------------- +// Tool handlers +// --------------------------------------------------------------------------- + +export async function mailSearch(token: Token, args: { + target: string; + folder?: string; + query?: Record; + limit?: number; +}) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + + try { + const searchQuery: mail.SearchQuery = { + folder: args.folder, + limit: args.limit, + ...(args.query ? { + from: args.query.from, + to: args.query.to, + subject: args.query.subject, + text: args.query.text, + since: args.query.since ? new Date(args.query.since) : undefined, + before: args.query.before ? new Date(args.query.before) : undefined, + } : {}), + }; + + const results = await mail.search(config, credential, searchQuery); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: "search", + statusCode: 200, + clientIp: "mcp", + durationMs: null, + }); + + return { messages: results }; + } catch (err) { + const message = err instanceof Error ? err.message : "Mail search failed"; + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: "search", + statusCode: 500, + clientIp: "mcp", + durationMs: null, + }); + return { error: message }; + } +} + +export async function mailRead(token: Token, args: { + target: string; + uid: number; + folder?: string; +}) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + + try { + const message = await mail.getMessage(config, credential, { uid: args.uid, folder: args.folder }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: `read/${args.uid}`, + statusCode: message ? 200 : 404, + clientIp: "mcp", + durationMs: null, + }); + + if (!message) return { error: "message not found" }; + return message; + } catch (err) { + const message = err instanceof Error ? err.message : "Mail read failed"; + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: `read/${args.uid}`, + statusCode: 500, + clientIp: "mcp", + durationMs: null, + }); + return { error: message }; + } +} + +export async function mailAttachment(token: Token, args: { + target: string; + uid: number; + partId: number; + folder?: string; +}) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + + try { + const attachment = await mail.getAttachment(config, credential, { + uid: args.uid, + partId: args.partId, + folder: args.folder, + }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: `attachment/${args.uid}/${args.partId}`, + statusCode: attachment ? 200 : 404, + clientIp: "mcp", + durationMs: null, + }); + + if (!attachment) return { error: "attachment not found" }; + return { + filename: attachment.filename, + contentType: attachment.contentType, + content: attachment.content.toString("base64"), + }; + } catch (err) { + const message = err instanceof Error ? err.message : "Mail attachment failed"; + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: `attachment/${args.uid}/${args.partId}`, + statusCode: 500, + clientIp: "mcp", + durationMs: null, + }); + return { error: message }; + } +} + +export async function mailSend(token: Token, args: { + target: string; + to: string | string[]; + cc?: string | string[]; + bcc?: string | string[]; + subject: string; + text?: string; + html?: string; + inReplyTo?: string; + approved?: boolean; +}) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + + if (!args.approved) { + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: "send", + statusCode: 202, + clientIp: "mcp", + durationMs: null, + }); + return { + status: "approval_required", + request: { + target: args.target, + to: args.to, + cc: args.cc, + bcc: args.bcc, + subject: args.subject, + text: args.text, + html: args.html, + inReplyTo: args.inReplyTo, + }, + next_action: + "STOP. Present the email details to the user (recipients, subject, body preview). Wait for explicit approval. Only then re-call this SAME tool with all the SAME parameters AND set approved: true. If the user denies, abort. Never auto-approve.", + }; + } + + try { + const result = await mail.send(config, credential, { + to: args.to, + cc: args.cc, + bcc: args.bcc, + subject: args.subject, + text: args.text, + html: args.html, + inReplyTo: args.inReplyTo, + }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: "send", + statusCode: 200, + clientIp: "mcp", + durationMs: null, + }); + + return { messageId: result.messageId }; + } catch (err) { + const message = err instanceof Error ? err.message : "Mail send failed"; + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: "send", + statusCode: 500, + clientIp: "mcp", + durationMs: null, + }); + return { error: message }; + } +} + +export async function mailDraft(token: Token, args: { + target: string; + to?: string | string[]; + subject?: string; + text?: string; + html?: string; +}) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + + try { + const result = await mail.createDraft(config, credential, { + to: args.to, + subject: args.subject, + text: args.text, + html: args.html, + }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: "draft", + statusCode: 200, + clientIp: "mcp", + durationMs: null, + }); + + return { uid: result.uid }; + } catch (err) { + const message = err instanceof Error ? err.message : "Mail draft failed"; + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: "draft", + statusCode: 500, + clientIp: "mcp", + durationMs: null, + }); + return { error: message }; + } +} + +export async function mailFolders(token: Token, args: { + target: string; +}) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + + try { + const folders = await mail.listFolders(config, credential); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: "folders", + statusCode: 200, + clientIp: "mcp", + durationMs: null, + }); + + return { folders }; + } catch (err) { + const message = err instanceof Error ? err.message : "Mail folders failed"; + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: "folders", + statusCode: 500, + clientIp: "mcp", + durationMs: null, + }); + return { error: message }; + } +} + +export async function mailMove(token: Token, args: { + target: string; + uid: number; + from: string; + to: string; +}) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + + try { + await mail.moveMessage(config, credential, { uid: args.uid, from: args.from, to: args.to }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: `move/${args.uid}`, + statusCode: 200, + clientIp: "mcp", + durationMs: null, + }); + + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : "Mail move failed"; + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: `move/${args.uid}`, + statusCode: 500, + clientIp: "mcp", + durationMs: null, + }); + return { error: message }; + } +} + +export async function mailFlag(token: Token, args: { + target: string; + uid: number; + folder?: string; + add?: string[]; + remove?: string[]; +}) { + const resolved = await resolveEmailTarget(token, args.target); + if ("error" in resolved) return resolved; + const { target, config, credential } = resolved; + + try { + await mail.flagMessage(config, credential, { + uid: args.uid, + folder: args.folder, + add: args.add, + remove: args.remove, + }); + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: `flag/${args.uid}`, + statusCode: 200, + clientIp: "mcp", + durationMs: null, + }); + + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : "Mail flag failed"; + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "mail", + method: null, + path: `flag/${args.uid}`, + statusCode: 500, + clientIp: "mcp", + durationMs: null, + }); + return { error: message }; + } +} From 4ee521a687e728c8c619fe612da69263e405c6e0 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:45:38 +0200 Subject: [PATCH 11/15] feat(mail): include email address in bootstrap response for email targets Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/mcp/tools/bootstrap.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/server/mcp/tools/bootstrap.ts b/src/lib/server/mcp/tools/bootstrap.ts index f0c89ce..5f59182 100644 --- a/src/lib/server/mcp/tools/bootstrap.ts +++ b/src/lib/server/mcp/tools/bootstrap.ts @@ -23,6 +23,9 @@ export async function bootstrap(token: Token) { proxy: `/gateway/${target.slug}`, baseUrl: target.baseUrl, }), + ...(target.type === "email" && { + email: target.email, + }), }; }), ) From a0d9bb4c1e9ee70c82382f1d4b432a8c44798e06 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:53:42 +0200 Subject: [PATCH 12/15] feat(mail): add tabbed target list with type counts Replaces the flat target table with a tabbed view (API / SSH / Email) showing per-type counts. Installs the shadcn-svelte tabs component. Co-Authored-By: Claude Sonnet 4.6 --- src/lib/components/ui/tabs/index.ts | 18 + .../components/ui/tabs/tabs-content.svelte | 17 + src/lib/components/ui/tabs/tabs-list.svelte | 40 ++ .../components/ui/tabs/tabs-trigger.svelte | 23 + src/lib/components/ui/tabs/tabs.svelte | 19 + src/routes/(app)/targets/+page.server.ts | 32 +- src/routes/(app)/targets/+page.svelte | 496 +++++++++++++----- 7 files changed, 515 insertions(+), 130 deletions(-) create mode 100644 src/lib/components/ui/tabs/index.ts create mode 100644 src/lib/components/ui/tabs/tabs-content.svelte create mode 100644 src/lib/components/ui/tabs/tabs-list.svelte create mode 100644 src/lib/components/ui/tabs/tabs-trigger.svelte create mode 100644 src/lib/components/ui/tabs/tabs.svelte diff --git a/src/lib/components/ui/tabs/index.ts b/src/lib/components/ui/tabs/index.ts new file mode 100644 index 0000000..31267e5 --- /dev/null +++ b/src/lib/components/ui/tabs/index.ts @@ -0,0 +1,18 @@ +import Root from "./tabs.svelte"; +import Content from "./tabs-content.svelte"; +import List, { tabsListVariants, type TabsListVariant } from "./tabs-list.svelte"; +import Trigger from "./tabs-trigger.svelte"; + +export { + Root, + Content, + List, + Trigger, + tabsListVariants, + type TabsListVariant, + // + Root as Tabs, + Content as TabsContent, + List as TabsList, + Trigger as TabsTrigger, +}; diff --git a/src/lib/components/ui/tabs/tabs-content.svelte b/src/lib/components/ui/tabs/tabs-content.svelte new file mode 100644 index 0000000..394ab3e --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-content.svelte @@ -0,0 +1,17 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs-list.svelte b/src/lib/components/ui/tabs/tabs-list.svelte new file mode 100644 index 0000000..18dce00 --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-list.svelte @@ -0,0 +1,40 @@ + + + + + diff --git a/src/lib/components/ui/tabs/tabs-trigger.svelte b/src/lib/components/ui/tabs/tabs-trigger.svelte new file mode 100644 index 0000000..8a0be4a --- /dev/null +++ b/src/lib/components/ui/tabs/tabs-trigger.svelte @@ -0,0 +1,23 @@ + + + diff --git a/src/lib/components/ui/tabs/tabs.svelte b/src/lib/components/ui/tabs/tabs.svelte new file mode 100644 index 0000000..bfb900d --- /dev/null +++ b/src/lib/components/ui/tabs/tabs.svelte @@ -0,0 +1,19 @@ + + + diff --git a/src/routes/(app)/targets/+page.server.ts b/src/routes/(app)/targets/+page.server.ts index e558091..71a668c 100644 --- a/src/routes/(app)/targets/+page.server.ts +++ b/src/routes/(app)/targets/+page.server.ts @@ -22,7 +22,7 @@ export const actions = { create: async ({ request }) => { const data = await request.formData(); const name = data.get("name")?.toString()?.trim() ?? ""; - const type = (data.get("type")?.toString() ?? "api") as "api" | "ssh"; + const type = (data.get("type")?.toString() ?? "api") as "api" | "ssh" | "email"; if (!name) return fail(400, { error: "Name is required" }); if (type === "ssh") { @@ -37,6 +37,31 @@ export const actions = { } catch (err) { return fail(400, { error: err instanceof Error ? err.message : "Failed to create target" }); } + } else if (type === "email") { + const email = data.get("email")?.toString()?.trim() ?? ""; + const imap_host = data.get("imap_host")?.toString()?.trim() ?? ""; + const imap_port = parseInt(data.get("imap_port")?.toString() ?? "993", 10) || 993; + const imap_secure = data.get("imap_secure") === "on"; + const smtp_host = data.get("smtp_host")?.toString()?.trim() ?? ""; + const smtp_port = parseInt(data.get("smtp_port")?.toString() ?? "587", 10) || 587; + const smtp_secure = data.get("smtp_secure") === "on"; + if (!email) return fail(400, { error: "Email address is required" }); + if (!imap_host) return fail(400, { error: "IMAP host is required" }); + if (!smtp_host) return fail(400, { error: "SMTP host is required" }); + try { + const target = await createTarget({ + name, + type: "email", + email, + config: { + imap: { host: imap_host, port: imap_port, secure: imap_secure }, + smtp: { host: smtp_host, port: smtp_port, secure: smtp_secure }, + }, + }); + return { created: { ...target, enabled: target.enabled !== false } }; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : "Failed to create target" }); + } } else { const base_url = data.get("base_url")?.toString()?.trim() ?? ""; if (!base_url) return fail(400, { error: "Base URL is required" }); @@ -100,6 +125,11 @@ export const actions = { if (tokenUrl) config.tokenUrl = tokenUrl; credential = JSON.stringify(config); + } else if (type === "imap_smtp") { + const username = data.get("credential1")?.toString() ?? ""; + const password = data.get("credential2")?.toString() ?? ""; + if (!username || !password) return fail(400, { error: "Username and password are required" }); + credential = JSON.stringify({ username, password }); } else { credential = data.get("credential")?.toString() ?? ""; if (!credential) return fail(400, { error: "Credential is required" }); diff --git a/src/routes/(app)/targets/+page.svelte b/src/routes/(app)/targets/+page.svelte index f658ece..cf0f035 100644 --- a/src/routes/(app)/targets/+page.svelte +++ b/src/routes/(app)/targets/+page.svelte @@ -10,6 +10,7 @@ import * as Dialog from "$lib/components/ui/dialog/index.js"; import * as DropdownMenu from "$lib/components/ui/dropdown-menu/index.js"; import * as Breadcrumb from "$lib/components/ui/breadcrumb/index.js"; import * as Card from "$lib/components/ui/card/index.js"; +import * as Tabs from "$lib/components/ui/tabs/index.js"; import { Input } from "$lib/components/ui/input/index.js"; import { Label } from "$lib/components/ui/label/index.js"; import { Checkbox } from "$lib/components/ui/checkbox/index.js"; @@ -17,6 +18,7 @@ import PlusIcon from "@lucide/svelte/icons/plus"; import ServerIcon from "@lucide/svelte/icons/server"; import GlobeIcon from "@lucide/svelte/icons/globe"; import MonitorIcon from "@lucide/svelte/icons/monitor"; +import MailIcon from "@lucide/svelte/icons/mail"; import CheckIcon from "@lucide/svelte/icons/check"; import CopyIcon from "@lucide/svelte/icons/copy"; import EllipsisIcon from "@lucide/svelte/icons/ellipsis"; @@ -33,7 +35,8 @@ type Target = { slug: string; type: string; baseUrl: string | null; - config: { host: string; port: number; username: string } | null; + config: { host: string; port: number; username: string } | { imap: { host: string; port: number; secure: boolean }; smtp: { host: string; port: number; secure: boolean } } | null; + email: string | null; enabled: boolean; authMethodCount: number; createdAt: string | Date; @@ -47,6 +50,12 @@ let localTargets = $state(null); let targetList = $derived(localTargets ?? (data.targets as Target[])); let confirmDeleteId = $state(null); +// Tab state +let activeTab = $state("api"); +let apiTargets = $derived(targetList.filter((t) => t.type === "api")); +let sshTargets = $derived(targetList.filter((t) => t.type === "ssh")); +let emailTargets = $derived(targetList.filter((t) => t.type === "email")); + // Create dialog state let createOpen = $state(false); let createStep = $state(0); @@ -59,6 +68,18 @@ let copied = $state(false); let selectedTokenIds = $state>(new Set()); let grantAccessSubmitting = $state(false); +// Email create form state +let emailName = $state(''); +let emailAddress = $state(''); +let emailImapHost = $state(''); +let emailImapPort = $state('993'); +let emailImapSecure = $state(true); +let emailSmtpHost = $state(''); +let emailSmtpPort = $state('587'); +let emailSmtpSecure = $state(false); +let emailCredUsername = $state(''); +let emailCredPassword = $state(''); + function resetCreateState() { createStep = 0; createSubmitting = false; @@ -69,6 +90,16 @@ function resetCreateState() { copied = false; selectedTokenIds = new Set(); grantAccessSubmitting = false; + emailName = ''; + emailAddress = ''; + emailImapHost = ''; + emailImapPort = '993'; + emailImapSecure = true; + emailSmtpHost = ''; + emailSmtpPort = '587'; + emailSmtpSecure = false; + emailCredUsername = ''; + emailCredPassword = ''; } function updateTargetList(updater: (targets: Target[]) => Target[]) { @@ -88,6 +119,12 @@ async function copyToClipboard(text: string) { copied = true; setTimeout(() => (copied = false), 2000); } + +function getSshConfig(config: Target['config']): { host: string; port: number; username: string } | null { + if (!config) return null; + if ('host' in config && 'username' in config) return config as { host: string; port: number; username: string }; + return null; +} @@ -98,7 +135,7 @@ async function copyToClipboard(text: string) { Create Target Choose the type of target to create. -
+
+
{:else if createStep === 1} @@ -379,10 +427,142 @@ async function copyToClipboard(text: string) { + {:else if createStep === 9} + + Email Target Details + Configure the IMAP/SMTP connection for your email target. + +
{ + createSubmitting = true; + return async ({ result, update }) => { + createSubmitting = false; + if (result.type === 'success' && result.data?.created) { + const created = result.data.created as Target; + createdTarget = created; + updateTargetList((targets) => [...targets, created]); + createStep = 10; + } else if (result.type === 'failure') { + toast.error((result.data?.error as string) ?? 'Failed to create target'); + } + await update({ reset: true, invalidateAll: false }); + }; + }} + > + +
+
+ + +
+
+ + +
+
+

IMAP

+
+
+ + +
+
+ + +
+
+
+ (emailImapSecure = v === true)} /> + +
+
+
+

SMTP

+
+
+ + +
+
+ + +
+
+
+ (emailSmtpSecure = v === true)} /> + +
+
+
+ + +
+
+
+ {:else if createStep === 10} + + Add Email Credentials + Add IMAP/SMTP login credentials for {createdTarget?.name ?? 'your email target'}. + +
{ + authSubmitting = true; + return async ({ result, update }) => { + authSubmitting = false; + if (result.type === 'success' && result.data?.authMethodAdded) { + if (createdTarget) { + updateTargetList((targets) => targets.map((t) => + t.id === createdTarget!.id ? { ...t, authMethodCount: t.authMethodCount + 1 } : t + )); + } + createStep = (data.activeTokens as Token[]).length > 0 ? 7 : 8; + } else if (result.type === 'failure') { + toast.error((result.data?.error as string) ?? 'Failed to add credentials'); + } + await update({ reset: true, invalidateAll: false }); + }; + }} + > + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
{:else if createStep === 8} Target Ready - Your {createdTarget?.type === 'ssh' ? 'SSH target' : 'target'} is ready! + Your {createdTarget?.type === 'ssh' ? 'SSH target' : createdTarget?.type === 'email' ? 'email target' : 'target'} is ready!
@@ -410,6 +590,8 @@ async function copyToClipboard(text: string) {
+ {:else if createdTarget?.type === 'email'} +

Your email target is ready! You can now read and send emails via the MCP tools.

{:else}

Your target is ready! You can proxy requests using:

@@ -482,133 +664,189 @@ async function copyToClipboard(text: string) {
{:else} -
- - - - Name - Type - Connection - Auth Methods - Status - Actions - - - - {#each targetList as target (target.id)} - {#if confirmDeleteId === target.id} - - -
-

- Delete this target? This action cannot be undone. -

-
- -
{ - return async ({ result, update }) => { - if (result.type === 'success' && result.data?.deleted) { - const deletedId = result.data.deleted as string; - updateTargetList((targets) => targets.filter((t) => t.id !== deletedId)); - confirmDeleteId = null; - toast.success('Target deleted'); - } else if (result.type === 'failure') { - toast.error((result.data?.error as string) ?? 'Failed to delete target'); - } - await update({ reset: false, invalidateAll: false }); - }; - }} - > - - -
-
-
-
-
- {:else} - - - {target.name} - - - {target.type} - - - {#if target.type === 'ssh' && target.config} - {target.config.username}@{target.config.host}:{target.config.port} - {:else if target.baseUrl} - {target.baseUrl} - {:else} - - {/if} - - {target.authMethodCount} - - {#if target.enabled !== false} - Active + {#snippet tabTable(targets: Target[])} + + {#if targets.length === 0} +
+

No targets of this type yet.

+ +
+ {:else} +
+ + + + Name + Type + Connection + Auth Methods + Status + Actions + + + + {#each targets as target (target.id)} + {#if confirmDeleteId === target.id} + + +
+

+ Delete this target? This action cannot be undone. +

+
+ +
{ + return async ({ result, update }) => { + if (result.type === 'success' && result.data?.deleted) { + const deletedId = result.data.deleted as string; + updateTargetList((targets) => targets.filter((t) => t.id !== deletedId)); + confirmDeleteId = null; + toast.success('Target deleted'); + } else if (result.type === 'failure') { + toast.error((result.data?.error as string) ?? 'Failed to delete target'); + } + await update({ reset: false, invalidateAll: false }); + }; + }} + > + + +
+
+
+
+
{:else} - Disabled + + + {target.name} + + + {target.type} + + + {#if target.type === 'ssh'} + {@const sshCfg = getSshConfig(target.config)} + {#if sshCfg} + {sshCfg.username}@{sshCfg.host}:{sshCfg.port} + {:else} + + {/if} + {:else if target.type === 'email'} + {#if target.email} + {target.email} + {:else} + + {/if} + {:else if target.baseUrl} + {target.baseUrl} + {:else} + + {/if} + + {target.authMethodCount} + + {#if target.enabled !== false} + Active + {:else} + Disabled + {/if} + + + + + + {#snippet child({ props })} + + {/snippet} + + + goto(`/targets/${target.slug}`)}>Edit + { + (document.getElementById(`toggle-form-${target.id}`) as HTMLFormElement)?.requestSubmit(); + }} + > + {target.enabled !== false ? 'Disable' : 'Enable'} + + + (confirmDeleteId = target.id)} + > + Delete + + + + + {/if} - - - - - - {#snippet child({ props })} - - {/snippet} - - - goto(`/targets/${target.slug}`)}>Edit - { - (document.getElementById(`toggle-form-${target.id}`) as HTMLFormElement)?.requestSubmit(); - }} - > - {target.enabled !== false ? 'Disable' : 'Enable'} - - - (confirmDeleteId = target.id)} - > - Delete - - - - - - {/if} - {/each} -
-
-
+ {/each} +
+
+
+ {/if} + {/snippet} + + (activeTab = v)}> + + + API + {#if apiTargets.length > 0} + {apiTargets.length} + {/if} + + + SSH + {#if sshTargets.length > 0} + {sshTargets.length} + {/if} + + + Email + {#if emailTargets.length > 0} + {emailTargets.length} + {/if} + + + + {@render tabTable(apiTargets)} + + + {@render tabTable(sshTargets)} + + + {@render tabTable(emailTargets)} + + {/if} From b050f15e267c90781cff08aa295dfd669e672b27 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:53:47 +0200 Subject: [PATCH 13/15] feat(mail): add email config editing and test connection to target detail page Adds updateEmailConfig and testConnection server actions, a sheet for editing IMAP/SMTP settings, and a Test Connection card showing per-protocol IMAP/SMTP status on the target detail page. Co-Authored-By: Claude Sonnet 4.6 --- .../(app)/targets/[slug]/+page.server.ts | 56 +++- src/routes/(app)/targets/[slug]/+page.svelte | 294 +++++++++++++++--- 2 files changed, 304 insertions(+), 46 deletions(-) diff --git a/src/routes/(app)/targets/[slug]/+page.server.ts b/src/routes/(app)/targets/[slug]/+page.server.ts index 6567ca6..0eac71d 100644 --- a/src/routes/(app)/targets/[slug]/+page.server.ts +++ b/src/routes/(app)/targets/[slug]/+page.server.ts @@ -3,9 +3,11 @@ import { eq } from "drizzle-orm"; import { db } from "$lib/server/db"; import { tokenPermissions, tokens } from "$lib/server/db/schema"; import { getTargetBySlug, updateTarget } from "$lib/server/services/targets"; -import { listAuthMethods, createAuthMethod, updateAuthMethod, deleteAuthMethod, getAuthMethodCredential } from "$lib/server/services/auth-methods"; +import { listAuthMethods, createAuthMethod, updateAuthMethod, deleteAuthMethod, getAuthMethodCredential, getDefaultAuthMethod } from "$lib/server/services/auth-methods"; import { listTokens } from "$lib/server/services/tokens"; import { addPermission } from "$lib/server/services/permissions"; +import { testConnection as mailTestConnection } from "$lib/server/services/mail"; +import type { EmailConfig } from "$lib/server/db/schema"; import type { Actions, PageServerLoad } from "./$types"; export const load: PageServerLoad = async ({ params }) => { @@ -357,4 +359,56 @@ export const actions = { if (!result) return fail(404, { error: "Auth method not found" }); return { revealedCredential: { id, credential: result.credential } }; }, + + updateEmailConfig: async ({ request }) => { + const data = await request.formData(); + const id = data.get("id")?.toString() ?? ""; + const email = data.get("email")?.toString()?.trim() ?? ""; + const imap_host = data.get("imap_host")?.toString()?.trim() ?? ""; + const imap_port = parseInt(data.get("imap_port")?.toString() ?? "993", 10) || 993; + const imap_secure = data.get("imap_secure") === "on"; + const smtp_host = data.get("smtp_host")?.toString()?.trim() ?? ""; + const smtp_port = parseInt(data.get("smtp_port")?.toString() ?? "587", 10) || 587; + const smtp_secure = data.get("smtp_secure") === "on"; + + if (!email) return fail(400, { error: "Email address is required" }); + if (!imap_host) return fail(400, { error: "IMAP host is required" }); + if (!smtp_host) return fail(400, { error: "SMTP host is required" }); + + try { + const updated = await updateTarget(id, { + email, + config: { + imap: { host: imap_host, port: imap_port, secure: imap_secure }, + smtp: { host: smtp_host, port: smtp_port, secure: smtp_secure }, + }, + }); + if (!updated) return fail(404, { error: "Target not found" }); + return { updated: true, emailConfig: { email: updated.email, config: updated.config } }; + } catch (err) { + return fail(400, { error: err instanceof Error ? err.message : "Failed to update email config" }); + } + }, + + testConnection: async ({ params }) => { + const target = await getTargetBySlug(params.slug); + if (!target) return fail(404, { error: "Target not found" }); + if (target.type !== "email") return fail(400, { error: "Target is not an email target" }); + + const config = target.config as EmailConfig | null; + if (!config) return fail(400, { error: "Email config not set" }); + + const defaultAuth = await getDefaultAuthMethod(target.id); + if (!defaultAuth) return fail(400, { error: "No default auth method set — add credentials first" }); + + const credentialResult = await getAuthMethodCredential(target.id, defaultAuth.id); + if (!credentialResult) return fail(400, { error: "Could not retrieve credentials" }); + + try { + const testResult = await mailTestConnection(config, credentialResult.credential); + return { testResult }; + } catch (err) { + return { testResult: { imap: false, smtp: false, error: err instanceof Error ? err.message : String(err) } }; + } + }, } satisfies Actions; diff --git a/src/routes/(app)/targets/[slug]/+page.svelte b/src/routes/(app)/targets/[slug]/+page.svelte index 90ffa48..119b1b6 100644 --- a/src/routes/(app)/targets/[slug]/+page.svelte +++ b/src/routes/(app)/targets/[slug]/+page.svelte @@ -18,6 +18,8 @@ import PlusIcon from "@lucide/svelte/icons/plus"; import StarIcon from "@lucide/svelte/icons/star"; import CopyIcon from "@lucide/svelte/icons/copy"; import CheckIcon from "@lucide/svelte/icons/check"; +import CircleCheckIcon from "@lucide/svelte/icons/circle-check"; +import CircleXIcon from "@lucide/svelte/icons/circle-x"; import EllipsisIcon from "@lucide/svelte/icons/ellipsis"; import LoaderCircleIcon from "@lucide/svelte/icons/loader-circle"; import KeyIcon from "@lucide/svelte/icons/key"; @@ -25,13 +27,17 @@ import AuthMethodFields from "$lib/components/auth-method-fields.svelte"; import type { PageData } from "./$types"; +type SshConfig = { host: string; port: number; username: string }; +type EmailConfig = { imap: { host: string; port: number; secure: boolean }; smtp: { host: string; port: number; secure: boolean } }; + type Target = { id: string; name: string; slug: string; type: string; baseUrl: string | null; - config: { host: string; port: number; username: string } | null; + config: SshConfig | EmailConfig | null; + email: string | null; enabled: boolean; createdAt: string | Date; updatedAt: string | Date; @@ -67,7 +73,7 @@ let copied = $state(false); // Sheet state let sheetOpen = $state(false); let sheetMode = $state< - "rename" | "baseUrl" | "connection" | "addAuth" | "renameAuth" | "editAuth" + "rename" | "baseUrl" | "connection" | "addAuth" | "renameAuth" | "editAuth" | "emailConfig" >("rename"); let sheetSubmitting = $state(false); @@ -110,6 +116,19 @@ let editAuthType = $state("bearer"); // Delete confirmation let confirmDeleteAuthId = $state(null); +// Email config edit state +let editEmailAddress = $state(""); +let editImapHost = $state(""); +let editImapPort = $state("993"); +let editImapSecure = $state(true); +let editSmtpHost = $state(""); +let editSmtpPort = $state("587"); +let editSmtpSecure = $state(false); + +// Test connection state +let testConnectionSubmitting = $state(false); +let testResult = $state<{ imap: boolean; smtp: boolean; error?: string } | null>(null); + // View credential dialog let viewCredentialOpen = $state(false); let viewCredentialLabel = $state(""); @@ -148,13 +167,40 @@ function openBaseUrlSheet() { function openConnectionSheet() { sheetMode = "connection"; - editHost = target.config?.host ?? ""; - editPort = String(target.config?.port ?? 22); - editUsername = target.config?.username ?? ""; + const sshCfg = getSshConfig(target.config); + editHost = sshCfg?.host ?? ""; + editPort = String(sshCfg?.port ?? 22); + editUsername = sshCfg?.username ?? ""; sheetSubmitting = false; sheetOpen = true; } +function openEmailConfigSheet() { + sheetMode = "emailConfig"; + const emailCfg = getEmailConfig(target.config); + editEmailAddress = target.email ?? ""; + editImapHost = emailCfg?.imap.host ?? ""; + editImapPort = String(emailCfg?.imap.port ?? 993); + editImapSecure = emailCfg?.imap.secure ?? true; + editSmtpHost = emailCfg?.smtp.host ?? ""; + editSmtpPort = String(emailCfg?.smtp.port ?? 587); + editSmtpSecure = emailCfg?.smtp.secure ?? false; + sheetSubmitting = false; + sheetOpen = true; +} + +function getSshConfig(config: Target['config']): SshConfig | null { + if (!config) return null; + if ('host' in config && 'username' in config) return config as SshConfig; + return null; +} + +function getEmailConfig(config: Target['config']): EmailConfig | null { + if (!config) return null; + if ('imap' in config && 'smtp' in config) return config as EmailConfig; + return null; +} + function openAddAuthSheet() { sheetMode = "addAuth"; authLabel = ""; @@ -415,6 +461,78 @@ async function copyToClipboard(text: string) { + {:else if sheetMode === 'emailConfig'} + + Update Email Config + Change the IMAP/SMTP connection settings for this target. + +
{ + sheetSubmitting = true; + return async ({ result, update }) => { + sheetSubmitting = false; + if (result.type === 'success' && result.data?.updated) { + const { emailConfig } = result.data as { emailConfig: { email: string | null; config: unknown } }; + localTarget = { ...target, email: emailConfig.email, config: emailConfig.config as Target['config'] }; + sheetOpen = false; + toast.success('Email config updated'); + } else if (result.type === 'failure') { + toast.error((result.data?.error as string) ?? 'Failed to update'); + } + await update({ reset: false, invalidateAll: false }); + }; + }} + > + +
+
+ + +
+
+

IMAP

+
+
+ + +
+
+ + +
+
+
+ (editImapSecure = v === true)} /> + +
+
+
+

SMTP

+
+
+ + +
+
+ + +
+
+
+ (editSmtpSecure = v === true)} /> + +
+
+ +
+
{:else if sheetMode === 'editAuth'} Edit Auth Method @@ -506,14 +624,38 @@ async function copyToClipboard(text: string) {
Type
{target.type}
- {#if target.type === 'ssh' && target.config} + {#if target.type === 'ssh'} + {@const sshCfg = getSshConfig(target.config)}
Connection
- {target.config.username}@{target.config.host}:{target.config.port} + {#if sshCfg} + {sshCfg.username}@{sshCfg.host}:{sshCfg.port} + {:else} + + {/if}
+ {:else if target.type === 'email'} + {@const emailCfg = getEmailConfig(target.config)} +
+
Email Address
+
+ {target.email ?? '—'} + +
+
+ {#if emailCfg} +
+
IMAP
+
{emailCfg.imap.host}:{emailCfg.imap.port}{emailCfg.imap.secure ? ' (TLS)' : ''}
+
+
+
SMTP
+
{emailCfg.smtp.host}:{emailCfg.smtp.port}{emailCfg.smtp.secure ? ' (TLS)' : ''}
+
+ {/if} {:else}
Base URL
@@ -785,51 +927,113 @@ async function copyToClipboard(text: string) {
- -
- - - Quick Start - - -
-
- {#if target.type === 'ssh'} -
{`curl -X POST ${gatewayUrl}/ssh/${target.slug}/exec \
+		
+		
+ {#if target.type !== 'email'} + + + Quick Start + + +
+
+ {#if target.type === 'ssh'} +
{`curl -X POST ${gatewayUrl}/ssh/${target.slug}/exec \
   -H "Authorization: Bearer " \
   -H "Content-Type: application/json" \
   -d '{"command": "whoami"}'`}
- {:else} -
{`curl ${gatewayUrl}/gateway/${target.slug}/health \
+								{:else}
+									
{`curl ${gatewayUrl}/gateway/${target.slug}/health \
   -H "Authorization: Bearer "`}
- {/if} - +
+
+ {#if target.type === 'ssh'} +

Replace whoami with any command. Add "timeout": 30 for a custom timeout (max 60s).

+ {:else} +

Replace /health with any path your target API supports.

+ {/if} +
+
+ {/if} + + {#if target.type === 'email'} + + + Test Connection + + +

Verify that Shellgate can connect to the IMAP and SMTP servers using the stored credentials.

+
{ + testConnectionSubmitting = true; + testResult = null; + return async ({ result, update }) => { + testConnectionSubmitting = false; + if (result.type === 'success' && result.data?.testResult) { + testResult = result.data.testResult as { imap: boolean; smtp: boolean; error?: string }; + } else if (result.type === 'failure') { + toast.error((result.data?.error as string) ?? 'Test failed'); } - }} - > - {#if copied} - - {:else} - + await update({ reset: false, invalidateAll: false }); + }; + }} + > + -
-
- {#if target.type === 'ssh'} -

Replace whoami with any command. Add "timeout": 30 for a custom timeout (max 60s).

- {:else} -

Replace /health with any path your target API supports.

- {/if} - - + + {#if testResult} +
+
+ {#if testResult.imap} + + IMAP connected + {:else} + + IMAP failed + {/if} +
+
+ {#if testResult.smtp} + + SMTP connected + {:else} + + SMTP failed + {/if} +
+ {#if testResult.error} +

{testResult.error}

+ {/if} +
+ {/if} + + + {/if}
From a708c45649ff9fe37d02a7d3a919f509945196c7 Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:55:43 +0200 Subject: [PATCH 14/15] docs: update AGENTS.md with mail integration routes, tools, and service Co-Authored-By: Claude Opus 4.6 (1M context) --- AGENTS.md | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e1c823..5b92ec0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,12 +21,13 @@ src/routes/bootstrap/ ← Agent-facing: full session-start context (REST) | Service | Purpose | |---|---| -| `targets` | CRUD for API and SSH targets | +| `targets` | CRUD for API, SSH, and email targets | | `tokens` | API key generation, hashing, revocation | | `permissions` | Token ↔ target access control | -| `auth-methods` | Credentials stored per target (bearer, basic, custom_header, ssh_key) | +| `auth-methods` | Credentials stored per target (bearer, basic, custom_header, ssh_key, imap_smtp) | | `gateway` | Proxy logic: resolve target, inject auth, forward request | | `ssh` | SSH command execution via `ssh2` | +| `mail` | Email operations via ImapFlow (IMAP) + Nodemailer (SMTP) | | `audit` | Request logging to `audit_logs` table | | `users` | Dashboard user management (email + password) | | `webhook-endpoints` | CRUD for incoming webhook endpoint registrations | @@ -46,12 +47,12 @@ tokens ──── webhook_endpoints (one token can have multiple endpoints) └── webhook_events (pending/delivered/expired) users (dashboard login) -audit_logs (every gateway + SSH request) +audit_logs (every gateway + SSH + mail request) ``` -- Targets have `type: "api" | "ssh"`. API targets have `baseUrl`, SSH targets have `config` (JSONB: host, port, username). +- Targets have `type: "api" | "ssh" | "email"`. API targets have `baseUrl`, SSH targets have `config` (JSONB: host, port, username), email targets have `config` (JSONB: imap + smtp server settings) and `email` (mailbox address). - Cascade deletes: deleting a target removes its auth methods and permissions. -- Auth method types: `bearer`, `basic`, `custom_header`, `query_param`, `ssh_key`, `jwt_es256`, `oauth2_refresh_token`, `json_body`. +- Auth method types: `bearer`, `basic`, `custom_header`, `query_param`, `ssh_key`, `jwt_es256`, `oauth2_refresh_token`, `json_body`, `imap_smtp`. - Webhook endpoints are linked to tokens (agents), not targets. Each endpoint has a unique slug for its public URL. - Webhook events expire after 7 days. Agents poll and ACK events. - Webhook endpoints have optional `handlingInstructions` (plain text) that agents receive in the poll response. @@ -66,6 +67,14 @@ These are NOT behind dashboard auth — they use bearer token auth (`requireBear | `GET /discovery` | List targets accessible to this token | | `ALL /gateway/[target]/[...path]` | Proxy HTTP to upstream API, inject stored credentials | | `POST /ssh/[target]/exec` | Execute command on SSH target, return stdout/stderr/exitCode | +| `POST /mail/[target]/search` | Search emails in a mailbox | +| `GET /mail/[target]/message/[id]` | Read full email by UID | +| `GET /mail/[target]/message/[id]/attachment/[partId]` | Download email attachment | +| `POST /mail/[target]/send` | Send email (requires approval) | +| `POST /mail/[target]/draft` | Create draft email | +| `GET /mail/[target]/folders` | List mailbox folders | +| `POST /mail/[target]/move` | Move email to folder | +| `POST /mail/[target]/flag` | Set/unset email flags | | `GET /health` | Health check | | `GET /verify-connection` | Connection verification for agent setup | | `GET /api/skill` | Returns OpenClaw skill YAML | @@ -85,7 +94,7 @@ Behind session auth (cookie-based): | Route | Purpose | |---|---| | `/` | Dashboard overview (stats) | -| `/targets` | Manage API + SSH targets | +| `/targets` | Manage API + SSH + email targets | | `/targets/[slug]` | Target detail + auth methods | | `/api-keys` | Manage agent tokens + permissions | | `/api-keys/[id]` | Token detail | @@ -116,11 +125,20 @@ Behind session auth (cookie-based): 5. Connect via `ssh2`, execute, return `{ stdout, stderr, exitCode, durationMs }` 6. Log to `audit_logs` +### Mail operations +1. Same auth + permission flow as gateway +2. Validate target type is `email`, has IMAP/SMTP config +3. Require default auth method with type `imap_smtp` +4. Connect via ImapFlow (IMAP) or Nodemailer (SMTP) per request +5. Execute operation (search, read, send, draft, folders, move, flag, attachment) +6. `mail_send` requires explicit approval (hardcoded, not via guard engine) +7. Log to `audit_logs` with type `mail` + ### MCP Server Shellgate exposes all agent-facing functionality as an MCP server at `POST /mcp` using Streamable HTTP transport. Claude Code connects via `mcpServers` config in `~/.claude/settings.json`. -**Tools:** `bootstrap`, `discover` (alias), `api_request`, `api_download`, `ssh_exec`, `webhook_poll`, `webhook_ack`, `org_skill_list`, `org_skill_read`, `org_skill_upsert`, `org_skill_delete`, `memory_list`, `memory_read`, `memory_add`, `memory_delete`, `wiki_list_pages`, `wiki_read_page`, `wiki_upsert_page`, `wiki_delete_page`, `wiki_lint_page`, `vault_search` +**Tools:** `bootstrap`, `discover` (alias), `api_request`, `api_download`, `ssh_exec`, `mail_search`, `mail_read`, `mail_attachment`, `mail_send`, `mail_draft`, `mail_folders`, `mail_move`, `mail_flag`, `webhook_poll`, `webhook_ack`, `org_skill_list`, `org_skill_read`, `org_skill_upsert`, `org_skill_delete`, `memory_list`, `memory_read`, `memory_add`, `memory_delete`, `wiki_list_pages`, `wiki_read_page`, `wiki_upsert_page`, `wiki_delete_page`, `wiki_lint_page`, `vault_search` **Auth:** Same bearer token as REST endpoints. Passed via `Authorization` header. @@ -187,7 +205,7 @@ When adding new agent-facing features or data types to Shellgate, always check w - **Forms** use `use:enhance` with `invalidateAll: false` + local state updates. - **Dates** from Drizzle are `string | Date` — handle both in components. Use `formatDate` for SSR-safe rendering. - **DB** uses lazy proxy pattern — setting `DATABASE_URL` before first access is sufficient. -- **Hooks** (`hooks.server.ts`) run migrations on startup and handle auth routing (skip auth for `/api/`, `/gateway/`, `/ssh/`, `/discovery`). +- **Hooks** (`hooks.server.ts`) run migrations on startup and handle auth routing (skip auth for `/api/`, `/gateway/`, `/ssh/`, `/mail/`, `/discovery`). - **Onboarding flow**: no users → `/setup`, no tokens → `/onboarding`. ## Stack From c0f7f142c1855c4de32c18e713ffd0ad63fa7f3e Mon Sep 17 00:00:00 2001 From: Matthias 't Jong Date: Tue, 9 Jun 2026 09:56:13 +0200 Subject: [PATCH 15/15] fix(mail): fix TypeScript error in createDraft append result type Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/server/services/mail.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/server/services/mail.ts b/src/lib/server/services/mail.ts index bcb9e56..4f1133d 100644 --- a/src/lib/server/services/mail.ts +++ b/src/lib/server/services/mail.ts @@ -419,7 +419,7 @@ export async function createDraft( const draftsPath = draftsMailbox?.path ?? "Drafts"; const result = await client.append(draftsPath, rawMessage, ["\\Draft", "\\Seen"]); - return { uid: result?.uid ?? null }; + return { uid: (result && typeof result === "object" && "uid" in result) ? (result as { uid: number }).uid : null }; } finally { await client.logout(); }