feat(mail): add mana-mail service and frontend module (Phase 1 MVP)

Backend: Hono/Bun service on port 3042 with JMAP client for Stalwart,
account provisioning (@mana.how addresses on user registration),
thread/message/send/label API endpoints, and JWT + service-key auth.

Frontend: Mail module with 3-column inbox UI (mailboxes, thread list,
detail/compose), local-first encrypted drafts in Dexie, and API-driven
thread fetching. Scoped CSS with theme tokens.

Integration: Dexie v11 schema, mail pgSchema in mana_platform,
mana-auth fire-and-forget hook for account provisioning,
getManaMailUrl() in API config, app registry + branding update.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-13 20:35:54 +02:00
parent 40e1145e9f
commit a3de6b3d81
41 changed files with 2908 additions and 1 deletions

View file

@ -10,6 +10,7 @@ export interface Config {
manaNotifyUrl: string;
manaCreditsUrl: string;
manaSubscriptionsUrl: string;
manaMailUrl: string;
/** Base64-encoded 32-byte AES-256 key encryption key (KEK). Wraps each
* user's master key in auth.encryption_vaults. Required in production
* in development a deterministic dev KEK is auto-generated so the
@ -54,6 +55,7 @@ export function loadConfig(): Config {
manaNotifyUrl: env('MANA_NOTIFY_URL', 'http://localhost:3013'),
manaCreditsUrl: env('MANA_CREDITS_URL', 'http://localhost:3061'),
manaSubscriptionsUrl: env('MANA_SUBSCRIPTIONS_URL', 'http://localhost:3063'),
manaMailUrl: env('MANA_MAIL_URL', 'http://localhost:3042'),
encryptionKek,
};
}

View file

@ -87,6 +87,16 @@ export function createAuthRoutes(
headers: { 'Content-Type': 'application/json', 'X-Service-Key': config.serviceKey },
body: JSON.stringify({ userId: response.user.id, email: body.email }),
}).catch(() => {});
// Provision mail account (fire-and-forget)
fetch(`${config.manaMailUrl}/api/v1/internal/mail/on-user-created`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Service-Key': config.serviceKey },
body: JSON.stringify({
userId: response.user.id,
email: body.email,
name: body.name || body.email.split('@')[0],
}),
}).catch(() => {});
}
return c.json(response);

View file

@ -0,0 +1,96 @@
# 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`

View file

@ -0,0 +1,40 @@
# 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"]

View file

@ -0,0 +1,11 @@
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',
},
schemaFilter: ['mail'],
});

View file

@ -0,0 +1,25 @@
{
"name": "@mana/mail-service",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --watch 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:*",
"hono": "^4.7.0",
"drizzle-orm": "^0.38.3",
"postgres": "^3.4.5",
"jose": "^6.1.2",
"zod": "^3.24.0"
},
"devDependencies": {
"drizzle-kit": "^0.30.4",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,64 @@
/**
* 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[];
};
}
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(','),
},
};
}

View file

@ -0,0 +1,19 @@
/**
* 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>;

View file

@ -0,0 +1 @@
export * from './mail';

View file

@ -0,0 +1,72 @@
/**
* 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;

View file

@ -0,0 +1,72 @@
/**
* 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 { 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';
// ─── 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);
// ─── 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);
// 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', 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,
};

View file

@ -0,0 +1,31 @@
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 });
}
}

View file

@ -0,0 +1,42 @@
/**
* 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(),
});

View file

@ -0,0 +1,57 @@
/**
* 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');
}
};
}

View file

@ -0,0 +1,26 @@
/**
* 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();
};
}

View file

@ -0,0 +1,24 @@
/**
* 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);
});
}

View file

@ -0,0 +1,5 @@
import { Hono } from 'hono';
export const healthRoutes = new Hono().get('/', (c) =>
c.json({ status: 'ok', service: 'mana-mail', timestamp: new Date().toISOString() })
);

View file

@ -0,0 +1,29 @@
/**
* Internal routes service-to-service (X-Service-Key auth).
*/
import { Hono } from 'hono';
import type { AccountService } from '../services/account-service';
import { onUserCreatedSchema } from '../lib/validation';
export function createInternalRoutes(accountService: AccountService) {
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' });
});
}

View file

@ -0,0 +1,15 @@
/**
* 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);
});
}

View file

@ -0,0 +1,18 @@
/**
* 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 });
});
}

View file

@ -0,0 +1,17 @@
/**
* 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);
});
}

View file

@ -0,0 +1,25 @@
/**
* 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);
});
}

View file

@ -0,0 +1,164 @@
/**
* 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;
}
}

View file

@ -0,0 +1,322 @@
/**
* 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[];
}
): 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;
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 ?? '';
}
}

View file

@ -0,0 +1,233 @@
/**
* 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 };
}
}

View file

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src/**/*.ts"]
}