managarten/services/mana-mail/src/index.ts
Till JS 260dd312a9 feat(broadcast): M8 DNS auth check (SPF / DKIM / DMARC)
Closes the last plan milestone. Users can verify their sending-domain
setup without leaving the broadcast settings page.

Server (mana-mail)
- services/dns-check.ts: parseSpf / parseDkim / parseDmarc are pure
  functions. SPF accepts include:<mailDomain>, flags weak (+all) and
  wrong (include missing) and multi-record (RFC 7208 §3.2). DKIM
  needs v=DKIM1 + a p= public-key segment. DMARC requires v=DMARC1,
  flags p=none as weak (monitoring only), ok on quarantine/reject.
  All three are case-insensitive.
- lookupTxt(): DNS-over-HTTPS against Cloudflare 1.1.1.1 — avoids
  the Bun/container udp-resolver flakiness and works everywhere.
  Multi-string TXT (`"a" "b"`) get concatenated before parsing.
- checkDomain(): one call, three parallel DoH lookups, returns a
  structured result with suggested copy-paste records scoped to the
  user's actual mail domain from config.
- Route: GET /v1/mail/dns-check?domain=&selector= (JWT auth). Zod
  validates the domain looks sensible before hitting DoH.
- 16 unit tests covering all three parsers + multi-record edge case.

Client
- api.ts: runDnsCheck(domain, selector?) helper with typed result.
- components/DnsCheckBanner.svelte: derives domain from the default
  from-email (after @), calls the check on-demand, renders per-record
  status chips (ok / weak / wrong / missing) with messages, exposes
  copy-pasteable SPF + DMARC records when anything's off. DKIM setup
  is provider-specific so we show a hint rather than a canned record.
  Last-check timestamp persists to settings.dnsCheck so the banner
  survives a reload without re-hitting the API.
- Wired into SettingsForm between Impressum and Standard-Footer —
  where the user is already thinking about "what's required to
  actually send".

All checks clean:
- webapp pnpm check: 0 broadcast errors (4 pre-existing articles errors
  from parallel Spaces work, unrelated)
- mana-mail tests: 36/36 across tracking-token + link-rewriter + dns-check
- mana-mail build: 2.51 MB (+8 KB for juice — dns-check itself is ~3 KB)

Plan: docs/plans/broadcast-module.md §M8. All 10 milestones now done.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:48:03 +02:00

100 lines
3.9 KiB
TypeScript

/**
* 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));
// ─── Start ──────────────────────────────────────────────────
console.log(`mana-mail starting on port ${config.port}...`);
export default {
port: config.port,
fetch: app.fetch,
};