mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
chore(cutover): remove services/mana-mail/ — moved to mana-platform
Live containers on the Mac Mini build out of `../mana/services/mana-mail/`
since the 8-Doppel-Cutover commit (774852ba2). Smoke test green
2026-05-08 — health endpoints, JWKS, login flow, Stripe-webhook all
reachable from the new build path. Removing the now-stale duplicate.
Was 188K in this repo, gone now. Active code lives in
`Code/mana/services/mana-mail/` (siehe ../mana/CLAUDE.md).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
467d8339cc
commit
879975b665
37 changed files with 0 additions and 3070 deletions
|
|
@ -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 <noreply@mana.how>
|
|
||||||
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`
|
|
||||||
|
|
@ -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"]
|
|
||||||
|
|
@ -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'],
|
|
||||||
});
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 <noreply@mana.how>',
|
|
||||||
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),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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<typeof drizzle<typeof schema>> | 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<typeof getDb>;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export * from './mail';
|
|
||||||
export * from './broadcast';
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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,
|
|
||||||
};
|
|
||||||
|
|
@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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(),
|
|
||||||
});
|
|
||||||
|
|
@ -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<typeof createRemoteJWKSet> | 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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
/**
|
|
||||||
* GET /v1/mail/dns-check?domain=<apex> — 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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<number>`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<number>`count(distinct ${events.sendId})::int`,
|
|
||||||
totalCount: sql<number>`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<string, { unique: number; total: number }>;
|
|
||||||
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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(
|
|
||||||
'<!doctype html><html><body><h1>Ungültiger Abmelde-Link</h1><p>Der Link ist entweder abgelaufen oder wurde manipuliert.</p></body></html>',
|
|
||||||
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(
|
|
||||||
'<!doctype html><html><head><meta charset="utf-8"><title>Abgemeldet</title></head>' +
|
|
||||||
'<body style="font-family:system-ui,sans-serif;max-width:480px;margin:48px auto;padding:24px;color:#0f172a;">' +
|
|
||||||
'<h1 style="font-size:24px;">Du wurdest abgemeldet</h1>' +
|
|
||||||
'<p>Du bekommst von uns keine weiteren Newsletter mehr.</p>' +
|
|
||||||
'<p style="color:#64748b;font-size:14px;">Falls das ein Versehen war, antworte einfach auf eine unserer letzten E-Mails — wir kümmern uns darum.</p>' +
|
|
||||||
'</body></html>'
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
@ -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() })
|
|
||||||
);
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
@ -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<void> {
|
|
||||||
// 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<MailAccount> {
|
|
||||||
// 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<MailAccount[]> {
|
|
||||||
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<MailAccount | null> {
|
|
||||||
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<MailAccount> {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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 `<style>` blocks + external stylesheets and splatters
|
|
||||||
* matching rules into inline style="". Our client-side render is
|
|
||||||
* already inline-heavy so this pass mostly normalises edge cases.
|
|
||||||
*/
|
|
||||||
private inlineOnce(html: string): string {
|
|
||||||
try {
|
|
||||||
return juice(html, {
|
|
||||||
preserveMediaQueries: true,
|
|
||||||
removeStyleTags: false,
|
|
||||||
webResources: {
|
|
||||||
// We never fetch external resources (images are already
|
|
||||||
// absolute URLs to mana-media). Blocking the resolver
|
|
||||||
// prevents juice from trying to load anything over HTTP
|
|
||||||
// and slowing the send loop.
|
|
||||||
images: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// If juice chokes, fall back to the input HTML — a mail with
|
|
||||||
// un-inlined styles is still better than no mail.
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace `{{unsubscribe_url}}` and `{{web_view_url}}` placeholders
|
|
||||||
* with signed per-recipient URLs. Client-side renderer puts the
|
|
||||||
* placeholders in; this is the only place they're resolved.
|
|
||||||
*/
|
|
||||||
private substituteUrls(
|
|
||||||
inlinedHtml: string,
|
|
||||||
campaignId: string,
|
|
||||||
sendId: string,
|
|
||||||
nonce: string
|
|
||||||
): { html: string; text: string; unsubscribeUrl: string; webViewUrl: string } {
|
|
||||||
const token = signToken({ campaignId, sendId, nonce }, this.trackingSecret);
|
|
||||||
// Track endpoints live outside /api/v1/mail/* because the JWT
|
|
||||||
// middleware guards that whole subtree — recipients aren't logged in.
|
|
||||||
const unsubscribeUrl = `${this.baseUrl}/api/v1/track/unsubscribe/${token}`;
|
|
||||||
const webViewUrl = `${this.baseUrl}/api/v1/track/view/${campaignId}`;
|
|
||||||
const openPixelUrl = `${this.baseUrl}/api/v1/track/open/${token}`;
|
|
||||||
|
|
||||||
// Two replace passes — placeholders in both text and html bodies.
|
|
||||||
const replaceAll = (s: string) =>
|
|
||||||
s
|
|
||||||
.replaceAll('{{unsubscribe_url}}', unsubscribeUrl)
|
|
||||||
.replaceAll('#unsubscribe-preview', unsubscribeUrl)
|
|
||||||
.replaceAll('{{web_view_url}}', webViewUrl)
|
|
||||||
.replaceAll('#web-view-preview', webViewUrl);
|
|
||||||
|
|
||||||
let html = replaceAll(inlinedHtml);
|
|
||||||
|
|
||||||
// Rewrite <a href="http(s)://…"> to go through the click-tracking
|
|
||||||
// endpoint. The already-substituted unsubscribe + web-view URLs
|
|
||||||
// are themselves tracking endpoints and must not be double-wrapped.
|
|
||||||
const rewriteResult = rewriteClickLinks(
|
|
||||||
html,
|
|
||||||
token,
|
|
||||||
this.baseUrl,
|
|
||||||
new Set([unsubscribeUrl, webViewUrl])
|
|
||||||
);
|
|
||||||
html = rewriteResult.html;
|
|
||||||
|
|
||||||
// Inject the open pixel just before </body>. No-op for malformed
|
|
||||||
// HTML — we still send.
|
|
||||||
const pixel = `<img src="${openPixelUrl}" width="1" height="1" alt="" style="display:block;border:0;width:1px;height:1px;">`;
|
|
||||||
if (html.includes('</body>')) {
|
|
||||||
html = html.replace('</body>', `${pixel}</body>`);
|
|
||||||
} else {
|
|
||||||
html = `${html}\n${pixel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { html, text: '', unsubscribeUrl, webViewUrl };
|
|
||||||
}
|
|
||||||
|
|
||||||
private sleep(ms: number): Promise<void> {
|
|
||||||
return new Promise((r) => setTimeout(r, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch the set of email addresses this user's audience has
|
|
||||||
* unsubscribed from previous campaigns. Lowercased so a later case-
|
|
||||||
* insensitive compare works. Scope is per-user, not per-campaign —
|
|
||||||
* once someone unsubscribes from you, they're out of every future
|
|
||||||
* campaign you send, not just the one they unsubscribed via.
|
|
||||||
*/
|
|
||||||
private async loadUnsubscribedEmails(userId: string): Promise<Set<string>> {
|
|
||||||
const rows = await this.db
|
|
||||||
.selectDistinct({ email: sends.recipientEmail })
|
|
||||||
.from(sends)
|
|
||||||
.innerJoin(campaigns, eq(sends.campaignId, campaigns.id))
|
|
||||||
.where(and(eq(campaigns.userId, userId), eq(sends.status, 'unsubscribed')));
|
|
||||||
return new Set(rows.map((r) => r.email.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the bulk send. Returns aggregate stats. Blocks for the duration
|
|
||||||
* of the send (MVP — see module header).
|
|
||||||
*/
|
|
||||||
async run(input: BulkSendInput): Promise<BulkSendResult> {
|
|
||||||
if (input.recipients.length === 0) {
|
|
||||||
throw new Error('No recipients provided');
|
|
||||||
}
|
|
||||||
if (input.recipients.length > input.maxRecipients) {
|
|
||||||
throw new Error(
|
|
||||||
`Recipient count ${input.recipients.length} exceeds cap ${input.maxRecipients}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const account = await this.accountService.getDefaultAccount(input.userId);
|
|
||||||
if (!account?.stalwartAccountId) {
|
|
||||||
throw new Error('No mail account configured for this user');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Compliance gate: drop anyone who's ever unsubscribed from this
|
|
||||||
// user. Done BEFORE any send rows are written so the dashboard
|
|
||||||
// counts reflect "tatsächlich versandt" rather than "geplant".
|
|
||||||
const unsubscribed = await this.loadUnsubscribedEmails(input.userId);
|
|
||||||
const originalCount = input.recipients.length;
|
|
||||||
const recipients = input.recipients.filter((r) => !unsubscribed.has(r.email.toLowerCase()));
|
|
||||||
const skipped = originalCount - recipients.length;
|
|
||||||
|
|
||||||
if (recipients.length === 0) {
|
|
||||||
throw new Error('Alle Empfänger haben sich abgemeldet — nichts zu senden.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
// 1. Persist campaign row (mirror of the webapp's campaign for
|
|
||||||
// server-side tracking joins). totalRecipients reflects the
|
|
||||||
// post-skip count so open/click rates aren't artificially
|
|
||||||
// lowered by "virtual sends" that never happened.
|
|
||||||
await this.db
|
|
||||||
.insert(campaigns)
|
|
||||||
.values({
|
|
||||||
id: input.campaignId,
|
|
||||||
userId: input.userId,
|
|
||||||
subject: input.subject,
|
|
||||||
fromEmail: input.fromEmail,
|
|
||||||
fromName: input.fromName,
|
|
||||||
sentAt: now,
|
|
||||||
totalRecipients: recipients.length,
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
|
|
||||||
const inlinedHtml = this.inlineOnce(input.htmlBody);
|
|
||||||
const result: BulkSendResult = {
|
|
||||||
campaignId: input.campaignId,
|
|
||||||
accepted: recipients.length,
|
|
||||||
delivered: 0,
|
|
||||||
failed: 0,
|
|
||||||
errors:
|
|
||||||
skipped > 0
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
email: '(skipped)',
|
|
||||||
reason: `${skipped} Empfänger übersprungen — haben sich vorher abgemeldet`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 2. Loop. One send row per recipient, written first (status=queued)
|
|
||||||
// so a crash mid-loop leaves the DB truthful about who got a
|
|
||||||
// mail attempt.
|
|
||||||
for (const recipient of recipients) {
|
|
||||||
const sendId = crypto.randomUUID();
|
|
||||||
const nonce = generateNonce();
|
|
||||||
const sendRow: NewBroadcastSend = {
|
|
||||||
id: sendId,
|
|
||||||
campaignId: input.campaignId,
|
|
||||||
recipientEmail: recipient.email,
|
|
||||||
recipientName: recipient.name ?? null,
|
|
||||||
recipientContactId: recipient.contactId ?? null,
|
|
||||||
trackingNonce: nonce,
|
|
||||||
status: 'queued',
|
|
||||||
};
|
|
||||||
await this.db.insert(sends).values(sendRow);
|
|
||||||
|
|
||||||
const { html } = this.substituteUrls(inlinedHtml, input.campaignId, sendId, nonce);
|
|
||||||
// Text body: also substitute URL placeholders so plain-text
|
|
||||||
// clients get working links. Sign once per recipient.
|
|
||||||
const textToken = signToken(
|
|
||||||
{ campaignId: input.campaignId, sendId, nonce },
|
|
||||||
this.trackingSecret
|
|
||||||
);
|
|
||||||
const textUnsubUrl = `${this.baseUrl}/api/v1/track/unsubscribe/${textToken}`;
|
|
||||||
const text = input.textBody
|
|
||||||
.replaceAll('{{unsubscribe_url}}', textUnsubUrl)
|
|
||||||
.replaceAll('[Abmelde-Link wird beim Versand eingefügt]', textUnsubUrl);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// RFC 8058: `List-Unsubscribe: <https://…>` + the POST
|
|
||||||
// header together tell Gmail / Apple Mail to show the
|
|
||||||
// native "Abmelden"-button in the header. Without this
|
|
||||||
// pair, they fall back to just the body link.
|
|
||||||
const listUnsubUrl = `${this.baseUrl}/api/v1/track/unsubscribe/${textToken}`;
|
|
||||||
await this.jmap.submitEmail(account.stalwartAccountId, {
|
|
||||||
from: { name: input.fromName, email: input.fromEmail },
|
|
||||||
to: [{ name: recipient.name ?? null, email: recipient.email }],
|
|
||||||
subject: input.subject,
|
|
||||||
textBody: text,
|
|
||||||
htmlBody: html,
|
|
||||||
extraHeaders: {
|
|
||||||
'List-Unsubscribe': `<${listUnsubUrl}>`,
|
|
||||||
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await this.db
|
|
||||||
.update(sends)
|
|
||||||
.set({ status: 'sent', sentAt: new Date() })
|
|
||||||
.where(eqSendId(sendId));
|
|
||||||
result.delivered++;
|
|
||||||
} catch (err) {
|
|
||||||
const reason = err instanceof Error ? err.message : String(err);
|
|
||||||
await this.db
|
|
||||||
.update(sends)
|
|
||||||
.set({
|
|
||||||
status: 'failed',
|
|
||||||
bounceReason: reason,
|
|
||||||
})
|
|
||||||
.where(eqSendId(sendId));
|
|
||||||
result.failed++;
|
|
||||||
result.errors.push({ email: recipient.email, reason });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Throttle between sends so the loop doesn't DDoS our own
|
|
||||||
// Stalwart + any downstream relay. Skip the sleep on the
|
|
||||||
// last iteration — nobody's watching after the final send.
|
|
||||||
if (this.sendThrottleMs > 0) {
|
|
||||||
await this.sleep(this.sendThrottleMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small local helper so we don't depend on drizzle-orm's exported `eq`
|
|
||||||
// right here — keeps this file free of orm-plumbing imports beyond what
|
|
||||||
// it actually needs.
|
|
||||||
import { eq } from 'drizzle-orm';
|
|
||||||
function eqSendId(id: string) {
|
|
||||||
return eq(sends.id, id);
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/**
|
|
||||||
* Tiny helper to pull the configured mail domain without threading the
|
|
||||||
* full Config object through every DNS call. Config.loadConfig() runs
|
|
||||||
* at boot, so MAIL_DOMAIN is always defined by the time these helpers
|
|
||||||
* run — we just read it from process.env directly.
|
|
||||||
*/
|
|
||||||
export function getMailDomain(): string {
|
|
||||||
return process.env.MAIL_DOMAIN || 'mana.how';
|
|
||||||
}
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import { describe, it, expect } from 'bun:test';
|
|
||||||
import { parseSpf, parseDkim, parseDmarc } from './dns-check';
|
|
||||||
|
|
||||||
// ─── SPF ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('parseSpf', () => {
|
|
||||||
it('missing when no v=spf1 record', () => {
|
|
||||||
const r = parseSpf([], 'mana.how');
|
|
||||||
expect(r.status).toBe('missing');
|
|
||||||
expect(r.record).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wrong when multiple SPF records (RFC 7208 §3.2)', () => {
|
|
||||||
const r = parseSpf(
|
|
||||||
['v=spf1 include:_spf.google.com ~all', 'v=spf1 include:mailgun.org ~all'],
|
|
||||||
'mana.how'
|
|
||||||
);
|
|
||||||
expect(r.status).toBe('wrong');
|
|
||||||
expect(r.message.toLowerCase()).toContain('mehrere');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ok when include:<mailDomain> is present', () => {
|
|
||||||
const r = parseSpf(['v=spf1 include:mana.how ~all'], 'mana.how');
|
|
||||||
expect(r.status).toBe('ok');
|
|
||||||
expect(r.record).toContain('include:mana.how');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ok match is case-insensitive on both sides', () => {
|
|
||||||
const r = parseSpf(['V=SPF1 INCLUDE:MANA.HOW ~ALL'], 'Mana.How');
|
|
||||||
expect(r.status).toBe('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('weak on +all even without our include', () => {
|
|
||||||
const r = parseSpf(['v=spf1 +all'], 'mana.how');
|
|
||||||
expect(r.status).toBe('weak');
|
|
||||||
expect(r.message.toLowerCase()).toContain('spoofing');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wrong when SPF exists but omits our include', () => {
|
|
||||||
const r = parseSpf(['v=spf1 include:_spf.google.com ~all'], 'mana.how');
|
|
||||||
expect(r.status).toBe('wrong');
|
|
||||||
expect(r.message).toContain('include:mana.how');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── DKIM ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('parseDkim', () => {
|
|
||||||
it('missing when no v=DKIM1 record', () => {
|
|
||||||
const r = parseDkim([], 'mana');
|
|
||||||
expect(r.status).toBe('missing');
|
|
||||||
expect(r.selector).toBe('mana');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wrong when p= key is absent', () => {
|
|
||||||
const r = parseDkim(['v=DKIM1; k=rsa'], 'mana');
|
|
||||||
expect(r.status).toBe('wrong');
|
|
||||||
expect(r.message.toLowerCase()).toContain('public-key');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ok when v=DKIM1 + p=<base64> is present', () => {
|
|
||||||
const r = parseDkim(['v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8A'], 'mana');
|
|
||||||
expect(r.status).toBe('ok');
|
|
||||||
expect(r.selector).toBe('mana');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('case-insensitive on v=DKIM1', () => {
|
|
||||||
const r = parseDkim(['V=dkim1; p=ABCDEF'], 'mana');
|
|
||||||
expect(r.status).toBe('ok');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── DMARC ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('parseDmarc', () => {
|
|
||||||
it('missing when no v=DMARC1 record', () => {
|
|
||||||
const r = parseDmarc([]);
|
|
||||||
expect(r.status).toBe('missing');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('wrong without p= policy', () => {
|
|
||||||
const r = parseDmarc(['v=DMARC1; rua=mailto:a@b.ch']);
|
|
||||||
expect(r.status).toBe('wrong');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('weak on p=none', () => {
|
|
||||||
const r = parseDmarc(['v=DMARC1; p=none; rua=mailto:a@b.ch']);
|
|
||||||
expect(r.status).toBe('weak');
|
|
||||||
expect(r.message.toLowerCase()).toContain('quarantine');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ok on p=quarantine', () => {
|
|
||||||
const r = parseDmarc(['v=DMARC1; p=quarantine']);
|
|
||||||
expect(r.status).toBe('ok');
|
|
||||||
expect(r.message).toContain('quarantine');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ok on p=reject', () => {
|
|
||||||
const r = parseDmarc(['v=DMARC1; p=reject']);
|
|
||||||
expect(r.status).toBe('ok');
|
|
||||||
expect(r.message).toContain('reject');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('case-insensitive on policy value', () => {
|
|
||||||
const r = parseDmarc(['v=DMARC1; p=REJECT']);
|
|
||||||
expect(r.status).toBe('ok');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,228 +0,0 @@
|
||||||
/**
|
|
||||||
* DNS-based email-auth check: SPF / DKIM / DMARC.
|
|
||||||
*
|
|
||||||
* Queries Cloudflare's 1.1.1.1 DoH (DNS-over-HTTPS) JSON endpoint so
|
|
||||||
* the check works everywhere (no local resolver / UDP concerns). Bun
|
|
||||||
* has a native `dns.resolveTxt` but it can be flaky in containerised
|
|
||||||
* environments — DoH is boringly reliable.
|
|
||||||
*
|
|
||||||
* Splitting parse from fetch keeps the test surface pure: feed a
|
|
||||||
* record string to the parser, assert the status.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getMailDomain } from './dns-check-env';
|
|
||||||
|
|
||||||
export type DnsRecordStatus = 'ok' | 'missing' | 'wrong' | 'weak';
|
|
||||||
|
|
||||||
export interface DnsCheckResult {
|
|
||||||
domain: string;
|
|
||||||
spf: { status: DnsRecordStatus; record: string | null; message: string };
|
|
||||||
dkim: {
|
|
||||||
status: DnsRecordStatus;
|
|
||||||
record: string | null;
|
|
||||||
selector: string;
|
|
||||||
message: string;
|
|
||||||
};
|
|
||||||
dmarc: { status: DnsRecordStatus; record: string | null; message: string };
|
|
||||||
checkedAt: string;
|
|
||||||
/** Copy-paste records the user should publish, given the hosting setup. */
|
|
||||||
suggested: {
|
|
||||||
spfAdd: string;
|
|
||||||
dmarcRecord: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── DNS-over-HTTPS fetch ────────────────────────────────
|
|
||||||
|
|
||||||
interface DoHAnswer {
|
|
||||||
name: string;
|
|
||||||
type: number;
|
|
||||||
TTL: number;
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DoHResponse {
|
|
||||||
Status: number;
|
|
||||||
Answer?: DoHAnswer[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Look up TXT records for a name via Cloudflare DoH. Returns the
|
|
||||||
* `data` field of each answer (Cloudflare returns TXT records wrapped
|
|
||||||
* in double quotes; we strip them).
|
|
||||||
*/
|
|
||||||
export async function lookupTxt(name: string): Promise<string[]> {
|
|
||||||
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(name)}&type=TXT`;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: { accept: 'application/dns-json' },
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error(`DoH lookup failed: ${res.status}`);
|
|
||||||
const body = (await res.json()) as DoHResponse;
|
|
||||||
if (body.Status !== 0 || !body.Answer) return [];
|
|
||||||
return body.Answer.filter((a) => a.type === 16).map((a) => stripQuotes(a.data));
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripQuotes(s: string): string {
|
|
||||||
// TXT records get returned as `"v=spf1 ..."` and multi-string TXT
|
|
||||||
// come as `"part1" "part2"`. Concatenate everything inside quotes.
|
|
||||||
const parts = s.match(/"([^"]*)"/g);
|
|
||||||
if (!parts) return s.replace(/^"|"$/g, '');
|
|
||||||
return parts.map((p) => p.slice(1, -1)).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Record parsers (pure, testable) ─────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* SPF check. Our send path goes through the user's Stalwart account,
|
|
||||||
* so SPF on the user's domain should include the Mana sending host.
|
|
||||||
* We accept three shapes:
|
|
||||||
* - Explicit `include:<mailDomain>` — the canonical form we
|
|
||||||
* recommend in the suggested-records UI.
|
|
||||||
* - A `+mx` / `mx` token — covers users who already route via their
|
|
||||||
* own MX; correct but needs the MX to be Mana.
|
|
||||||
* - Plain presence of `v=spf1` with `all` qualifier — weak: parses
|
|
||||||
* but doesn't actually authorise us.
|
|
||||||
*/
|
|
||||||
export function parseSpf(
|
|
||||||
records: string[],
|
|
||||||
mailDomain: string
|
|
||||||
): { status: DnsRecordStatus; record: string | null; message: string } {
|
|
||||||
const spfRecords = records.filter((r) => r.toLowerCase().startsWith('v=spf1'));
|
|
||||||
if (spfRecords.length === 0) {
|
|
||||||
return { status: 'missing', record: null, message: 'Kein SPF-Record gefunden.' };
|
|
||||||
}
|
|
||||||
if (spfRecords.length > 1) {
|
|
||||||
// RFC 7208 §3.2 — multiple SPF records = PermError for resolvers.
|
|
||||||
return {
|
|
||||||
status: 'wrong',
|
|
||||||
record: spfRecords.join(' | '),
|
|
||||||
message: 'Mehrere SPF-Records. Erlaubt ist genau einer — überflüssige entfernen.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const record = spfRecords[0];
|
|
||||||
const lower = record.toLowerCase();
|
|
||||||
if (lower.includes(`include:${mailDomain.toLowerCase()}`)) {
|
|
||||||
return {
|
|
||||||
status: 'ok',
|
|
||||||
record,
|
|
||||||
message: `SPF erlaubt Versand über ${mailDomain}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Accept a catch-all `+all` or `+mx` as a permissive pass, but warn.
|
|
||||||
if (lower.includes(' +all') || lower.endsWith('+all')) {
|
|
||||||
return {
|
|
||||||
status: 'weak',
|
|
||||||
record,
|
|
||||||
message: '+all erlaubt jedem Server zu senden — Einladung zum Spoofing. Spezifischer werden.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: 'wrong',
|
|
||||||
record,
|
|
||||||
message: `SPF enthält kein include:${mailDomain}. Mails könnten in Spam landen.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DKIM check. The selector is `mana` (or whatever Stalwart's config
|
|
||||||
* uses) at `<selector>._domainkey.<domain>`. We don't try to verify
|
|
||||||
* the key matches — just that a record of the right shape exists.
|
|
||||||
*/
|
|
||||||
export function parseDkim(
|
|
||||||
records: string[],
|
|
||||||
selector: string
|
|
||||||
): { status: DnsRecordStatus; record: string | null; selector: string; message: string } {
|
|
||||||
const dkim = records.find((r) => r.toLowerCase().startsWith('v=dkim1'));
|
|
||||||
if (!dkim) {
|
|
||||||
return {
|
|
||||||
status: 'missing',
|
|
||||||
record: null,
|
|
||||||
selector,
|
|
||||||
message: `Kein DKIM-Record auf ${selector}._domainkey — Mail wird nicht signiert.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Loose validity check: needs a p= public-key segment.
|
|
||||||
if (!/\bp=[A-Za-z0-9+/=]+/i.test(dkim)) {
|
|
||||||
return {
|
|
||||||
status: 'wrong',
|
|
||||||
record: dkim,
|
|
||||||
selector,
|
|
||||||
message: 'DKIM-Record ohne gültiges p= (Public-Key fehlt).',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: 'ok',
|
|
||||||
record: dkim,
|
|
||||||
selector,
|
|
||||||
message: `DKIM signiert mit Selector "${selector}".`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DMARC check. Policy `none` parses but doesn't actually enforce
|
|
||||||
* anything — flagged as weak. `quarantine` and `reject` both OK.
|
|
||||||
*/
|
|
||||||
export function parseDmarc(records: string[]): {
|
|
||||||
status: DnsRecordStatus;
|
|
||||||
record: string | null;
|
|
||||||
message: string;
|
|
||||||
} {
|
|
||||||
const dmarc = records.find((r) => r.toLowerCase().startsWith('v=dmarc1'));
|
|
||||||
if (!dmarc) {
|
|
||||||
return {
|
|
||||||
status: 'missing',
|
|
||||||
record: null,
|
|
||||||
message:
|
|
||||||
'Kein DMARC-Record auf _dmarc — Gmail/Yahoo behandeln Bulk-Mails ohne DMARC strenger.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const policy = dmarc.match(/\bp=(none|quarantine|reject)\b/i);
|
|
||||||
if (!policy) {
|
|
||||||
return {
|
|
||||||
status: 'wrong',
|
|
||||||
record: dmarc,
|
|
||||||
message: 'DMARC ohne p= (Policy). Setze mindestens p=none.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (policy[1].toLowerCase() === 'none') {
|
|
||||||
return {
|
|
||||||
status: 'weak',
|
|
||||||
record: dmarc,
|
|
||||||
message:
|
|
||||||
'p=none loggt nur — Phishing wird nicht abgewiesen. Nach Monitoring auf quarantine/reject gehen.',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
status: 'ok',
|
|
||||||
record: dmarc,
|
|
||||||
message: `DMARC aktiv mit Policy ${policy[1]}.`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Orchestrator ────────────────────────────────────────
|
|
||||||
|
|
||||||
export async function checkDomain(
|
|
||||||
domain: string,
|
|
||||||
opts: { mailDomain?: string; dkimSelector?: string } = {}
|
|
||||||
): Promise<DnsCheckResult> {
|
|
||||||
const mailDomain = opts.mailDomain ?? getMailDomain();
|
|
||||||
const selector = opts.dkimSelector ?? 'mana';
|
|
||||||
|
|
||||||
const [spfRecords, dkimRecords, dmarcRecords] = await Promise.all([
|
|
||||||
lookupTxt(domain).catch(() => []),
|
|
||||||
lookupTxt(`${selector}._domainkey.${domain}`).catch(() => []),
|
|
||||||
lookupTxt(`_dmarc.${domain}`).catch(() => []),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
domain,
|
|
||||||
spf: parseSpf(spfRecords, mailDomain),
|
|
||||||
dkim: parseDkim(dkimRecords, selector),
|
|
||||||
dmarc: parseDmarc(dmarcRecords),
|
|
||||||
checkedAt: new Date().toISOString(),
|
|
||||||
suggested: {
|
|
||||||
spfAdd: `v=spf1 include:${mailDomain} ~all`,
|
|
||||||
dmarcRecord: `v=DMARC1; p=none; rua=mailto:dmarc-reports@${domain}`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,336 +0,0 @@
|
||||||
/**
|
|
||||||
* JMAP Client — Communicates with Stalwart mail server.
|
|
||||||
*
|
|
||||||
* Stalwart supports JMAP (RFC 8620) natively on port 8080.
|
|
||||||
* This client uses HTTP Basic Auth with admin credentials,
|
|
||||||
* scoped to individual user accounts via JMAP accountId.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Config } from '../config';
|
|
||||||
|
|
||||||
// ─── JMAP Types ─────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface JmapMailbox {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role: string | null;
|
|
||||||
totalEmails: number;
|
|
||||||
unreadEmails: number;
|
|
||||||
sortOrder: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JmapEmailAddress {
|
|
||||||
name: string | null;
|
|
||||||
email: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JmapEmail {
|
|
||||||
id: string;
|
|
||||||
threadId: string;
|
|
||||||
mailboxIds: Record<string, boolean>;
|
|
||||||
from: JmapEmailAddress[] | null;
|
|
||||||
to: JmapEmailAddress[] | null;
|
|
||||||
cc: JmapEmailAddress[] | null;
|
|
||||||
subject: string;
|
|
||||||
receivedAt: string;
|
|
||||||
preview: string;
|
|
||||||
size: number;
|
|
||||||
keywords: Record<string, boolean>;
|
|
||||||
hasAttachment: boolean;
|
|
||||||
bodyValues?: Record<string, { value: string; isEncodingProblem: boolean }>;
|
|
||||||
htmlBody?: Array<{ partId: string; type: string }>;
|
|
||||||
textBody?: Array<{ partId: string; type: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JmapThread {
|
|
||||||
id: string;
|
|
||||||
emailIds: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Client ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export class JmapClient {
|
|
||||||
private baseUrl: string;
|
|
||||||
private authHeader: string;
|
|
||||||
|
|
||||||
constructor(config: Config['stalwart']) {
|
|
||||||
this.baseUrl = config.jmapUrl;
|
|
||||||
this.authHeader =
|
|
||||||
'Basic ' + Buffer.from(`${config.adminUser}:${config.adminPassword}`).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async call(methodCalls: unknown[][], accountId: string): Promise<unknown[][]> {
|
|
||||||
const response = await fetch(`${this.baseUrl}/jmap`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Authorization: this.authHeader,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
using: ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'],
|
|
||||||
methodCalls: methodCalls.map((call) => {
|
|
||||||
// Inject accountId into each method call's arguments
|
|
||||||
if (call[1] && typeof call[1] === 'object') {
|
|
||||||
(call[1] as Record<string, unknown>).accountId = accountId;
|
|
||||||
}
|
|
||||||
return call;
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const text = await response.text();
|
|
||||||
throw new Error(`JMAP call failed (${response.status}): ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = (await response.json()) as { methodResponses: unknown[][] };
|
|
||||||
return result.methodResponses;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get all mailboxes (folders/labels) for an account. */
|
|
||||||
async getMailboxes(accountId: string): Promise<JmapMailbox[]> {
|
|
||||||
const responses = await this.call(
|
|
||||||
[
|
|
||||||
[
|
|
||||||
'Mailbox/get',
|
|
||||||
{ properties: ['id', 'name', 'role', 'totalEmails', 'unreadEmails', 'sortOrder'] },
|
|
||||||
'mb-0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
accountId
|
|
||||||
);
|
|
||||||
const [, result] = responses[0];
|
|
||||||
return ((result as Record<string, unknown>).list as JmapMailbox[]) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Query email IDs in a mailbox, sorted by date descending. */
|
|
||||||
async queryEmails(
|
|
||||||
accountId: string,
|
|
||||||
opts: {
|
|
||||||
mailboxId?: string;
|
|
||||||
limit?: number;
|
|
||||||
position?: number;
|
|
||||||
filter?: Record<string, unknown>;
|
|
||||||
} = {}
|
|
||||||
): Promise<{ ids: string[]; total: number }> {
|
|
||||||
const filter: Record<string, unknown> = { ...opts.filter };
|
|
||||||
if (opts.mailboxId) filter.inMailbox = opts.mailboxId;
|
|
||||||
|
|
||||||
const responses = await this.call(
|
|
||||||
[
|
|
||||||
[
|
|
||||||
'Email/query',
|
|
||||||
{
|
|
||||||
filter: Object.keys(filter).length > 0 ? filter : undefined,
|
|
||||||
sort: [{ property: 'receivedAt', isAscending: false }],
|
|
||||||
limit: opts.limit ?? 50,
|
|
||||||
position: opts.position ?? 0,
|
|
||||||
},
|
|
||||||
'eq-0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
accountId
|
|
||||||
);
|
|
||||||
const [, result] = responses[0];
|
|
||||||
const r = result as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
ids: (r.ids as string[]) || [],
|
|
||||||
total: (r.total as number) || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get full email objects by ID. */
|
|
||||||
async getEmails(
|
|
||||||
accountId: string,
|
|
||||||
emailIds: string[],
|
|
||||||
properties?: string[]
|
|
||||||
): Promise<JmapEmail[]> {
|
|
||||||
if (emailIds.length === 0) return [];
|
|
||||||
|
|
||||||
const responses = await this.call(
|
|
||||||
[
|
|
||||||
[
|
|
||||||
'Email/get',
|
|
||||||
{
|
|
||||||
ids: emailIds,
|
|
||||||
properties: properties ?? [
|
|
||||||
'id',
|
|
||||||
'threadId',
|
|
||||||
'mailboxIds',
|
|
||||||
'from',
|
|
||||||
'to',
|
|
||||||
'cc',
|
|
||||||
'subject',
|
|
||||||
'receivedAt',
|
|
||||||
'preview',
|
|
||||||
'size',
|
|
||||||
'keywords',
|
|
||||||
'hasAttachment',
|
|
||||||
],
|
|
||||||
fetchHTMLBodyValues: true,
|
|
||||||
fetchTextBodyValues: true,
|
|
||||||
},
|
|
||||||
'eg-0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
accountId
|
|
||||||
);
|
|
||||||
const [, result] = responses[0];
|
|
||||||
return ((result as Record<string, unknown>).list as JmapEmail[]) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get full email with body content. */
|
|
||||||
async getEmailWithBody(accountId: string, emailId: string): Promise<JmapEmail | null> {
|
|
||||||
const emails = await this.getEmails(
|
|
||||||
accountId,
|
|
||||||
[emailId],
|
|
||||||
[
|
|
||||||
'id',
|
|
||||||
'threadId',
|
|
||||||
'mailboxIds',
|
|
||||||
'from',
|
|
||||||
'to',
|
|
||||||
'cc',
|
|
||||||
'subject',
|
|
||||||
'receivedAt',
|
|
||||||
'preview',
|
|
||||||
'size',
|
|
||||||
'keywords',
|
|
||||||
'hasAttachment',
|
|
||||||
'bodyValues',
|
|
||||||
'htmlBody',
|
|
||||||
'textBody',
|
|
||||||
]
|
|
||||||
);
|
|
||||||
return emails[0] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get threads by ID. */
|
|
||||||
async getThreads(accountId: string, threadIds: string[]): Promise<JmapThread[]> {
|
|
||||||
if (threadIds.length === 0) return [];
|
|
||||||
|
|
||||||
const responses = await this.call([['Thread/get', { ids: threadIds }, 'tg-0']], accountId);
|
|
||||||
const [, result] = responses[0];
|
|
||||||
return ((result as Record<string, unknown>).list as JmapThread[]) || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update email keywords (read, flagged) or mailbox membership. */
|
|
||||||
async updateEmail(
|
|
||||||
accountId: string,
|
|
||||||
emailId: string,
|
|
||||||
update: {
|
|
||||||
isRead?: boolean;
|
|
||||||
isFlagged?: boolean;
|
|
||||||
mailboxIds?: Record<string, boolean>;
|
|
||||||
}
|
|
||||||
): Promise<void> {
|
|
||||||
const patch: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
if (update.isRead !== undefined) {
|
|
||||||
patch['keywords/$seen'] = update.isRead || null;
|
|
||||||
}
|
|
||||||
if (update.isFlagged !== undefined) {
|
|
||||||
patch['keywords/$flagged'] = update.isFlagged || null;
|
|
||||||
}
|
|
||||||
if (update.mailboxIds) {
|
|
||||||
patch.mailboxIds = update.mailboxIds;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.call([['Email/set', { update: { [emailId]: patch } }, 'eu-0']], accountId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Submit an email for delivery via JMAP. */
|
|
||||||
async submitEmail(
|
|
||||||
accountId: string,
|
|
||||||
email: {
|
|
||||||
from: JmapEmailAddress;
|
|
||||||
to: JmapEmailAddress[];
|
|
||||||
cc?: JmapEmailAddress[];
|
|
||||||
bcc?: JmapEmailAddress[];
|
|
||||||
subject: string;
|
|
||||||
textBody: string;
|
|
||||||
htmlBody?: string;
|
|
||||||
inReplyTo?: string;
|
|
||||||
references?: string[];
|
|
||||||
/**
|
|
||||||
* Extra headers to add to the outgoing mail. Keys should be
|
|
||||||
* the header name (e.g. "List-Unsubscribe"); values are the
|
|
||||||
* string header value. Used for RFC 8058 one-click unsubscribe.
|
|
||||||
* JMAP's property convention is `header:<Name>:asText`.
|
|
||||||
*/
|
|
||||||
extraHeaders?: Record<string, string>;
|
|
||||||
}
|
|
||||||
): Promise<string> {
|
|
||||||
const emailId = `draft-${Date.now()}`;
|
|
||||||
const identityId = accountId;
|
|
||||||
|
|
||||||
// Create + send in a single JMAP batch
|
|
||||||
const bodyParts: unknown[] = [];
|
|
||||||
|
|
||||||
if (email.htmlBody) {
|
|
||||||
bodyParts.push({ partId: 'html', type: 'text/html' });
|
|
||||||
}
|
|
||||||
bodyParts.push({ partId: 'text', type: 'text/plain' });
|
|
||||||
|
|
||||||
const bodyValues: Record<string, unknown> = {
|
|
||||||
text: { value: email.textBody, charset: 'utf-8' },
|
|
||||||
};
|
|
||||||
if (email.htmlBody) {
|
|
||||||
bodyValues.html = { value: email.htmlBody, charset: 'utf-8' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailCreate: Record<string, unknown> = {
|
|
||||||
from: [email.from],
|
|
||||||
to: email.to,
|
|
||||||
subject: email.subject,
|
|
||||||
bodyValues,
|
|
||||||
textBody: [{ partId: 'text', type: 'text/plain' }],
|
|
||||||
htmlBody: email.htmlBody ? [{ partId: 'html', type: 'text/html' }] : undefined,
|
|
||||||
keywords: { $draft: true },
|
|
||||||
};
|
|
||||||
|
|
||||||
if (email.cc) emailCreate.cc = email.cc;
|
|
||||||
if (email.bcc) emailCreate.bcc = email.bcc;
|
|
||||||
if (email.inReplyTo) emailCreate.inReplyTo = email.inReplyTo;
|
|
||||||
if (email.references) emailCreate.references = email.references;
|
|
||||||
// Custom headers via JMAP's `header:<Name>:asText` convention.
|
|
||||||
// Used for RFC 8058 (List-Unsubscribe + List-Unsubscribe-Post).
|
|
||||||
if (email.extraHeaders) {
|
|
||||||
for (const [name, value] of Object.entries(email.extraHeaders)) {
|
|
||||||
emailCreate[`header:${name}:asText`] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const responses = await this.call(
|
|
||||||
[
|
|
||||||
['Email/set', { create: { [emailId]: emailCreate } }, 'ec-0'],
|
|
||||||
[
|
|
||||||
'EmailSubmission/set',
|
|
||||||
{
|
|
||||||
create: {
|
|
||||||
sub0: {
|
|
||||||
emailId: `#${emailId}`,
|
|
||||||
identityId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
onSuccessUpdateEmail: {
|
|
||||||
'#sub0': {
|
|
||||||
'keywords/$draft': null,
|
|
||||||
'keywords/$sent': true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'es-0',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
accountId
|
|
||||||
);
|
|
||||||
|
|
||||||
const [, createResult] = responses[0];
|
|
||||||
const created = (createResult as Record<string, unknown>).created as Record<
|
|
||||||
string,
|
|
||||||
{ id: string }
|
|
||||||
>;
|
|
||||||
return created?.[emailId]?.id ?? '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
import { describe, it, expect } from 'bun:test';
|
|
||||||
import { rewriteClickLinks } from './link-rewriter';
|
|
||||||
|
|
||||||
const TOKEN = 'abc.def';
|
|
||||||
const BASE = 'https://mail.mana.how';
|
|
||||||
const EMPTY_SKIP = new Set<string>();
|
|
||||||
|
|
||||||
describe('rewriteClickLinks', () => {
|
|
||||||
it('rewrites a simple https link', () => {
|
|
||||||
const { html, rewritten } = rewriteClickLinks(
|
|
||||||
'<a href="https://example.com">click</a>',
|
|
||||||
TOKEN,
|
|
||||||
BASE,
|
|
||||||
EMPTY_SKIP
|
|
||||||
);
|
|
||||||
expect(rewritten).toBe(1);
|
|
||||||
expect(html).toContain(`${BASE}/api/v1/track/click/${TOKEN}?url=`);
|
|
||||||
expect(html).toContain(encodeURIComponent('https://example.com'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rewrites http:// links too', () => {
|
|
||||||
const { rewritten } = rewriteClickLinks(
|
|
||||||
'<a href="http://old-site.ch">x</a>',
|
|
||||||
TOKEN,
|
|
||||||
BASE,
|
|
||||||
EMPTY_SKIP
|
|
||||||
);
|
|
||||||
expect(rewritten).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves mailto: alone', () => {
|
|
||||||
const input = '<a href="mailto:foo@bar.ch">mail</a>';
|
|
||||||
const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP);
|
|
||||||
expect(rewritten).toBe(0);
|
|
||||||
expect(html).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves tel: alone', () => {
|
|
||||||
const input = '<a href="tel:+41443000000">call</a>';
|
|
||||||
const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP);
|
|
||||||
expect(rewritten).toBe(0);
|
|
||||||
expect(html).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves anchor fragments alone', () => {
|
|
||||||
const input = '<a href="#section-2">down</a>';
|
|
||||||
const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP);
|
|
||||||
expect(rewritten).toBe(0);
|
|
||||||
expect(html).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips URLs listed in skipUrls (unsubscribe, web-view)', () => {
|
|
||||||
const unsub = 'https://mail.mana.how/api/v1/track/unsubscribe/xxx.yyy';
|
|
||||||
const input = `<a href="${unsub}">abbestellen</a><a href="https://other.ch">other</a>`;
|
|
||||||
const { html, rewritten } = rewriteClickLinks(input, TOKEN, BASE, new Set([unsub]));
|
|
||||||
expect(rewritten).toBe(1);
|
|
||||||
expect(html).toContain(unsub); // untouched
|
|
||||||
expect(html).toContain(encodeURIComponent('https://other.ch')); // rewritten
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves other attributes on the anchor', () => {
|
|
||||||
const { html } = rewriteClickLinks(
|
|
||||||
'<a class="btn" href="https://example.com" style="color:red">x</a>',
|
|
||||||
TOKEN,
|
|
||||||
BASE,
|
|
||||||
EMPTY_SKIP
|
|
||||||
);
|
|
||||||
expect(html).toContain('class="btn"');
|
|
||||||
expect(html).toContain('style="color:red"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('counts multiple rewrites', () => {
|
|
||||||
const input =
|
|
||||||
'<a href="https://a.ch">a</a> <a href="https://b.ch">b</a> <a href="mailto:x@y.z">x</a>';
|
|
||||||
const { rewritten } = rewriteClickLinks(input, TOKEN, BASE, EMPTY_SKIP);
|
|
||||||
expect(rewritten).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles single-quoted href attributes', () => {
|
|
||||||
const { rewritten, html } = rewriteClickLinks(
|
|
||||||
"<a href='https://example.com'>x</a>",
|
|
||||||
TOKEN,
|
|
||||||
BASE,
|
|
||||||
EMPTY_SKIP
|
|
||||||
);
|
|
||||||
expect(rewritten).toBe(1);
|
|
||||||
// Output should still be single-quoted.
|
|
||||||
expect(html).toContain("href='");
|
|
||||||
});
|
|
||||||
|
|
||||||
it('is idempotent for skip URLs — passing them again does not double-wrap', () => {
|
|
||||||
const wrapped = `${BASE}/api/v1/track/click/${TOKEN}?url=${encodeURIComponent('https://x.ch')}`;
|
|
||||||
const input = `<a href="${wrapped}">x</a>`;
|
|
||||||
const { rewritten, html } = rewriteClickLinks(input, TOKEN, BASE, new Set([wrapped]));
|
|
||||||
expect(rewritten).toBe(0);
|
|
||||||
expect(html).toBe(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns count even when no links match', () => {
|
|
||||||
const { rewritten, html } = rewriteClickLinks(
|
|
||||||
'<p>Just some text, no links.</p>',
|
|
||||||
TOKEN,
|
|
||||||
BASE,
|
|
||||||
EMPTY_SKIP
|
|
||||||
);
|
|
||||||
expect(rewritten).toBe(0);
|
|
||||||
expect(html).toBe('<p>Just some text, no links.</p>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
/**
|
|
||||||
* HTML anchor-href rewriter for click tracking.
|
|
||||||
*
|
|
||||||
* Walks the body HTML and rewrites each `<a href="http…">` to
|
|
||||||
* `.../api/v1/track/click/{token}?url={encoded_original}` so clicks go
|
|
||||||
* through the tracking endpoint first. Non-http schemes (mailto:, tel:,
|
|
||||||
* sms:, anchor fragments) are left alone — tracking them is both
|
|
||||||
* pointless and potentially harmful (mailto: tracking would break the
|
|
||||||
* recipient's mail client hand-off).
|
|
||||||
*
|
|
||||||
* URLs listed in `skipUrls` are passed through untouched. That's how
|
|
||||||
* the unsubscribe-URL and web-view-URL (already signed tracking URLs)
|
|
||||||
* avoid double-wrapping.
|
|
||||||
*
|
|
||||||
* Regex-based because Tiptap's output is well-formed HTML — we don't
|
|
||||||
* need a full parser. If users ever get to paste arbitrary HTML, we
|
|
||||||
* swap to parse5.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replace anchor href attributes in the HTML body with tracked URLs.
|
|
||||||
* Returns the rewritten HTML plus a count of how many links were
|
|
||||||
* touched (useful for stats / debugging).
|
|
||||||
*/
|
|
||||||
export function rewriteClickLinks(
|
|
||||||
html: string,
|
|
||||||
token: string,
|
|
||||||
baseUrl: string,
|
|
||||||
skipUrls: Set<string>
|
|
||||||
): { html: string; rewritten: number } {
|
|
||||||
let rewritten = 0;
|
|
||||||
const trackBase = `${baseUrl}/api/v1/track/click/${token}`;
|
|
||||||
|
|
||||||
// Match only anchor tags — image src / form action aren't clicks.
|
|
||||||
// The pattern is deliberately loose on whitespace and attribute
|
|
||||||
// order to survive minor Tiptap formatting variations.
|
|
||||||
const pattern = /<a\b([^>]*?)href=(["'])([^"']+)\2([^>]*)>/gi;
|
|
||||||
|
|
||||||
const out = html.replace(pattern, (match, preAttrs, quote, url, postAttrs) => {
|
|
||||||
// Keep non-http(s) untouched: mailto / tel / anchor fragments
|
|
||||||
// should land in the native handler, not the tracker.
|
|
||||||
if (!/^https?:\/\//i.test(url)) return match;
|
|
||||||
if (skipUrls.has(url)) return match;
|
|
||||||
|
|
||||||
const wrappedUrl = `${trackBase}?url=${encodeURIComponent(url)}`;
|
|
||||||
rewritten++;
|
|
||||||
return `<a${preAttrs}href=${quote}${wrappedUrl}${quote}${postAttrs}>`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { html: out, rewritten };
|
|
||||||
}
|
|
||||||
|
|
@ -1,233 +0,0 @@
|
||||||
/**
|
|
||||||
* Mail Service — Business logic for reading and sending mail.
|
|
||||||
*
|
|
||||||
* Wraps the JMAP client with user-scoped operations.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Database } from '../db/connection';
|
|
||||||
import type { JmapClient, JmapEmail, JmapMailbox } from './jmap-client';
|
|
||||||
import type { AccountService } from './account-service';
|
|
||||||
import { NotFoundError } from '../lib/errors';
|
|
||||||
|
|
||||||
// ─── Response Types ─────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface ThreadSummary {
|
|
||||||
id: string;
|
|
||||||
subject: string;
|
|
||||||
snippet: string;
|
|
||||||
from: { name: string | null; email: string }[];
|
|
||||||
lastMessageAt: string;
|
|
||||||
messageCount: number;
|
|
||||||
isRead: boolean;
|
|
||||||
isFlagged: boolean;
|
|
||||||
hasAttachment: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ThreadDetail {
|
|
||||||
id: string;
|
|
||||||
subject: string;
|
|
||||||
messages: MessageDetail[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageDetail {
|
|
||||||
id: string;
|
|
||||||
from: { name: string | null; email: string }[] | null;
|
|
||||||
to: { name: string | null; email: string }[] | null;
|
|
||||||
cc: { name: string | null; email: string }[] | null;
|
|
||||||
subject: string;
|
|
||||||
date: string;
|
|
||||||
preview: string;
|
|
||||||
bodyText?: string;
|
|
||||||
bodyHtml?: string;
|
|
||||||
isRead: boolean;
|
|
||||||
isFlagged: boolean;
|
|
||||||
hasAttachment: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MailboxInfo {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
role: string | null;
|
|
||||||
totalEmails: number;
|
|
||||||
unreadEmails: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Service ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export class MailService {
|
|
||||||
constructor(
|
|
||||||
private db: Database,
|
|
||||||
private jmap: JmapClient,
|
|
||||||
private accountService: AccountService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** Resolve the Stalwart accountId for a user (their @mana.how address). */
|
|
||||||
private async resolveAccountId(userId: string): Promise<string> {
|
|
||||||
const account = await this.accountService.getDefaultAccount(userId);
|
|
||||||
if (!account?.stalwartAccountId) {
|
|
||||||
throw new NotFoundError('No mail account configured');
|
|
||||||
}
|
|
||||||
return account.stalwartAccountId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get mailboxes (labels/folders) for the user. */
|
|
||||||
async getMailboxes(userId: string): Promise<MailboxInfo[]> {
|
|
||||||
const accountId = await this.resolveAccountId(userId);
|
|
||||||
const mailboxes = await this.jmap.getMailboxes(accountId);
|
|
||||||
return mailboxes.map((mb) => ({
|
|
||||||
id: mb.id,
|
|
||||||
name: mb.name,
|
|
||||||
role: mb.role,
|
|
||||||
totalEmails: mb.totalEmails,
|
|
||||||
unreadEmails: mb.unreadEmails,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get paginated thread list for a mailbox. */
|
|
||||||
async getThreads(
|
|
||||||
userId: string,
|
|
||||||
opts: { mailboxId?: string; limit?: number; offset?: number } = {}
|
|
||||||
): Promise<{ threads: ThreadSummary[]; total: number }> {
|
|
||||||
const accountId = await this.resolveAccountId(userId);
|
|
||||||
|
|
||||||
// Query email IDs
|
|
||||||
const { ids: emailIds, total } = await this.jmap.queryEmails(accountId, {
|
|
||||||
mailboxId: opts.mailboxId,
|
|
||||||
limit: opts.limit ?? 50,
|
|
||||||
position: opts.offset ?? 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (emailIds.length === 0) return { threads: [], total };
|
|
||||||
|
|
||||||
// Fetch email details
|
|
||||||
const emails = await this.jmap.getEmails(accountId, emailIds);
|
|
||||||
|
|
||||||
// Group by threadId
|
|
||||||
const threadMap = new Map<string, JmapEmail[]>();
|
|
||||||
for (const email of emails) {
|
|
||||||
const existing = threadMap.get(email.threadId) || [];
|
|
||||||
existing.push(email);
|
|
||||||
threadMap.set(email.threadId, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build thread summaries
|
|
||||||
const threads: ThreadSummary[] = [];
|
|
||||||
for (const [threadId, threadEmails] of threadMap) {
|
|
||||||
const sorted = threadEmails.sort(
|
|
||||||
(a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
|
|
||||||
);
|
|
||||||
const latest = sorted[0];
|
|
||||||
const allRead = sorted.every((e) => e.keywords?.['$seen']);
|
|
||||||
const anyFlagged = sorted.some((e) => e.keywords?.['$flagged']);
|
|
||||||
const anyAttachment = sorted.some((e) => e.hasAttachment);
|
|
||||||
|
|
||||||
threads.push({
|
|
||||||
id: threadId,
|
|
||||||
subject: latest.subject,
|
|
||||||
snippet: latest.preview,
|
|
||||||
from: latest.from?.map((f) => ({ name: f.name, email: f.email })) ?? [],
|
|
||||||
lastMessageAt: latest.receivedAt,
|
|
||||||
messageCount: sorted.length,
|
|
||||||
isRead: allRead,
|
|
||||||
isFlagged: anyFlagged,
|
|
||||||
hasAttachment: anyAttachment,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by most recent message
|
|
||||||
threads.sort(
|
|
||||||
(a, b) => new Date(b.lastMessageAt).getTime() - new Date(a.lastMessageAt).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
return { threads, total };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get full thread with all messages and body content. */
|
|
||||||
async getThread(userId: string, threadId: string): Promise<ThreadDetail> {
|
|
||||||
const accountId = await this.resolveAccountId(userId);
|
|
||||||
|
|
||||||
// Get thread to find all email IDs
|
|
||||||
const threads = await this.jmap.getThreads(accountId, [threadId]);
|
|
||||||
if (threads.length === 0) throw new NotFoundError('Thread not found');
|
|
||||||
|
|
||||||
const emailIds = threads[0].emailIds;
|
|
||||||
|
|
||||||
// Fetch full email content
|
|
||||||
const emails = await Promise.all(
|
|
||||||
emailIds.map((id) => this.jmap.getEmailWithBody(accountId, id))
|
|
||||||
);
|
|
||||||
|
|
||||||
const messages: MessageDetail[] = emails
|
|
||||||
.filter((e): e is JmapEmail => e !== null)
|
|
||||||
.sort((a, b) => new Date(a.receivedAt).getTime() - new Date(b.receivedAt).getTime())
|
|
||||||
.map((email) => {
|
|
||||||
const textPartId = email.textBody?.[0]?.partId;
|
|
||||||
const htmlPartId = email.htmlBody?.[0]?.partId;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: email.id,
|
|
||||||
from: email.from,
|
|
||||||
to: email.to,
|
|
||||||
cc: email.cc,
|
|
||||||
subject: email.subject,
|
|
||||||
date: email.receivedAt,
|
|
||||||
preview: email.preview,
|
|
||||||
bodyText: textPartId ? email.bodyValues?.[textPartId]?.value : undefined,
|
|
||||||
bodyHtml: htmlPartId ? email.bodyValues?.[htmlPartId]?.value : undefined,
|
|
||||||
isRead: !!email.keywords?.['$seen'],
|
|
||||||
isFlagged: !!email.keywords?.['$flagged'],
|
|
||||||
hasAttachment: email.hasAttachment,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: threadId,
|
|
||||||
subject: messages[0]?.subject ?? '(kein Betreff)',
|
|
||||||
messages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update email flags (read, starred) or move between mailboxes. */
|
|
||||||
async updateMessage(
|
|
||||||
userId: string,
|
|
||||||
emailId: string,
|
|
||||||
update: { isRead?: boolean; isFlagged?: boolean; mailboxIds?: Record<string, boolean> }
|
|
||||||
): Promise<void> {
|
|
||||||
const accountId = await this.resolveAccountId(userId);
|
|
||||||
await this.jmap.updateEmail(accountId, emailId, update);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Send an email. */
|
|
||||||
async sendEmail(
|
|
||||||
userId: string,
|
|
||||||
email: {
|
|
||||||
to: { email: string; name?: string }[];
|
|
||||||
cc?: { email: string; name?: string }[];
|
|
||||||
bcc?: { email: string; name?: string }[];
|
|
||||||
subject: string;
|
|
||||||
body: string;
|
|
||||||
htmlBody?: string;
|
|
||||||
inReplyTo?: string;
|
|
||||||
references?: string[];
|
|
||||||
}
|
|
||||||
): Promise<{ emailId: string }> {
|
|
||||||
const account = await this.accountService.getDefaultAccount(userId);
|
|
||||||
if (!account?.stalwartAccountId) {
|
|
||||||
throw new NotFoundError('No mail account configured');
|
|
||||||
}
|
|
||||||
|
|
||||||
const emailId = await this.jmap.submitEmail(account.stalwartAccountId, {
|
|
||||||
from: { name: account.displayName, email: account.email },
|
|
||||||
to: email.to.map((t) => ({ name: t.name ?? null, email: t.email })),
|
|
||||||
cc: email.cc?.map((c) => ({ name: c.name ?? null, email: c.email })),
|
|
||||||
bcc: email.bcc?.map((b) => ({ name: b.name ?? null, email: b.email })),
|
|
||||||
subject: email.subject,
|
|
||||||
textBody: email.body,
|
|
||||||
htmlBody: email.htmlBody,
|
|
||||||
inReplyTo: email.inReplyTo,
|
|
||||||
references: email.references,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { emailId };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
import { describe, it, expect } from 'bun:test';
|
|
||||||
import { signToken, verifyToken, generateNonce } from './tracking-token';
|
|
||||||
|
|
||||||
const SECRET = 'test-secret-never-in-prod';
|
|
||||||
const OTHER_SECRET = 'other-secret';
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
campaignId: 'camp-abc-123',
|
|
||||||
sendId: 'send-xyz-789',
|
|
||||||
nonce: 'n_test_nonce',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('tracking-token', () => {
|
|
||||||
it('signs and verifies a token roundtrip', () => {
|
|
||||||
const token = signToken(payload, SECRET);
|
|
||||||
const decoded = verifyToken(token, SECRET);
|
|
||||||
expect(decoded).toEqual(payload);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns a URL-safe token (base64url, no padding)', () => {
|
|
||||||
const token = signToken(payload, SECRET);
|
|
||||||
expect(token).not.toContain('+');
|
|
||||||
expect(token).not.toContain('/');
|
|
||||||
expect(token).not.toContain('=');
|
|
||||||
expect(token).toContain('.');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects tokens signed with a different secret', () => {
|
|
||||||
const token = signToken(payload, OTHER_SECRET);
|
|
||||||
expect(verifyToken(token, SECRET)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects tampered payload', () => {
|
|
||||||
const token = signToken(payload, SECRET);
|
|
||||||
// Flip a character in the payload half — signature no longer matches.
|
|
||||||
const [p, s] = token.split('.');
|
|
||||||
const tampered = `${p.slice(0, -1)}X.${s}`;
|
|
||||||
expect(verifyToken(tampered, SECRET)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects tampered signature', () => {
|
|
||||||
const token = signToken(payload, SECRET);
|
|
||||||
const [p, s] = token.split('.');
|
|
||||||
const tampered = `${p}.${s.slice(0, -1)}X`;
|
|
||||||
expect(verifyToken(tampered, SECRET)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects malformed tokens', () => {
|
|
||||||
expect(verifyToken('', SECRET)).toBeNull();
|
|
||||||
expect(verifyToken('only-one-part', SECRET)).toBeNull();
|
|
||||||
expect(verifyToken('a.b.c', SECRET)).toBeNull(); // three parts
|
|
||||||
expect(verifyToken('!@#.!@#', SECRET)).toBeNull(); // bad base64
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles special chars in ids through base64-encoding', () => {
|
|
||||||
const withDots = {
|
|
||||||
campaignId: 'camp:with:colons',
|
|
||||||
sendId: 'send.with.dots',
|
|
||||||
nonce: 'n_ok',
|
|
||||||
};
|
|
||||||
const token = signToken(withDots, SECRET);
|
|
||||||
const decoded = verifyToken(token, SECRET);
|
|
||||||
expect(decoded).toEqual(withDots);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates unique nonces', () => {
|
|
||||||
const nonces = new Set<string>();
|
|
||||||
for (let i = 0; i < 100; i++) nonces.add(generateNonce());
|
|
||||||
expect(nonces.size).toBe(100);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generated nonces are URL-safe', () => {
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
const nonce = generateNonce();
|
|
||||||
expect(nonce).toMatch(/^[A-Za-z0-9_-]+$/);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,113 +0,0 @@
|
||||||
/**
|
|
||||||
* Tracking-token signing / verification.
|
|
||||||
*
|
|
||||||
* Tokens travel in publicly-fetchable URLs (the open pixel, the click
|
|
||||||
* redirect, the unsubscribe link). If they were just raw IDs, anyone who
|
|
||||||
* guessed a campaign+send id pair could forge events or unsubscribe
|
|
||||||
* other people.
|
|
||||||
*
|
|
||||||
* The token is an HMAC-SHA256 over `{campaignId}:{sendId}:{nonce}`,
|
|
||||||
* base64url-encoded into a single opaque blob. The nonce is stored in
|
|
||||||
* the DB (broadcast.sends.tracking_nonce) so rotating the signing key
|
|
||||||
* doesn't invalidate existing tokens — verification re-signs with the
|
|
||||||
* stored nonce and the current key.
|
|
||||||
*
|
|
||||||
* We don't use JWT here because:
|
|
||||||
* - JWT is overkill for a single-field payload
|
|
||||||
* - base64url-HMAC is ~50 chars vs JWT's ~150; better in a mailto
|
|
||||||
* - JWT's `alg` field adds an attack surface (alg=none, etc.)
|
|
||||||
*
|
|
||||||
* The signing key lives in BROADCAST_TRACKING_SECRET. Rotating it
|
|
||||||
* requires walking broadcast.sends and re-issuing tokens — deferred
|
|
||||||
* until we actually need rotation (Phase 2+).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
export interface TokenPayload {
|
|
||||||
campaignId: string;
|
|
||||||
sendId: string;
|
|
||||||
nonce: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Base64url-encode without padding (URL-safe). */
|
|
||||||
function base64url(buf: Buffer): string {
|
|
||||||
return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
function base64urlDecode(s: string): Buffer {
|
|
||||||
const padded = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
const pad = padded.length % 4 === 0 ? '' : '='.repeat(4 - (padded.length % 4));
|
|
||||||
return Buffer.from(padded + pad, 'base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a fresh per-send nonce. 16 bytes (128 bits) — enough entropy
|
|
||||||
* that even with 1M sends the collision chance is negligible.
|
|
||||||
*/
|
|
||||||
export function generateNonce(): string {
|
|
||||||
return base64url(randomBytes(16));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sign a token. The returned string is url-safe and ready to drop into
|
|
||||||
* a mail template.
|
|
||||||
*
|
|
||||||
* Format: `base64url(JSON(payload)).base64url(hmac)`
|
|
||||||
*
|
|
||||||
* Inner JSON encoding means campaign / send ids can contain arbitrary
|
|
||||||
* characters (colons, dots, whatever) without breaking the parse — the
|
|
||||||
* alternative, a delimiter-based raw string, puts a fragile escape
|
|
||||||
* dance on IDs that should just be opaque.
|
|
||||||
*
|
|
||||||
* Two sections separated by `.` — familiar from JWT but without the
|
|
||||||
* header (we don't need algorithm agility).
|
|
||||||
*/
|
|
||||||
export function signToken(payload: TokenPayload, secret: string): string {
|
|
||||||
const json = JSON.stringify(payload);
|
|
||||||
const payloadPart = base64url(Buffer.from(json, 'utf8'));
|
|
||||||
const sig = createHmac('sha256', secret).update(payloadPart).digest();
|
|
||||||
const sigPart = base64url(sig);
|
|
||||||
return `${payloadPart}.${sigPart}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify + decode a token. Returns null on ANY failure — invalid format,
|
|
||||||
* bad HMAC, truncated payload. Constant-time HMAC compare to avoid
|
|
||||||
* timing-side-channel on the secret.
|
|
||||||
*/
|
|
||||||
export function verifyToken(token: string, secret: string): TokenPayload | null {
|
|
||||||
const parts = token.split('.');
|
|
||||||
if (parts.length !== 2) return null;
|
|
||||||
const [payloadPart, sigPart] = parts;
|
|
||||||
|
|
||||||
const expectedSig = createHmac('sha256', secret).update(payloadPart).digest();
|
|
||||||
let providedSig: Buffer;
|
|
||||||
try {
|
|
||||||
providedSig = base64urlDecode(sigPart);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (providedSig.length !== expectedSig.length) return null;
|
|
||||||
if (!timingSafeEqual(providedSig, expectedSig)) return null;
|
|
||||||
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
const json = base64urlDecode(payloadPart).toString('utf8');
|
|
||||||
parsed = JSON.parse(json);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
!parsed ||
|
|
||||||
typeof parsed !== 'object' ||
|
|
||||||
typeof (parsed as TokenPayload).campaignId !== 'string' ||
|
|
||||||
typeof (parsed as TokenPayload).sendId !== 'string' ||
|
|
||||||
typeof (parsed as TokenPayload).nonce !== 'string'
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const { campaignId, sendId, nonce } = parsed as TokenPayload;
|
|
||||||
if (!campaignId || !sendId || !nonce) return null;
|
|
||||||
return { campaignId, sendId, nonce };
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"outDir": "dist",
|
|
||||||
"rootDir": "src",
|
|
||||||
"declaration": true,
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts"]
|
|
||||||
}
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue