diff --git a/services/mana-mail/CLAUDE.md b/services/mana-mail/CLAUDE.md deleted file mode 100644 index b30f76407..000000000 --- a/services/mana-mail/CLAUDE.md +++ /dev/null @@ -1,96 +0,0 @@ -# mana-mail - -Mail service for the Mana ecosystem. Provides JMAP-based email access to the self-hosted Stalwart mail server, account provisioning for `@mana.how` addresses, and REST API for the frontend mail module. - -## Tech Stack - -| Layer | Technology | -|-------|------------| -| **Runtime** | Bun | -| **Framework** | Hono | -| **Database** | PostgreSQL + Drizzle ORM (pgSchema `mail` in `mana_platform`) | -| **Mail Server** | Stalwart (JMAP + SMTP) | -| **Auth** | JWT validation via JWKS from mana-auth | - -## Quick Start - -```bash -# Start (requires PostgreSQL + Stalwart running) -bun run dev - -# Database -bun run db:push # Push schema -bun run db:studio # Open Drizzle Studio -``` - -## Port: 3042 - -## API Endpoints - -### Mail (JWT auth) - -| Method | Path | Description | -|--------|------|-------------| -| GET | `/api/v1/mail/threads` | Thread list (paginated, filter by mailbox) | -| GET | `/api/v1/mail/threads/:id` | Full thread with messages | -| PUT | `/api/v1/mail/messages/:id` | Update flags (read/star/archive) | -| POST | `/api/v1/mail/send` | Send email | -| GET | `/api/v1/mail/labels` | Mailbox/folder list | -| GET | `/api/v1/mail/accounts` | User's mail accounts | -| PUT | `/api/v1/mail/accounts/:id` | Update account settings | - -### Internal (X-Service-Key auth) - -| Method | Path | Description | -|--------|------|-------------| -| POST | `/api/v1/internal/mail/on-user-created` | Provision Stalwart account | -| POST | `/api/v1/internal/mail/on-user-deleted` | Deactivate account (Phase 2) | - -## Environment Variables - -```env -PORT=3042 -DATABASE_URL=postgresql://mana:devpassword@localhost:5432/mana_platform -MANA_AUTH_URL=http://localhost:3001 -MANA_SERVICE_KEY=dev-service-key -BASE_URL=http://localhost:3042 -STALWART_JMAP_URL=http://localhost:8080 -STALWART_ADMIN_USER=admin -STALWART_ADMIN_PASSWORD=ChangeMe123! -MAIL_DOMAIN=mana.how -SMTP_HOST=localhost -SMTP_PORT=587 -SMTP_USER=noreply -SMTP_PASSWORD=ManaNoReply2026! -SMTP_FROM=Mana -CORS_ORIGINS=http://localhost:5173,https://mana.how -``` - -## Database - -Schema: `mail.*` in `mana_platform` - -Tables: -- `mail.accounts` — User-to-Stalwart account mapping, display name, signature -- `mail.thread_metadata` — AI-generated summaries, categories, cross-module links (Phase 2) - -## Architecture - -``` -Browser → mana-mail (Hono, :3042) → Stalwart (JMAP, :8080) - → Stalwart (SMTP, :587) -``` - -Mail content lives in Stalwart. This service acts as an authenticated proxy that: -1. Maps Mana JWT users to Stalwart accounts -2. Translates REST calls to JMAP protocol -3. Caches AI metadata in PostgreSQL -4. Handles account provisioning on user registration - -## Account Provisioning - -When a user registers in mana-auth, a fire-and-forget POST hits `/api/v1/internal/mail/on-user-created`. The service: -1. Generates a `username@mana.how` address from the user's name/email -2. Creates a Stalwart account via Admin API (`POST /api/principal`) -3. Assigns the `user` role (required for JMAP/SMTP access) -4. Saves the mapping in `mail.accounts` diff --git a/services/mana-mail/Dockerfile b/services/mana-mail/Dockerfile deleted file mode 100644 index 71955bb90..000000000 --- a/services/mana-mail/Dockerfile +++ /dev/null @@ -1,40 +0,0 @@ -# Install stage: use node + pnpm to resolve workspace dependencies -FROM node:22-alpine AS installer - -RUN corepack enable && corepack prepare pnpm@9.15.0 --activate - -WORKDIR /app - -# Copy workspace structure -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ -COPY services/mana-mail/package.json ./services/mana-mail/ -COPY packages/shared-hono ./packages/shared-hono -COPY packages/shared-logger ./packages/shared-logger -COPY packages/shared-types ./packages/shared-types - -# Install only mana-mail and its workspace deps -RUN pnpm install --filter @mana/mail-service... --no-frozen-lockfile --ignore-scripts - -# Runtime stage: bun -FROM oven/bun:1 AS production - -WORKDIR /app - -# Copy installed deps from installer stage -COPY --from=installer /app/node_modules ./node_modules -COPY --from=installer /app/services/mana-mail/node_modules ./services/mana-mail/node_modules -COPY --from=installer /app/packages ./packages - -# Copy source -COPY services/mana-mail/package.json ./services/mana-mail/ -COPY services/mana-mail/src ./services/mana-mail/src -COPY services/mana-mail/tsconfig.json services/mana-mail/drizzle.config.ts ./services/mana-mail/ - -WORKDIR /app/services/mana-mail - -EXPOSE 3042 - -HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD bun -e "fetch('http://localhost:3042/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" - -CMD ["bun", "run", "src/index.ts"] diff --git a/services/mana-mail/drizzle.config.ts b/services/mana-mail/drizzle.config.ts deleted file mode 100644 index c78e175cb..000000000 --- a/services/mana-mail/drizzle.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'drizzle-kit'; - -export default defineConfig({ - schema: './src/db/schema/*.ts', - out: './drizzle', - dialect: 'postgresql', - dbCredentials: { - url: process.env.DATABASE_URL || 'postgresql://mana:devpassword@localhost:5432/mana_platform', - }, - // Scope to just the schemas mana-mail owns. Other services (mana-auth, - // mana-research) manage their own pgSchemas; pushing all would - // accidentally drop foreign rows on each service's next migration. - schemaFilter: ['mail', 'broadcast'], -}); diff --git a/services/mana-mail/package.json b/services/mana-mail/package.json deleted file mode 100644 index 26c400d86..000000000 --- a/services/mana-mail/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@mana/mail-service", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "bun run --hot src/index.ts", - "start": "bun run src/index.ts", - "db:push": "drizzle-kit push", - "db:generate": "drizzle-kit generate", - "db:studio": "drizzle-kit studio" - }, - "dependencies": { - "@mana/shared-hono": "workspace:*", - "drizzle-orm": "^0.38.3", - "hono": "^4.7.0", - "jose": "^6.1.2", - "juice": "^11.1.1", - "postgres": "^3.4.5", - "zod": "^3.24.0" - }, - "devDependencies": { - "drizzle-kit": "^0.30.4", - "typescript": "^5.9.3" - } -} diff --git a/services/mana-mail/src/config.ts b/services/mana-mail/src/config.ts deleted file mode 100644 index 1fe779705..000000000 --- a/services/mana-mail/src/config.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * Application configuration loaded from environment variables. - */ - -export interface Config { - port: number; - databaseUrl: string; - manaAuthUrl: string; - serviceKey: string; - baseUrl: string; - stalwart: { - jmapUrl: string; - adminUser: string; - adminPassword: string; - domain: string; - }; - smtp: { - host: string; - port: number; - user: string; - password: string; - from: string; - insecureTls: boolean; - }; - cors: { - origins: string[]; - }; - broadcast: { - /** HMAC secret for tracking tokens. Different from MANA_SERVICE_KEY - * because tracking tokens appear in public URLs — the blast - * radius of a leak is narrower with a dedicated secret. */ - trackingSecret: string; - maxRecipientsPerCampaign: number; - maxRecipientsPerHour: number; - /** Sleep between JMAP submits during bulk-send. Protects Stalwart - * + downstream relays from being hammered. Set via env var - * BROADCAST_SEND_THROTTLE_MS (default 150ms). */ - sendThrottleMs: number; - }; -} - -export function loadConfig(): Config { - const requiredEnv = (key: string, fallback?: string): string => { - const value = process.env[key] || fallback; - if (!value) throw new Error(`Missing required env var: ${key}`); - return value; - }; - - return { - port: parseInt(process.env.PORT || '3042', 10), - databaseUrl: requiredEnv( - 'DATABASE_URL', - 'postgresql://mana:devpassword@localhost:5432/mana_platform' - ), - manaAuthUrl: requiredEnv('MANA_AUTH_URL', 'http://localhost:3001'), - serviceKey: requiredEnv('MANA_SERVICE_KEY', 'dev-service-key'), - baseUrl: requiredEnv('BASE_URL', 'http://localhost:3042'), - stalwart: { - jmapUrl: requiredEnv('STALWART_JMAP_URL', 'http://localhost:8080'), - adminUser: requiredEnv('STALWART_ADMIN_USER', 'admin'), - adminPassword: requiredEnv('STALWART_ADMIN_PASSWORD', 'ChangeMe123!'), - domain: requiredEnv('MAIL_DOMAIN', 'mana.how'), - }, - smtp: { - host: process.env.SMTP_HOST || 'localhost', - port: parseInt(process.env.SMTP_PORT || '587', 10), - user: process.env.SMTP_USER || 'noreply', - password: process.env.SMTP_PASSWORD || '', - from: process.env.SMTP_FROM || 'Mana ', - insecureTls: process.env.SMTP_INSECURE_TLS === 'true', - }, - cors: { - origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','), - }, - broadcast: { - trackingSecret: requiredEnv( - 'BROADCAST_TRACKING_SECRET', - // Dev fallback — MUST be rotated in prod. The requiredEnv - // signature accepts a fallback but throws if both env + - // fallback are empty; the literal below keeps local dev - // working without forcing users to set the var. - 'dev-only-broadcast-secret-change-me' - ), - maxRecipientsPerCampaign: parseInt( - process.env.BROADCAST_MAX_RECIPIENTS_PER_CAMPAIGN || '5000', - 10 - ), - maxRecipientsPerHour: parseInt(process.env.BROADCAST_MAX_RECIPIENTS_PER_HOUR || '500', 10), - sendThrottleMs: parseInt(process.env.BROADCAST_SEND_THROTTLE_MS || '150', 10), - }, - }; -} diff --git a/services/mana-mail/src/db/connection.ts b/services/mana-mail/src/db/connection.ts deleted file mode 100644 index aa63e328e..000000000 --- a/services/mana-mail/src/db/connection.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Database connection using Drizzle ORM + postgres.js - */ - -import { drizzle } from 'drizzle-orm/postgres-js'; -import postgres from 'postgres'; -import * as schema from './schema/index'; - -let db: ReturnType> | null = null; - -export function getDb(databaseUrl: string) { - if (!db) { - const client = postgres(databaseUrl, { max: 10 }); - db = drizzle(client, { schema }); - } - return db; -} - -export type Database = ReturnType; diff --git a/services/mana-mail/src/db/schema/broadcast.ts b/services/mana-mail/src/db/schema/broadcast.ts deleted file mode 100644 index ef9507bfc..000000000 --- a/services/mana-mail/src/db/schema/broadcast.ts +++ /dev/null @@ -1,122 +0,0 @@ -/** - * Broadcast schema — server-side mirror of sent campaigns + tracking events. - * - * Content (subject, body, audience) lives in the webapp's Dexie + sync - * pipeline — that's the user-authored source. Here we track only what - * the server produces: per-recipient delivery rows + the open/click/ - * unsubscribe events that flow in from public tracking endpoints. - * - * Why server-only? - * - Event volume is high (opens can hit thousands per campaign); - * round-tripping through the sync layer would be pointless. - * - Events are write-once from public endpoints; they don't need - * multi-client reconciliation. - * - The user's webapp reads aggregate stats via a summary API, not - * the raw events table. - */ - -import { pgSchema, text, timestamp, jsonb, index, integer, bigserial } from 'drizzle-orm/pg-core'; - -export const broadcastSchema = pgSchema('broadcast'); - -// ─── Campaigns ─────────────────────────────────────────── - -/** Server-side echo of a sent campaign. Populated when bulk-send kicks off. - * Keeps just enough metadata to scope events + render audit views. */ -export const campaigns = broadcastSchema.table( - 'campaigns', - { - // Campaign id from the webapp (LocalCampaign.id) — we intentionally - // carry it through so Dexie-side + Postgres-side can be joined by - // a stable external key without an extra lookup. - id: text('id').primaryKey(), - userId: text('user_id').notNull(), - subject: text('subject'), - fromEmail: text('from_email'), - fromName: text('from_name'), - sentAt: timestamp('sent_at', { withTimezone: true }).notNull(), - totalRecipients: integer('total_recipients').notNull().default(0), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (t) => ({ - userIdx: index('broadcast_campaigns_user_idx').on(t.userId), - }) -); - -export type BroadcastCampaign = typeof campaigns.$inferSelect; -export type NewBroadcastCampaign = typeof campaigns.$inferInsert; - -// ─── Sends (per-recipient delivery record) ────────────── - -/** - * One row per (campaign × recipient). Status advances: - * queued → sent → delivered | bounced | failed - * any → unsubscribed (recipient opted out) - * - * `tracking_token` is a server-generated random nonce stored here; the - * HMAC-signed tokens that appear in URLs are derived from - * {campaignId, id, nonce} via the tracking-token service. Storing the - * nonce (not the signed token) means a leaked DB row alone can't be used - * to forge tracking hits. - */ -export const sends = broadcastSchema.table( - 'sends', - { - id: text('id').primaryKey(), - campaignId: text('campaign_id') - .notNull() - .references(() => campaigns.id, { onDelete: 'cascade' }), - recipientEmail: text('recipient_email').notNull(), - recipientName: text('recipient_name'), - /** Stable FK back to the user's contact if the segment pulled from - * contacts; null for ad-hoc lists. Sync key, not authoritative. */ - recipientContactId: text('recipient_contact_id'), - trackingNonce: text('tracking_nonce').notNull(), - status: text('status').notNull().default('queued'), - sentAt: timestamp('sent_at', { withTimezone: true }), - bouncedAt: timestamp('bounced_at', { withTimezone: true }), - bounceReason: text('bounce_reason'), - unsubscribedAt: timestamp('unsubscribed_at', { withTimezone: true }), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - }, - (t) => ({ - campaignIdx: index('broadcast_sends_campaign_idx').on(t.campaignId), - statusIdx: index('broadcast_sends_status_idx').on(t.status), - emailIdx: index('broadcast_sends_email_idx').on(t.recipientEmail), - }) -); - -export type BroadcastSend = typeof sends.$inferSelect; -export type NewBroadcastSend = typeof sends.$inferInsert; - -// ─── Events (opens, clicks, unsubscribes) ─────────────── - -/** - * Append-only event log. Every hit on a tracking endpoint becomes a row. - * Dedup happens at query time (COUNT DISTINCT on send_id + day) because - * trying to dedup at write time creates contention on the hot tracking - * path — a duplicate event row is cheaper than a transaction. - */ -export const events = broadcastSchema.table( - 'events', - { - id: bigserial('id', { mode: 'number' }).primaryKey(), - sendId: text('send_id') - .notNull() - .references(() => sends.id, { onDelete: 'cascade' }), - kind: text('kind').notNull(), // 'open' | 'click' | 'unsubscribe' - occurredAt: timestamp('occurred_at', { withTimezone: true }).defaultNow().notNull(), - /** HMAC hash — not PII, just for same-recipient dedup inside a window. */ - ipHash: text('ip_hash'), - userAgentHash: text('user_agent_hash'), - linkUrl: text('link_url'), - metadata: jsonb('metadata'), - }, - (t) => ({ - sendKindIdx: index('broadcast_events_send_kind_idx').on(t.sendId, t.kind), - occurredIdx: index('broadcast_events_occurred_idx').on(t.occurredAt), - }) -); - -export type BroadcastEvent = typeof events.$inferSelect; -export type NewBroadcastEvent = typeof events.$inferInsert; diff --git a/services/mana-mail/src/db/schema/index.ts b/services/mana-mail/src/db/schema/index.ts deleted file mode 100644 index 893f4146e..000000000 --- a/services/mana-mail/src/db/schema/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './mail'; -export * from './broadcast'; diff --git a/services/mana-mail/src/db/schema/mail.ts b/services/mana-mail/src/db/schema/mail.ts deleted file mode 100644 index 77c3cc9ce..000000000 --- a/services/mana-mail/src/db/schema/mail.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Mail schema — user mailbox settings and AI metadata cache. - * - * Actual mail content lives in Stalwart (JMAP). This schema stores: - * - Account mapping (mana userId → Stalwart account) - * - AI-generated metadata per thread (summaries, categories) - */ - -import { - pgSchema, - uuid, - text, - timestamp, - jsonb, - index, - boolean, - integer, -} from 'drizzle-orm/pg-core'; - -export const mailSchema = pgSchema('mail'); - -// ─── Accounts ─────────────────────────────────────────────── - -export const accounts = mailSchema.table( - 'accounts', - { - id: uuid('id').primaryKey().defaultRandom(), - userId: text('user_id').notNull(), - email: text('email').notNull().unique(), - displayName: text('display_name'), - provider: text('provider').default('stalwart').notNull(), - isDefault: boolean('is_default').default(true).notNull(), - signature: text('signature'), - stalwartAccountId: text('stalwart_account_id'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - userIdIdx: index('mail_accounts_user_id_idx').on(table.userId), - }) -); - -export type MailAccount = typeof accounts.$inferSelect; -export type NewMailAccount = typeof accounts.$inferInsert; - -// ─── Thread Metadata (AI cache) ───────────────────────────── - -export const threadMetadata = mailSchema.table( - 'thread_metadata', - { - id: uuid('id').primaryKey().defaultRandom(), - accountId: uuid('account_id') - .notNull() - .references(() => accounts.id), - threadId: text('thread_id').notNull(), - summary: text('summary'), - category: text('category'), - sentiment: text('sentiment'), - linkedItems: jsonb('linked_items'), - createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), - }, - (table) => ({ - accountThreadIdx: index('mail_thread_metadata_account_thread_idx').on( - table.accountId, - table.threadId - ), - }) -); - -export type ThreadMetadata = typeof threadMetadata.$inferSelect; -export type NewThreadMetadata = typeof threadMetadata.$inferInsert; diff --git a/services/mana-mail/src/index.ts b/services/mana-mail/src/index.ts deleted file mode 100644 index a2aeb84a0..000000000 --- a/services/mana-mail/src/index.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * mana-mail — Mail service for the Mana ecosystem. - * - * Hono + Bun runtime. Provides JMAP-based email access to Stalwart, - * account provisioning (@mana.how addresses), and mail API for the frontend. - */ - -import { Hono } from 'hono'; -import { cors } from 'hono/cors'; -import { loadConfig } from './config'; -import { getDb } from './db/connection'; -import { serviceErrorHandler as errorHandler } from '@mana/shared-hono'; -import { jwtAuth } from './middleware/jwt-auth'; -import { serviceAuth } from './middleware/service-auth'; -import { JmapClient } from './services/jmap-client'; -import { AccountService } from './services/account-service'; -import { MailService } from './services/mail-service'; -import { BroadcastOrchestrator } from './services/broadcast-orchestrator'; -import { healthRoutes } from './routes/health'; -import { createThreadRoutes } from './routes/threads'; -import { createMessageRoutes } from './routes/messages'; -import { createSendRoutes } from './routes/send'; -import { createLabelRoutes } from './routes/labels'; -import { createAccountRoutes } from './routes/accounts'; -import { createInternalRoutes } from './routes/internal'; -import { createBroadcastSendRoutes } from './routes/broadcast-send'; -import { createBroadcastTrackRoutes } from './routes/broadcast-track'; -import { createBroadcastStatsRoutes } from './routes/broadcast-stats'; -import { createBroadcastDnsRoutes } from './routes/broadcast-dns'; - -// ─── Bootstrap ────────────────────────────────────────────── - -const config = loadConfig(); -const db = getDb(config.databaseUrl); - -// Instantiate services -const jmapClient = new JmapClient(config.stalwart); -const accountService = new AccountService(db, config.stalwart); -const mailService = new MailService(db, jmapClient, accountService); -const broadcastOrchestrator = new BroadcastOrchestrator( - db, - jmapClient, - accountService, - config.broadcast.trackingSecret, - config.baseUrl, - config.broadcast.sendThrottleMs -); - -// ─── App ──────────────────────────────────────────────────── - -const app = new Hono(); - -// Global middleware -app.onError(errorHandler); -app.use( - '*', - cors({ - origin: config.cors.origins, - credentials: true, - }) -); - -// Health check (no auth) -app.route('/health', healthRoutes); - -// Public tracking routes — NO auth. Recipients click these from -// emails without being logged in. Mounted under /api/v1/track/* so -// they sit outside the /api/v1/mail/* JWT middleware. Registered -// BEFORE the JWT middleware to avoid middleware leakage. -app.route( - '/api/v1/track', - createBroadcastTrackRoutes(db, config.broadcast.trackingSecret, config.baseUrl) -); - -// User-facing routes (JWT auth) -app.use('/api/v1/mail/*', jwtAuth(config.manaAuthUrl)); -app.route('/api/v1/mail', createThreadRoutes(mailService)); -app.route('/api/v1/mail', createSendRoutes(mailService)); -app.route( - '/api/v1/mail', - createBroadcastSendRoutes(broadcastOrchestrator, config.broadcast.maxRecipientsPerCampaign) -); -app.route('/api/v1/mail', createBroadcastStatsRoutes(db)); -app.route('/api/v1/mail', createBroadcastDnsRoutes(config.stalwart.domain)); -app.route('/api/v1/mail', createLabelRoutes(mailService)); -app.route('/api/v1/mail', createAccountRoutes(accountService)); -app.route('/api/v1/mail/messages', createMessageRoutes(mailService)); - -// Service-to-service routes (X-Service-Key auth) -app.use('/api/v1/internal/*', serviceAuth(config.serviceKey)); -app.route( - '/api/v1/internal', - createInternalRoutes( - accountService, - broadcastOrchestrator, - config.broadcast.maxRecipientsPerCampaign - ) -); - -// ─── Start ────────────────────────────────────────────────── - -console.log(`mana-mail starting on port ${config.port}...`); - -export default { - port: config.port, - fetch: app.fetch, -}; diff --git a/services/mana-mail/src/lib/errors.ts b/services/mana-mail/src/lib/errors.ts deleted file mode 100644 index 453491bfa..000000000 --- a/services/mana-mail/src/lib/errors.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { HTTPException } from 'hono/http-exception'; - -export class BadRequestError extends HTTPException { - constructor(message: string) { - super(400, { message }); - } -} - -export class UnauthorizedError extends HTTPException { - constructor(message = 'Unauthorized') { - super(401, { message }); - } -} - -export class ForbiddenError extends HTTPException { - constructor(message = 'Forbidden') { - super(403, { message }); - } -} - -export class NotFoundError extends HTTPException { - constructor(message = 'Not found') { - super(404, { message }); - } -} - -export class ConflictError extends HTTPException { - constructor(message = 'Conflict') { - super(409, { message }); - } -} diff --git a/services/mana-mail/src/lib/validation.ts b/services/mana-mail/src/lib/validation.ts deleted file mode 100644 index eb4bc69d7..000000000 --- a/services/mana-mail/src/lib/validation.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Zod validation schemas for request bodies. - */ - -import { z } from 'zod'; - -export const sendEmailSchema = z.object({ - to: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).min(1), - cc: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).optional(), - bcc: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).optional(), - subject: z.string().min(1), - body: z.string().min(1), - htmlBody: z.string().optional(), - inReplyTo: z.string().optional(), - references: z.array(z.string()).optional(), -}); - -export const saveDraftSchema = z.object({ - to: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).optional(), - cc: z.array(z.object({ email: z.string().email(), name: z.string().optional() })).optional(), - subject: z.string().optional(), - body: z.string().optional(), - htmlBody: z.string().optional(), - inReplyTo: z.string().optional(), -}); - -export const updateMessageSchema = z.object({ - isRead: z.boolean().optional(), - isFlagged: z.boolean().optional(), - mailboxIds: z.record(z.boolean()).optional(), -}); - -export const updateAccountSchema = z.object({ - displayName: z.string().optional(), - signature: z.string().optional(), -}); - -export const onUserCreatedSchema = z.object({ - userId: z.string().min(1), - email: z.string().email(), - name: z.string().optional(), -}); diff --git a/services/mana-mail/src/middleware/jwt-auth.ts b/services/mana-mail/src/middleware/jwt-auth.ts deleted file mode 100644 index 894f2aad3..000000000 --- a/services/mana-mail/src/middleware/jwt-auth.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * JWT Authentication Middleware - * - * Validates Bearer tokens via JWKS from mana-auth. - * Uses jose library with EdDSA algorithm. - */ - -import type { MiddlewareHandler } from 'hono'; -import { createRemoteJWKSet, jwtVerify } from 'jose'; -import { UnauthorizedError } from '../lib/errors'; - -let jwks: ReturnType | null = null; - -function getJwks(authUrl: string) { - if (!jwks) { - jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl)); - } - return jwks; -} - -export interface AuthUser { - userId: string; - email: string; - role: string; -} - -/** - * Middleware that validates JWT tokens from Authorization: Bearer header. - * Sets c.set('user', { userId, email, role }) on success. - */ -export function jwtAuth(authUrl: string): MiddlewareHandler { - return async (c, next) => { - const authHeader = c.req.header('Authorization'); - if (!authHeader?.startsWith('Bearer ')) { - throw new UnauthorizedError('Missing or invalid Authorization header'); - } - - const token = authHeader.slice(7); - try { - const { payload } = await jwtVerify(token, getJwks(authUrl), { - issuer: authUrl, - audience: 'mana', - }); - - const user: AuthUser = { - userId: payload.sub || '', - email: (payload.email as string) || '', - role: (payload.role as string) || 'user', - }; - - c.set('user', user); - await next(); - } catch { - throw new UnauthorizedError('Invalid or expired token'); - } - }; -} diff --git a/services/mana-mail/src/middleware/service-auth.ts b/services/mana-mail/src/middleware/service-auth.ts deleted file mode 100644 index a1012a11d..000000000 --- a/services/mana-mail/src/middleware/service-auth.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Service-to-Service Authentication Middleware - * - * Validates X-Service-Key header for backend-to-backend calls. - * Used by /internal/* routes. - */ - -import type { MiddlewareHandler } from 'hono'; -import { UnauthorizedError } from '../lib/errors'; - -/** - * Middleware that validates X-Service-Key header. - * Sets c.set('appId', ...) from X-App-Id header. - */ -export function serviceAuth(serviceKey: string): MiddlewareHandler { - return async (c, next) => { - const key = c.req.header('X-Service-Key'); - if (!key || key !== serviceKey) { - throw new UnauthorizedError('Invalid or missing service key'); - } - - const appId = c.req.header('X-App-Id') || 'unknown'; - c.set('appId', appId); - await next(); - }; -} diff --git a/services/mana-mail/src/routes/accounts.ts b/services/mana-mail/src/routes/accounts.ts deleted file mode 100644 index 13383431f..000000000 --- a/services/mana-mail/src/routes/accounts.ts +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Account routes — mail account settings (JWT auth). - */ - -import { Hono } from 'hono'; -import type { AccountService } from '../services/account-service'; -import type { AuthUser } from '../middleware/jwt-auth'; -import { updateAccountSchema } from '../lib/validation'; - -export function createAccountRoutes(accountService: AccountService) { - return new Hono<{ Variables: { user: AuthUser } }>() - .get('/accounts', async (c) => { - const user = c.get('user'); - const accounts = await accountService.getAccounts(user.userId); - return c.json(accounts); - }) - .put('/accounts/:accountId', async (c) => { - const user = c.get('user'); - const accountId = c.req.param('accountId'); - const body = updateAccountSchema.parse(await c.req.json()); - const updated = await accountService.updateAccount(user.userId, accountId, body); - return c.json(updated); - }); -} diff --git a/services/mana-mail/src/routes/broadcast-dns.ts b/services/mana-mail/src/routes/broadcast-dns.ts deleted file mode 100644 index 7afd22a8a..000000000 --- a/services/mana-mail/src/routes/broadcast-dns.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * GET /v1/mail/dns-check?domain= — JWT auth. - * - * Returns the SPF / DKIM / DMARC status for the user's sending domain - * plus the exact records they should publish. Called on-demand from - * the broadcast settings UI. - */ - -import { Hono } from 'hono'; -import { z } from 'zod'; -import { checkDomain } from '../services/dns-check'; -import type { AuthUser } from '../middleware/jwt-auth'; - -const querySchema = z.object({ - domain: z - .string() - .min(3) - .regex(/^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}$/i, 'Domain sieht nicht valide aus'), - selector: z.string().optional(), -}); - -export function createBroadcastDnsRoutes(defaultMailDomain: string) { - return new Hono<{ Variables: { user: AuthUser } }>().get('/dns-check', async (c) => { - const parsed = querySchema.safeParse({ - domain: c.req.query('domain'), - selector: c.req.query('selector'), - }); - if (!parsed.success) { - return c.json({ error: parsed.error.issues[0]?.message ?? 'bad query' }, 400); - } - try { - const result = await checkDomain(parsed.data.domain, { - mailDomain: defaultMailDomain, - dkimSelector: parsed.data.selector, - }); - return c.json(result); - } catch (err) { - const reason = err instanceof Error ? err.message : String(err); - return c.json({ error: `DNS-Lookup fehlgeschlagen: ${reason}` }, 502); - } - }); -} diff --git a/services/mana-mail/src/routes/broadcast-send.ts b/services/mana-mail/src/routes/broadcast-send.ts deleted file mode 100644 index f9dd586fd..000000000 --- a/services/mana-mail/src/routes/broadcast-send.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * POST /v1/mail/bulk-send — JWT auth. - * - * The webapp resolves recipients client-side (contacts live in Dexie) and - * POSTs a flat list here. Hard-capped at config.broadcastMaxRecipients so - * a misbehaving client can't send 100k mails in one request. - */ - -import { Hono } from 'hono'; -import { z } from 'zod'; -import type { BroadcastOrchestrator } from '../services/broadcast-orchestrator'; -import type { AuthUser } from '../middleware/jwt-auth'; - -const recipientSchema = z.object({ - email: z.string().email(), - name: z.string().optional(), - contactId: z.string().optional(), -}); - -const bulkSendSchema = z.object({ - campaignId: z.string().min(1), - subject: z.string().min(1), - fromName: z.string().min(1), - fromEmail: z.string().email(), - replyTo: z.string().email().optional(), - htmlBody: z.string().min(1), - textBody: z.string().min(1), - recipients: z.array(recipientSchema).min(1).max(5000), -}); - -export function createBroadcastSendRoutes( - orchestrator: BroadcastOrchestrator, - maxRecipients: number -) { - return new Hono<{ Variables: { user: AuthUser } }>().post('/bulk-send', async (c) => { - const user = c.get('user'); - const body = bulkSendSchema.parse(await c.req.json()); - - if (body.recipients.length > maxRecipients) { - return c.json( - { - error: `Recipient count ${body.recipients.length} exceeds configured cap ${maxRecipients}`, - }, - 400 - ); - } - - const result = await orchestrator.run({ - userId: user.userId, - campaignId: body.campaignId, - subject: body.subject, - fromName: body.fromName, - fromEmail: body.fromEmail, - replyTo: body.replyTo, - htmlBody: body.htmlBody, - textBody: body.textBody, - recipients: body.recipients, - maxRecipients, - }); - - return c.json(result); - }); -} diff --git a/services/mana-mail/src/routes/broadcast-stats.ts b/services/mana-mail/src/routes/broadcast-stats.ts deleted file mode 100644 index e3752303e..000000000 --- a/services/mana-mail/src/routes/broadcast-stats.ts +++ /dev/null @@ -1,78 +0,0 @@ -/** - * GET /v1/mail/campaigns/:id/events — JWT auth. - * - * Aggregate stats for a campaign. Returns counts derived from the - * events table plus delivery status from sends. The webapp's - * BroadcastStats type mirrors this response shape. - */ - -import { Hono } from 'hono'; -import { eq, sql, and } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { campaigns, sends, events } from '../db/schema'; -import type { AuthUser } from '../middleware/jwt-auth'; - -export function createBroadcastStatsRoutes(db: Database) { - return new Hono<{ Variables: { user: AuthUser } }>().get('/campaigns/:id/events', async (c) => { - const user = c.get('user'); - const campaignId = c.req.param('id'); - - // Ownership check: only the campaign's creator sees its stats. - const campaign = await db - .select() - .from(campaigns) - .where(and(eq(campaigns.id, campaignId), eq(campaigns.userId, user.userId))) - .limit(1); - if (campaign.length === 0) { - return c.json({ error: 'not found' }, 404); - } - - // Aggregate delivery status counts. - const deliveryRows = await db - .select({ - status: sends.status, - count: sql`count(*)::int`, - }) - .from(sends) - .where(eq(sends.campaignId, campaignId)) - .groupBy(sends.status); - const delivery = Object.fromEntries(deliveryRows.map((r) => [r.status, r.count])) as Record< - string, - number - >; - - // Distinct-recipient event counts. COUNT(DISTINCT send_id) gives - // us the "unique opens / clicks" the user actually cares about; - // raw open counts include re-opens and image-proxy fetches. - const eventRows = await db - .select({ - kind: events.kind, - uniqueCount: sql`count(distinct ${events.sendId})::int`, - totalCount: sql`count(*)::int`, - }) - .from(events) - .innerJoin(sends, eq(events.sendId, sends.id)) - .where(eq(sends.campaignId, campaignId)) - .groupBy(events.kind); - const eventCounts = Object.fromEntries( - eventRows.map((r) => [r.kind, { unique: r.uniqueCount, total: r.totalCount }]) - ) as Record; - - return c.json({ - campaignId, - totalRecipients: campaign[0].totalRecipients, - delivery: { - queued: delivery.queued ?? 0, - sent: delivery.sent ?? 0, - delivered: delivery.delivered ?? 0, - bounced: delivery.bounced ?? 0, - failed: delivery.failed ?? 0, - unsubscribed: delivery.unsubscribed ?? 0, - }, - opens: eventCounts.open ?? { unique: 0, total: 0 }, - clicks: eventCounts.click ?? { unique: 0, total: 0 }, - unsubscribes: eventCounts.unsubscribe ?? { unique: 0, total: 0 }, - lastSyncedAt: new Date().toISOString(), - }); - }); -} diff --git a/services/mana-mail/src/routes/broadcast-track.ts b/services/mana-mail/src/routes/broadcast-track.ts deleted file mode 100644 index 3104acdcb..000000000 --- a/services/mana-mail/src/routes/broadcast-track.ts +++ /dev/null @@ -1,183 +0,0 @@ -/** - * Public tracking endpoints — NO auth (recipients aren't logged in). - * - * Verification happens via HMAC on the token in the URL. A leaked / forged - * token just silently falls through to a graceful response; we never - * reveal whether a token was recognised or not, because that would help - * an attacker probe the space. - * - * M4 status: tokens are signed and validated, but event persistence is - * a minimal stub — inserts with metadata only, no dedup / IP-hashing. - * M5 adds the full tracking pipeline (rate-limited dedup, user-agent - * hashing, bounce webhook integration). - */ - -import { Hono } from 'hono'; -import { createHash } from 'node:crypto'; -import { eq } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import { sends, events } from '../db/schema'; -import { verifyToken } from '../services/tracking-token'; - -// ─── Response helpers ─────────────────────────────────── - -/** - * 1×1 transparent GIF for the open-tracking pixel. Generated once — this - * is the smallest valid GIF that renders correctly in every mail client. - */ -const PIXEL_GIF = Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'); - -function pixelResponse(): Response { - return new Response(PIXEL_GIF, { - status: 200, - headers: { - 'content-type': 'image/gif', - 'content-length': String(PIXEL_GIF.byteLength), - 'cache-control': 'no-store, no-cache, must-revalidate, private', - pragma: 'no-cache', - expires: '0', - }, - }); -} - -function hashIp(ip: string): string { - return createHash('sha256').update(ip).digest('hex').slice(0, 16); -} - -function hashUserAgent(ua: string): string { - return createHash('sha256').update(ua).digest('hex').slice(0, 16); -} - -// ─── Routes ──────────────────────────────────────────── - -export function createBroadcastTrackRoutes(db: Database, trackingSecret: string, baseUrl: string) { - const app = new Hono(); - - /** - * GET /track/open/:token — 1×1 pixel. Always returns the pixel even - * on bad tokens so there's no signal to whoever's probing. - */ - app.get('/open/:token', async (c) => { - const token = c.req.param('token'); - const payload = verifyToken(token, trackingSecret); - if (!payload) return pixelResponse(); - - const ip = c.req.header('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown'; - const ua = c.req.header('user-agent') ?? ''; - - // Best-effort insert — if the DB is unreachable, we still return - // the pixel so the email displays correctly in the client. - try { - await db.insert(events).values({ - sendId: payload.sendId, - kind: 'open', - ipHash: hashIp(ip), - userAgentHash: hashUserAgent(ua), - }); - } catch { - // Swallow — see comment above. - } - - return pixelResponse(); - }); - - /** - * GET /track/click/:token?url=... — 302 to the original URL. Same - * graceful-fall-through on verification failure so a broken token - * doesn't strand the recipient on a dead page. - */ - app.get('/click/:token', async (c) => { - const token = c.req.param('token'); - const targetUrl = c.req.query('url'); - if (!targetUrl) return c.text('missing url', 400); - - // Validate target is http(s) to prevent open-redirect-to-javascript: - // et al. If it's not, refuse rather than bounce through. - if (!/^https?:\/\//i.test(targetUrl)) return c.text('bad url', 400); - - const payload = verifyToken(token, trackingSecret); - if (payload) { - try { - await db.insert(events).values({ - sendId: payload.sendId, - kind: 'click', - linkUrl: targetUrl, - ipHash: hashIp(c.req.header('x-forwarded-for')?.split(',')[0].trim() ?? 'unknown'), - userAgentHash: hashUserAgent(c.req.header('user-agent') ?? ''), - }); - } catch { - // Best-effort; continue to redirect. - } - } - - return c.redirect(targetUrl, 302); - }); - - /** - * GET /track/unsubscribe/:token — confirmation page + implicit - * one-click unsubscribe. - * - * RFC 8058 wants one-click via POST to this URL. We also handle GET - * so a plain anchor link works for older clients — but we still - * persist the unsubscribe on GET because the user actively clicked. - */ - app.get('/unsubscribe/:token', async (c) => { - const token = c.req.param('token'); - const payload = verifyToken(token, trackingSecret); - if (!payload) { - return c.html( - '

Ungültiger Abmelde-Link

Der Link ist entweder abgelaufen oder wurde manipuliert.

', - 400 - ); - } - - try { - await db - .update(sends) - .set({ status: 'unsubscribed', unsubscribedAt: new Date() }) - .where(eq(sends.id, payload.sendId)); - await db.insert(events).values({ - sendId: payload.sendId, - kind: 'unsubscribe', - }); - } catch { - // Still render the success page — the recipient did their part, - // db hiccups are our problem not theirs. - } - - return c.html( - 'Abgemeldet' + - '' + - '

Du wurdest abgemeldet

' + - '

Du bekommst von uns keine weiteren Newsletter mehr.

' + - '

Falls das ein Versehen war, antworte einfach auf eine unserer letzten E-Mails — wir kümmern uns darum.

' + - '' - ); - }); - - /** - * POST /track/unsubscribe/:token — RFC 8058 one-click unsubscribe. - * Same effect as GET but returns 204 so the client doesn't show a - * page (Gmail/Apple-Mail's native button calls this). - */ - app.post('/unsubscribe/:token', async (c) => { - const token = c.req.param('token'); - const payload = verifyToken(token, trackingSecret); - if (!payload) return c.text('', 400); - - try { - await db - .update(sends) - .set({ status: 'unsubscribed', unsubscribedAt: new Date() }) - .where(eq(sends.id, payload.sendId)); - await db.insert(events).values({ sendId: payload.sendId, kind: 'unsubscribe' }); - } catch { - return c.text('', 500); - } - - return c.text('', 204); - }); - - void baseUrl; // reserved for future asset URLs - return app; -} diff --git a/services/mana-mail/src/routes/health.ts b/services/mana-mail/src/routes/health.ts deleted file mode 100644 index 8766ebd32..000000000 --- a/services/mana-mail/src/routes/health.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Hono } from 'hono'; - -export const healthRoutes = new Hono().get('/', (c) => - c.json({ status: 'ok', service: 'mana-mail', timestamp: new Date().toISOString() }) -); diff --git a/services/mana-mail/src/routes/internal.ts b/services/mana-mail/src/routes/internal.ts deleted file mode 100644 index 9173b2e3d..000000000 --- a/services/mana-mail/src/routes/internal.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Internal routes — service-to-service (X-Service-Key auth). - */ - -import { Hono } from 'hono'; -import { z } from 'zod'; -import type { AccountService } from '../services/account-service'; -import type { BroadcastOrchestrator } from '../services/broadcast-orchestrator'; -import { onUserCreatedSchema } from '../lib/validation'; - -const recipientSchema = z.object({ - email: z.string().email(), - name: z.string().optional(), - contactId: z.string().optional(), -}); - -/** - * Internal bulk-send (M10d, headless wave-cron): - * Same payload-shape as the user-facing /api/v1/mail/bulk-send, but - * the userId comes from the body instead of a JWT — the caller is a - * trusted Mana service (apps/api forms wave-worker). The X-Service-Key - * gate sits at the route prefix in index.ts; we additionally require - * the body to name a userId so audit-logs always carry a principal. - */ -const internalBulkSendSchema = z.object({ - userId: z.string().min(1), - campaignId: z.string().min(1), - subject: z.string().min(1), - fromName: z.string().min(1), - fromEmail: z.string().email(), - replyTo: z.string().email().optional(), - htmlBody: z.string().min(1), - textBody: z.string().min(1), - recipients: z.array(recipientSchema).min(1).max(5000), -}); - -export function createInternalRoutes( - accountService: AccountService, - broadcastOrchestrator: BroadcastOrchestrator, - maxBroadcastRecipients: number -) { - return new Hono() - .post('/mail/on-user-created', async (c) => { - const body = onUserCreatedSchema.parse(await c.req.json()); - try { - const account = await accountService.provisionAccount(body.userId, body.email, body.name); - console.log(`[mana-mail] Provisioned ${account.email} for user ${body.userId}`); - return c.json({ success: true, email: account.email }); - } catch (err) { - console.error(`[mana-mail] Failed to provision account for ${body.userId}:`, err); - return c.json( - { success: false, error: err instanceof Error ? err.message : 'Unknown error' }, - 500 - ); - } - }) - .post('/mail/on-user-deleted', async (c) => { - // Phase 2: Deactivate Stalwart account - return c.json({ success: true, message: 'Not yet implemented' }); - }) - .post('/mail/bulk-send', async (c) => { - const body = internalBulkSendSchema.parse(await c.req.json()); - if (body.recipients.length > maxBroadcastRecipients) { - return c.json( - { - error: `Recipient count ${body.recipients.length} exceeds configured cap ${maxBroadcastRecipients}`, - }, - 400 - ); - } - const result = await broadcastOrchestrator.run({ - userId: body.userId, - campaignId: body.campaignId, - subject: body.subject, - fromName: body.fromName, - fromEmail: body.fromEmail, - replyTo: body.replyTo, - htmlBody: body.htmlBody, - textBody: body.textBody, - recipients: body.recipients, - maxRecipients: maxBroadcastRecipients, - }); - return c.json(result); - }); -} diff --git a/services/mana-mail/src/routes/labels.ts b/services/mana-mail/src/routes/labels.ts deleted file mode 100644 index 202d8a234..000000000 --- a/services/mana-mail/src/routes/labels.ts +++ /dev/null @@ -1,15 +0,0 @@ -/** - * Label routes — mailbox/folder listing (JWT auth). - */ - -import { Hono } from 'hono'; -import type { MailService } from '../services/mail-service'; -import type { AuthUser } from '../middleware/jwt-auth'; - -export function createLabelRoutes(mailService: MailService) { - return new Hono<{ Variables: { user: AuthUser } }>().get('/labels', async (c) => { - const user = c.get('user'); - const mailboxes = await mailService.getMailboxes(user.userId); - return c.json(mailboxes); - }); -} diff --git a/services/mana-mail/src/routes/messages.ts b/services/mana-mail/src/routes/messages.ts deleted file mode 100644 index e83a70019..000000000 --- a/services/mana-mail/src/routes/messages.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Message routes — update email flags (JWT auth). - */ - -import { Hono } from 'hono'; -import type { MailService } from '../services/mail-service'; -import type { AuthUser } from '../middleware/jwt-auth'; -import { updateMessageSchema } from '../lib/validation'; - -export function createMessageRoutes(mailService: MailService) { - return new Hono<{ Variables: { user: AuthUser } }>().put('/:emailId', async (c) => { - const user = c.get('user'); - const emailId = c.req.param('emailId'); - const body = updateMessageSchema.parse(await c.req.json()); - await mailService.updateMessage(user.userId, emailId, body); - return c.json({ success: true }); - }); -} diff --git a/services/mana-mail/src/routes/send.ts b/services/mana-mail/src/routes/send.ts deleted file mode 100644 index f0f406695..000000000 --- a/services/mana-mail/src/routes/send.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Send routes — compose and send emails (JWT auth). - */ - -import { Hono } from 'hono'; -import type { MailService } from '../services/mail-service'; -import type { AuthUser } from '../middleware/jwt-auth'; -import { sendEmailSchema } from '../lib/validation'; - -export function createSendRoutes(mailService: MailService) { - return new Hono<{ Variables: { user: AuthUser } }>().post('/send', async (c) => { - const user = c.get('user'); - const body = sendEmailSchema.parse(await c.req.json()); - const result = await mailService.sendEmail(user.userId, body); - return c.json(result); - }); -} diff --git a/services/mana-mail/src/routes/threads.ts b/services/mana-mail/src/routes/threads.ts deleted file mode 100644 index 1c5847ced..000000000 --- a/services/mana-mail/src/routes/threads.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Thread routes — inbox and thread detail (JWT auth). - */ - -import { Hono } from 'hono'; -import type { MailService } from '../services/mail-service'; -import type { AuthUser } from '../middleware/jwt-auth'; - -export function createThreadRoutes(mailService: MailService) { - return new Hono<{ Variables: { user: AuthUser } }>() - .get('/threads', async (c) => { - const user = c.get('user'); - const mailboxId = c.req.query('mailboxId'); - const limit = parseInt(c.req.query('limit') || '50', 10); - const offset = parseInt(c.req.query('offset') || '0', 10); - const result = await mailService.getThreads(user.userId, { mailboxId, limit, offset }); - return c.json(result); - }) - .get('/threads/:threadId', async (c) => { - const user = c.get('user'); - const threadId = c.req.param('threadId'); - const thread = await mailService.getThread(user.userId, threadId); - return c.json(thread); - }); -} diff --git a/services/mana-mail/src/services/account-service.ts b/services/mana-mail/src/services/account-service.ts deleted file mode 100644 index 10ff3660c..000000000 --- a/services/mana-mail/src/services/account-service.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * Account Service — Manages Stalwart mail accounts and DB records. - * - * Creates @mana.how mailboxes for users via Stalwart's Admin API. - */ - -import { eq } from 'drizzle-orm'; -import type { Database } from '../db/connection'; -import type { Config } from '../config'; -import { accounts, type MailAccount, type NewMailAccount } from '../db/schema/mail'; -import { ConflictError, NotFoundError } from '../lib/errors'; - -export class AccountService { - constructor( - private db: Database, - private config: Config['stalwart'] - ) {} - - private get authHeader(): string { - return ( - 'Basic ' + - Buffer.from(`${this.config.adminUser}:${this.config.adminPassword}`).toString('base64') - ); - } - - /** Generate a username from email or name. */ - private generateUsername(email: string, name?: string): string { - if (name) { - return name - .toLowerCase() - .replace(/\s+/g, '.') - .replace(/[^a-z0-9.]/g, '') - .slice(0, 30); - } - return email - .split('@')[0] - .toLowerCase() - .replace(/[^a-z0-9.]/g, ''); - } - - /** Create a Stalwart account via Admin API. */ - private async createStalwartAccount( - username: string, - password: string, - email: string - ): Promise { - // Hash the password with SHA512-crypt via Stalwart's own API - const createResponse = await fetch(`${this.config.jmapUrl}/api/principal`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: this.authHeader, - }, - body: JSON.stringify({ - type: 'individual', - name: username, - secrets: [password], - emails: [email], - }), - }); - - if (!createResponse.ok) { - const text = await createResponse.text(); - if (createResponse.status === 409 || text.includes('already exists')) { - throw new ConflictError(`Account ${email} already exists in Stalwart`); - } - throw new Error(`Failed to create Stalwart account: ${text}`); - } - - // Assign 'user' role (required for SMTP/JMAP access) - const roleResponse = await fetch(`${this.config.jmapUrl}/api/principal/${username}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - Authorization: this.authHeader, - }, - body: JSON.stringify([{ action: 'set', field: 'roles', value: ['user'] }]), - }); - - if (!roleResponse.ok) { - console.error( - `[mana-mail] Warning: failed to set role for ${username}: ${await roleResponse.text()}` - ); - } - } - - /** Provision a new mail account for a user (called on registration). */ - async provisionAccount(userId: string, email: string, name?: string): Promise { - // Check if user already has an account - const existing = await this.db.query.accounts.findFirst({ - where: eq(accounts.userId, userId), - }); - if (existing) return existing; - - // Generate @mana.how address - let username = this.generateUsername(email, name); - let manaEmail = `${username}@${this.config.domain}`; - - // Handle collision — append random suffix - const emailExists = await this.db.query.accounts.findFirst({ - where: eq(accounts.email, manaEmail), - }); - if (emailExists) { - const suffix = Math.floor(Math.random() * 1000); - username = `${username}${suffix}`; - manaEmail = `${username}@${this.config.domain}`; - } - - // Create Stalwart account with a random password - // (users authenticate via Mana JWT, not mail credentials directly) - const mailPassword = crypto.randomUUID(); - await this.createStalwartAccount(username, mailPassword, manaEmail); - - // Save to database - const newAccount: NewMailAccount = { - userId, - email: manaEmail, - displayName: name || username, - provider: 'stalwart', - isDefault: true, - stalwartAccountId: username, - }; - - const [created] = await this.db.insert(accounts).values(newAccount).returning(); - return created; - } - - /** Get all mail accounts for a user. */ - async getAccounts(userId: string): Promise { - return this.db.query.accounts.findMany({ - where: eq(accounts.userId, userId), - }); - } - - /** Get the default (or first) account for a user. */ - async getDefaultAccount(userId: string): Promise { - const account = await this.db.query.accounts.findFirst({ - where: eq(accounts.userId, userId), - orderBy: (a, { desc }) => [desc(a.isDefault)], - }); - return account ?? null; - } - - /** Update account settings (display name, signature). */ - async updateAccount( - userId: string, - accountId: string, - update: { displayName?: string; signature?: string } - ): Promise { - const account = await this.db.query.accounts.findFirst({ - where: eq(accounts.id, accountId), - }); - if (!account || account.userId !== userId) { - throw new NotFoundError('Account not found'); - } - - const [updated] = await this.db - .update(accounts) - .set({ ...update, updatedAt: new Date() }) - .where(eq(accounts.id, accountId)) - .returning(); - return updated; - } -} diff --git a/services/mana-mail/src/services/broadcast-orchestrator.ts b/services/mana-mail/src/services/broadcast-orchestrator.ts deleted file mode 100644 index a7a2d569e..000000000 --- a/services/mana-mail/src/services/broadcast-orchestrator.ts +++ /dev/null @@ -1,323 +0,0 @@ -/** - * Broadcast orchestrator — takes a campaign payload + recipient list, - * produces per-recipient HTML with substituted tracking URLs, submits - * each email via Stalwart (reusing the user's mailbox), and writes - * progress to broadcast.sends / broadcast.campaigns. - * - * MVP note: this is a synchronous loop. For 100 recipients it takes - * ~15s (JMAP submit latency-dominated) and the API call simply blocks - * until done. Phase 2 wraps this in an async job queue with SSE - * progress updates; the loop logic stays the same. - * - * Recipient resolution is NOT done here — the webapp ships a pre- - * resolved recipient list in the bulk-send payload because contacts - * live in Dexie (local-first) and the server never sees them decrypted. - */ - -import { eq, and } from 'drizzle-orm'; -import juice from 'juice'; -import type { Database } from '../db/connection'; -import { campaigns, sends, type NewBroadcastSend } from '../db/schema'; -import type { AccountService } from './account-service'; -import type { JmapClient } from './jmap-client'; -import { generateNonce, signToken } from './tracking-token'; -import { rewriteClickLinks } from './link-rewriter'; - -export interface BulkRecipient { - email: string; - name?: string; - /** Stable back-link to the user's contact, if resolvable. Opaque to us. */ - contactId?: string; -} - -export interface BulkSendInput { - userId: string; - campaignId: string; - subject: string; - fromName: string; - fromEmail: string; - replyTo?: string; - htmlBody: string; - textBody: string; - recipients: BulkRecipient[]; - /** Max recipients the campaign allows — hard-capped by the route - * against the server's MAX_RECIPIENTS_PER_CAMPAIGN config. */ - maxRecipients: number; -} - -export interface BulkSendResult { - campaignId: string; - accepted: number; - delivered: number; - failed: number; - /** Fine-grained error per recipient — useful for the UI to show a - * "3 bounces" badge without waiting on bounce-webhook propagation. */ - errors: Array<{ email: string; reason: string }>; -} - -export class BroadcastOrchestrator { - constructor( - private db: Database, - private jmap: JmapClient, - private accountService: AccountService, - private trackingSecret: string, - private baseUrl: string, - /** - * Milliseconds to sleep between JMAP submits. Protects the user's - * Stalwart + any downstream relay from being hammered by a 5000- - * recipient campaign. Default 150ms = ~6/sec = ~360/min, safely - * below most provider rate limits without making a 50-person - * newsletter feel slow. - */ - private sendThrottleMs: number = 150 - ) {} - - /** - * Inline CSS once for the whole campaign so every recipient gets the - * same final HTML structure — only the per-recipient URLs change. - * - * juice walks `