diff --git a/apps/mana/apps/web/src/lib/api/config.ts b/apps/mana/apps/web/src/lib/api/config.ts index f257743f8..9e4edf751 100644 --- a/apps/mana/apps/web/src/lib/api/config.ts +++ b/apps/mana/apps/web/src/lib/api/config.ts @@ -59,3 +59,16 @@ export function getManaCreditsUrl(): string { } return process.env.PUBLIC_MANA_CREDITS_URL || 'http://localhost:3061'; } + +/** + * Get the mana-mail service URL. + * Hosts mail threads, send, labels, accounts. + */ +export function getManaMailUrl(): string { + if (browser && typeof window !== 'undefined') { + const injected = (window as unknown as { __PUBLIC_MANA_MAIL_URL__?: string }) + .__PUBLIC_MANA_MAIL_URL__; + return injected || 'http://localhost:3042'; + } + return process.env.PUBLIC_MANA_MAIL_URL || 'http://localhost:3042'; +} diff --git a/apps/mana/apps/web/src/lib/modules/mail/ListView.svelte b/apps/mana/apps/web/src/lib/modules/mail/ListView.svelte new file mode 100644 index 000000000..9c4961fa5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mail/ListView.svelte @@ -0,0 +1,574 @@ + + + +
+ +
+ +
+ {#each mailStore.mailboxes as mb (mb.id)} + +
selectMailbox(mb.id)} + > + {getMailboxLabel(mb)} + {#if mb.unreadEmails > 0} + {mb.unreadEmails} + {/if} +
+ {/each} +
+
+ + +
+ {#if mailStore.loading && mailStore.threads.length === 0} +
Lade Mails...
+ {:else if mailStore.error} +
+

{mailStore.error}

+ +
+ {:else if mailStore.threads.length === 0} +
+

Keine Mails

+

Dein Postfach ist leer.

+
+ {:else} + {#each mailStore.threads as thread (thread.id)} + +
selectThread(thread)} + oncontextmenu={(e) => ctxMenu.open(e, thread)} + > +
+ {#if thread.isFlagged}{/if} + {formatSender(thread.from)} +
+
{thread.subject}
+
{thread.snippet}
+
+ {formatDate(thread.lastMessageAt)} + {#if thread.messageCount > 1} + {thread.messageCount} + {/if} +
+
+ {/each} + {/if} +
+ + +
+ {#if showCompose} + +
e.key === 'Escape' && (showCompose = false)} + > +
Neue Nachricht
+ + + + +
+ + +
+
+ {:else if mailStore.activeThread} +
+

{mailStore.activeThread.subject}

+ {#each mailStore.activeThread.messages as msg (msg.id)} +
+
+ {msg.from?.[0]?.name || msg.from?.[0]?.email || 'Unbekannt'} + {formatDate(msg.date)} +
+ {#if msg.to} +
An: {msg.to.map((t) => t.name || t.email).join(', ')}
+ {/if} +
+ {#if msg.bodyHtml} + {@html msg.bodyHtml} + {:else} +
{msg.bodyText || msg.preview}
+ {/if} +
+
+ {/each} +
+ {:else} +
+

Wähle eine Nachricht aus

+
+ {/if} +
+ + +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/mail/api.ts b/apps/mana/apps/web/src/lib/modules/mail/api.ts new file mode 100644 index 000000000..c6be9f846 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mail/api.ts @@ -0,0 +1,88 @@ +/** + * Mail API client — communicates with the mana-mail service. + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaMailUrl } from '$lib/api/config'; +import type { ThreadSummary, ThreadDetail, MailboxInfo, MailAccount, EmailAddress } from './types'; + +async function fetchWithAuth(endpoint: string, options: RequestInit = {}): Promise { + const token = await authStore.getValidToken(); + + const response = await fetch(`${getManaMailUrl()}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...options.headers, + }, + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return response.json(); +} + +export const mailApi = { + async getThreads(opts: { mailboxId?: string; limit?: number; offset?: number } = {}): Promise<{ + threads: ThreadSummary[]; + total: number; + }> { + const params = new URLSearchParams(); + if (opts.mailboxId) params.set('mailboxId', opts.mailboxId); + if (opts.limit) params.set('limit', String(opts.limit)); + if (opts.offset) params.set('offset', String(opts.offset)); + return fetchWithAuth(`/api/v1/mail/threads?${params}`); + }, + + async getThread(threadId: string): Promise { + return fetchWithAuth(`/api/v1/mail/threads/${threadId}`); + }, + + async updateMessage( + emailId: string, + update: { isRead?: boolean; isFlagged?: boolean; mailboxIds?: Record } + ): Promise { + await fetchWithAuth(`/api/v1/mail/messages/${emailId}`, { + method: 'PUT', + body: JSON.stringify(update), + }); + }, + + async sendEmail(email: { + to: EmailAddress[]; + cc?: EmailAddress[]; + bcc?: EmailAddress[]; + subject: string; + body: string; + htmlBody?: string; + inReplyTo?: string; + references?: string[]; + }): Promise<{ emailId: string }> { + return fetchWithAuth('/api/v1/mail/send', { + method: 'POST', + body: JSON.stringify(email), + }); + }, + + async getLabels(): Promise { + return fetchWithAuth('/api/v1/mail/labels'); + }, + + async getAccounts(): Promise { + return fetchWithAuth('/api/v1/mail/accounts'); + }, + + async updateAccount( + accountId: string, + update: { displayName?: string; signature?: string } + ): Promise { + return fetchWithAuth(`/api/v1/mail/accounts/${accountId}`, { + method: 'PUT', + body: JSON.stringify(update), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/mail/collections.ts b/apps/mana/apps/web/src/lib/modules/mail/collections.ts new file mode 100644 index 000000000..d85ad13bb --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mail/collections.ts @@ -0,0 +1,11 @@ +/** + * Mail module — collection accessors. + * + * mailDrafts are local-first (stored in Dexie, encrypted). + * Threads/messages are fetched from the mana-mail API (not cached in Dexie). + */ + +import { db } from '$lib/data/database'; +import type { LocalMailDraft } from './types'; + +export const mailDraftTable = db.table('mailDrafts'); diff --git a/apps/mana/apps/web/src/lib/modules/mail/index.ts b/apps/mana/apps/web/src/lib/modules/mail/index.ts new file mode 100644 index 000000000..6c2afc91a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mail/index.ts @@ -0,0 +1,20 @@ +/** + * Mail module — barrel exports. + */ + +export { mailStore } from './stores/mail.svelte'; +export { draftsStore } from './stores/drafts.svelte'; +export { useAllDrafts, toMailDraft, formatSender, formatDate } from './queries'; +export { mailDraftTable } from './collections'; +export { mailApi } from './api'; +export { SYSTEM_MAILBOXES } from './types'; +export type { + ThreadSummary, + ThreadDetail, + MessageDetail, + EmailAddress, + MailboxInfo, + MailAccount, + LocalMailDraft, + MailDraft, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/mail/module.config.ts b/apps/mana/apps/web/src/lib/modules/mail/module.config.ts new file mode 100644 index 000000000..10438b9b0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mail/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const mailModuleConfig: ModuleConfig = { + appId: 'mail', + tables: [{ name: 'mailDrafts' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/mail/queries.ts b/apps/mana/apps/web/src/lib/modules/mail/queries.ts new file mode 100644 index 000000000..2a3cdd2c3 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mail/queries.ts @@ -0,0 +1,65 @@ +/** + * Mail module queries — drafts (local-first) + API data helpers. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { decryptRecords } from '$lib/data/crypto'; +import { db } from '$lib/data/database'; +import type { LocalMailDraft, MailDraft } from './types'; + +// ─── Draft Converter ──────────────────────────────────────── + +export function toMailDraft(local: LocalMailDraft): MailDraft { + const now = new Date().toISOString(); + return { + id: local.id, + accountId: local.accountId, + to: local.to ?? '', + cc: local.cc ?? '', + subject: local.subject ?? '', + body: local.body ?? '', + htmlBody: local.htmlBody ?? '', + replyToMessageId: local.replyToMessageId ?? null, + createdAt: local.createdAt ?? now, + updatedAt: local.updatedAt ?? now, + }; +} + +// ─── Live Queries (local drafts) ──────────────────────────── + +export function useAllDrafts() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('mailDrafts').toArray(); + const visible = locals.filter((d) => !d.deletedAt); + const decrypted = await decryptRecords('mailDrafts', visible); + return decrypted.map(toMailDraft); + }, [] as MailDraft[]); +} + +// ─── Pure Helpers ─────────────────────────────────────────── + +export function formatSender(from: { name: string | null; email: string }[]): string { + if (from.length === 0) return 'Unbekannt'; + const first = from[0]; + return first.name || first.email; +} + +export function formatDate(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const isToday = + date.getDate() === now.getDate() && + date.getMonth() === now.getMonth() && + date.getFullYear() === now.getFullYear(); + + if (isToday) { + return date.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + } + + const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24)); + if (diffDays < 7) { + return date.toLocaleDateString('de-DE', { weekday: 'short' }); + } + + return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' }); +} diff --git a/apps/mana/apps/web/src/lib/modules/mail/stores/drafts.svelte.ts b/apps/mana/apps/web/src/lib/modules/mail/stores/drafts.svelte.ts new file mode 100644 index 000000000..5a88cd982 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mail/stores/drafts.svelte.ts @@ -0,0 +1,55 @@ +/** + * Drafts Store — local-first draft management. + */ + +import { encryptRecord } from '$lib/data/crypto'; +import { mailDraftTable } from '../collections'; +import { toMailDraft } from '../queries'; +import type { LocalMailDraft } from '../types'; + +export const draftsStore = { + async saveDraft(input: { + accountId: string; + to?: string; + cc?: string; + subject?: string; + body?: string; + htmlBody?: string; + replyToMessageId?: string | null; + existingDraftId?: string; + }) { + if (input.existingDraftId) { + const wrapped = { ...input } as Record; + delete wrapped.existingDraftId; + delete wrapped.accountId; + await encryptRecord('mailDrafts', wrapped); + await mailDraftTable.update(input.existingDraftId, { + ...wrapped, + updatedAt: new Date().toISOString(), + }); + return input.existingDraftId; + } + + const newLocal: LocalMailDraft = { + id: crypto.randomUUID(), + accountId: input.accountId, + to: input.to ?? '', + cc: input.cc ?? '', + subject: input.subject ?? '', + body: input.body ?? '', + htmlBody: input.htmlBody ?? '', + replyToMessageId: input.replyToMessageId ?? null, + }; + const snapshot = toMailDraft({ ...newLocal }); + await encryptRecord('mailDrafts', newLocal); + await mailDraftTable.add(newLocal); + return snapshot.id; + }, + + async deleteDraft(id: string) { + await mailDraftTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/mail/stores/mail.svelte.ts b/apps/mana/apps/web/src/lib/modules/mail/stores/mail.svelte.ts new file mode 100644 index 000000000..35053b37e --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mail/stores/mail.svelte.ts @@ -0,0 +1,111 @@ +/** + * Mail Store — API-driven actions for reading and sending mail. + */ + +import { mailApi } from '../api'; +import type { ThreadSummary, ThreadDetail, MailboxInfo } from '../types'; + +// ─── Reactive State ───────────────────────────────────────── + +let threads = $state([]); +let totalThreads = $state(0); +let activeThread = $state(null); +let mailboxes = $state([]); +let activeMailboxId = $state(null); +let loading = $state(false); +let error = $state(null); + +export const mailStore = { + get threads() { + return threads; + }, + get totalThreads() { + return totalThreads; + }, + get activeThread() { + return activeThread; + }, + get mailboxes() { + return mailboxes; + }, + get activeMailboxId() { + return activeMailboxId; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + + async loadMailboxes() { + try { + mailboxes = await mailApi.getLabels(); + } catch (e) { + console.error('[mail] Failed to load mailboxes:', e); + } + }, + + async loadThreads(opts: { mailboxId?: string; limit?: number; offset?: number } = {}) { + loading = true; + error = null; + try { + const result = await mailApi.getThreads(opts); + threads = result.threads; + totalThreads = result.total; + if (opts.mailboxId !== undefined) activeMailboxId = opts.mailboxId ?? null; + } catch (e) { + error = e instanceof Error ? e.message : 'Fehler beim Laden'; + console.error('[mail] Failed to load threads:', e); + } finally { + loading = false; + } + }, + + async loadThread(threadId: string) { + loading = true; + error = null; + try { + activeThread = await mailApi.getThread(threadId); + } catch (e) { + error = e instanceof Error ? e.message : 'Fehler beim Laden'; + console.error('[mail] Failed to load thread:', e); + } finally { + loading = false; + } + }, + + async markRead(emailId: string) { + await mailApi.updateMessage(emailId, { isRead: true }); + // Optimistic update + threads = threads.map((t) => (t.id === activeThread?.id ? { ...t, isRead: true } : t)); + }, + + async toggleStar(emailId: string, currentState: boolean) { + await mailApi.updateMessage(emailId, { isFlagged: !currentState }); + }, + + async archive(emailId: string, archiveMailboxId: string) { + await mailApi.updateMessage(emailId, { mailboxIds: { [archiveMailboxId]: true } }); + }, + + async sendEmail(email: { + to: { email: string; name?: string }[]; + cc?: { email: string; name?: string }[]; + subject: string; + body: string; + htmlBody?: string; + inReplyTo?: string; + references?: string[]; + }) { + return mailApi.sendEmail({ + ...email, + to: email.to.map((t) => ({ email: t.email, name: t.name ?? null })), + cc: email.cc?.map((c) => ({ email: c.email, name: c.name ?? null })), + }); + }, + + clearActiveThread() { + activeThread = null; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/mail/types.ts b/apps/mana/apps/web/src/lib/modules/mail/types.ts new file mode 100644 index 000000000..48e9cc5f4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/mail/types.ts @@ -0,0 +1,99 @@ +/** + * Mail module types. + */ + +import type { BaseRecord } from '@mana/local-store'; + +// ─── API Response Types ───────────────────────────────────── + +export interface ThreadSummary { + id: string; + subject: string; + snippet: string; + from: EmailAddress[]; + 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: EmailAddress[] | null; + to: EmailAddress[] | null; + cc: EmailAddress[] | null; + subject: string; + date: string; + preview: string; + bodyText?: string; + bodyHtml?: string; + isRead: boolean; + isFlagged: boolean; + hasAttachment: boolean; +} + +export interface EmailAddress { + name: string | null; + email: string; +} + +export interface MailboxInfo { + id: string; + name: string; + role: string | null; + totalEmails: number; + unreadEmails: number; +} + +export interface MailAccount { + id: string; + userId: string; + email: string; + displayName: string | null; + provider: string; + isDefault: boolean; + signature: string | null; +} + +// ─── Local Cache Types (Dexie) ────────────────────────────── + +export interface LocalMailDraft extends BaseRecord { + accountId: string; + to: string; + cc: string; + subject: string; + body: string; + htmlBody: string; + replyToMessageId: string | null; +} + +export interface MailDraft { + id: string; + accountId: string; + to: string; + cc: string; + subject: string; + body: string; + htmlBody: string; + replyToMessageId: string | null; + createdAt: string; + updatedAt: string; +} + +// ─── Constants ────────────────────────────────────────────── + +export const SYSTEM_MAILBOXES: Record = { + inbox: { label: 'Posteingang', icon: 'tray' }, + sent: { label: 'Gesendet', icon: 'paper-plane-tilt' }, + drafts: { label: 'Entwürfe', icon: 'note-pencil' }, + trash: { label: 'Papierkorb', icon: 'trash' }, + junk: { label: 'Spam', icon: 'warning' }, + archive: { label: 'Archiv', icon: 'archive-box' }, +}; diff --git a/apps/mana/apps/web/src/routes/(app)/mail/+layout.svelte b/apps/mana/apps/web/src/routes/(app)/mail/+layout.svelte new file mode 100644 index 000000000..ae9c9d035 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/mail/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/apps/mana/apps/web/src/routes/(app)/mail/+page.svelte b/apps/mana/apps/web/src/routes/(app)/mail/+page.svelte new file mode 100644 index 000000000..ec93d309a --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/mail/+page.svelte @@ -0,0 +1,9 @@ + + + + Mail - Mana + + + diff --git a/docker/init-db/02-create-schemas.sh b/docker/init-db/02-create-schemas.sh index 7ed7eeb91..1cf239377 100755 --- a/docker/init-db/02-create-schemas.sh +++ b/docker/init-db/02-create-schemas.sh @@ -20,8 +20,9 @@ psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname mana_platform <<-EO CREATE SCHEMA IF NOT EXISTS presi; CREATE SCHEMA IF NOT EXISTS uload; CREATE SCHEMA IF NOT EXISTS cards; + CREATE SCHEMA IF NOT EXISTS mail; -- Grant schema usage GRANT ALL ON SCHEMA auth, credits, gifts, subscriptions, feedback, usr, media, - todo, traces, presi, uload, cards TO mana; + todo, traces, presi, uload, cards, mail TO mana; EOSQL diff --git a/docs/plans/mail-module-plan.md b/docs/plans/mail-module-plan.md new file mode 100644 index 000000000..d3ab8dbda --- /dev/null +++ b/docs/plans/mail-module-plan.md @@ -0,0 +1,394 @@ +# ManaMail — Module Plan + +## Motivation + +Mana hat bereits eine vollständige Mail-Infrastruktur (Stalwart, mana-notify, DNS/DKIM/SPF), die bisher nur für Transaktionsmails genutzt wird. Ein Mail-Modul macht `@mana.how`-Adressen für jeden User möglich und schafft einen integrierten Mail-Client mit KI-Features — tief verknüpft mit Todo, Kalender, Kontakte und anderen Modulen. + +## Bestehendes + +| Komponente | Status | Details | +|---|---|---| +| Stalwart Mail Server | Produktiv | Rust, Container `mana-mail`, Ports 25/587/465/993/8443 | +| DNS (MX, SPF, DKIM, DMARC) | Konfiguriert | `mail.mana.how` → 194.191.241.139 | +| mana-notify | Produktiv | Go, Port 3013, SMTP-Gateway mit Retry/Queue | +| Mailpit (Dev) | Konfiguriert | Port 1025/8025 für lokale Entwicklung | +| Stalwart Admin API | Verfügbar | Account-CRUD, DKIM-Management | +| Inbound Port-Forwarding | **TODO** | Fritz!Box Ports 25/587/465 → Mac Mini | + +### Stalwart JMAP-Support + +Stalwart unterstützt **JMAP** (RFC 8620) nativ — ein modernes, REST-basiertes Mail-Protokoll. Vorteile gegenüber IMAP: +- JSON über HTTP (kein kompliziertes IMAP-State-Management) +- Push-Benachrichtigungen bei neuen Mails (EventSource) +- Effizientes Delta-Sync (nur Änderungen seit letztem State) +- Batch-Requests (mehrere Operationen in einem Call) +- Attachment-Upload via Blob-Store + +**JMAP wird der primäre Zugriffspfad** für den mana-mail Service. + +--- + +## Architektur + +``` +Browser (Mana Web) + ↓ REST / SSE +mana-mail Service (Hono/Bun, Port 3042) + ↓ JMAP (HTTP) ↓ SMTP (Port 587) + Stalwart Stalwart + (Lesen/Sync) (Senden) + ↓ +Internet ← MX: mail.mana.how +``` + +### Warum ein eigener Service (nicht direkt JMAP vom Browser)? + +1. **Credentials** — JMAP-Auth-Credentials dürfen nicht im Browser landen +2. **KI-Processing** — Zusammenfassungen, Kategorisierung laufen serverseitig +3. **Cross-Module-Linking** — Server kann Mails mit Todo/Kalender/Kontakte verknüpfen +4. **Rate-Limiting & Caching** — Server cached Thread-Listen, limitiert API-Calls +5. **Account-Provisioning** — Stalwart-Account-Erstellung bei User-Registrierung + +--- + +## Phase 1: Fundament (MVP) + +### 1.1 Service: `services/mana-mail/` + +**Stack:** Hono/Bun, Drizzle ORM, PostgreSQL (`mana_platform.mail` Schema) + +**Port:** 3042 + +``` +services/mana-mail/ +├── src/ +│ ├── index.ts # Bootstrap + Route-Mounting +│ ├── config.ts # Env-Vars laden +│ ├── routes/ +│ │ ├── threads.ts # GET /threads, GET /threads/:id +│ │ ├── messages.ts # PUT /messages/:id (read/star/archive) +│ │ ├── send.ts # POST /send, POST /draft +│ │ ├── labels.ts # GET /labels, POST /labels +│ │ ├── accounts.ts # GET /accounts (user's mail accounts) +│ │ └── internal.ts # POST /internal/on-user-created +│ ├── services/ +│ │ ├── jmap-client.ts # JMAP-Verbindung zu Stalwart +│ │ ├── mail-service.ts # Business-Logik (Thread-Aufbau, Suche) +│ │ ├── send-service.ts # SMTP-Senden via Stalwart +│ │ └── account-service.ts# Stalwart Account-Provisioning +│ ├── middleware/ +│ │ └── jwt-auth.ts # JWT-Validierung (wie mana-credits) +│ └── db/ +│ └── schema/ +│ └── mail.ts # Drizzle Schema (pgSchema('mail')) +├── package.json +├── tsconfig.json +├── CLAUDE.md +└── Dockerfile +``` + +### 1.2 Datenbank-Schema (`mana_platform`, Schema `mail`) + +```sql +-- User-Mailbox-Einstellungen (nicht die Mails selbst — die leben in Stalwart) +mail.accounts ( + id UUID PK, + user_id TEXT NOT NULL REFERENCES auth.users(id), + email TEXT NOT NULL UNIQUE, -- z.B. till@mana.how + display_name TEXT, + provider TEXT DEFAULT 'stalwart', -- Phase 2: 'gmail', 'outlook' + is_default BOOLEAN DEFAULT true, + signature TEXT, -- HTML-Signatur + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ +) + +-- Lokaler Label-Cache (Stalwart Labels + User-eigene) +mail.labels ( + id UUID PK, + account_id UUID REFERENCES mail.accounts(id), + stalwart_id TEXT, -- JMAP mailboxId + name TEXT NOT NULL, + color TEXT, + type TEXT DEFAULT 'user', -- 'system' | 'user' + sort_order INT DEFAULT 0 +) + +-- KI-generierte Metadaten pro Thread (Cache) +mail.thread_metadata ( + id UUID PK, + account_id UUID REFERENCES mail.accounts(id), + thread_id TEXT NOT NULL, -- JMAP threadId + summary TEXT, -- KI-Zusammenfassung + category TEXT, -- 'important'|'newsletter'|'social'|'todo' + sentiment TEXT, -- 'positive'|'neutral'|'negative' + linked_items JSONB, -- [{appId, recordId}] Cross-Module-Links + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + UNIQUE(account_id, thread_id) +) +``` + +**Keine Mail-Inhalte in der DB** — Mails leben in Stalwart, der Service ist ein Proxy/Cache. + +### 1.3 API-Endpunkte + +**User-Endpoints (JWT-Auth):** + +| Method | Path | Beschreibung | +|---|---|---| +| `GET` | `/api/v1/mail/threads` | Thread-Liste (paginiert, Filter nach Label/Unread) | +| `GET` | `/api/v1/mail/threads/:id` | Thread mit allen Messages | +| `PUT` | `/api/v1/mail/messages/:id` | Status ändern (read/unread, star, archive, label) | +| `POST` | `/api/v1/mail/send` | Mail senden (oder Reply) | +| `POST` | `/api/v1/mail/draft` | Entwurf speichern | +| `DELETE` | `/api/v1/mail/draft/:id` | Entwurf löschen | +| `GET` | `/api/v1/mail/labels` | Labels abrufen | +| `POST` | `/api/v1/mail/labels` | Label erstellen | +| `GET` | `/api/v1/mail/accounts` | User's Mail-Accounts | +| `PUT` | `/api/v1/mail/accounts/:id` | Account-Einstellungen (Signatur etc.) | +| `GET` | `/api/v1/mail/live` | SSE-Stream für neue Mails (JMAP EventSource) | + +**Service-Endpoints (X-Service-Key):** + +| Method | Path | Beschreibung | +|---|---|---| +| `POST` | `/api/v1/internal/mail/on-user-created` | Account in Stalwart anlegen | +| `POST` | `/api/v1/internal/mail/on-user-deleted` | Account in Stalwart deaktivieren | + +### 1.4 JMAP-Client + +```typescript +// services/mana-mail/src/services/jmap-client.ts + +class JmapClient { + constructor(private baseUrl: string, private adminToken: string) {} + + // Authentifizierung: Service nutzt Admin-Token, scoped auf User-Account + async getThreads(accountId: string, opts: { + limit?: number; + position?: number; + filter?: { inMailbox?: string; isUnread?: boolean }; + sort?: { property: string; isAscending: boolean }[]; + }): Promise + + async getThread(accountId: string, threadId: string): Promise + + async getEmails(accountId: string, emailIds: string[]): Promise + + async setEmailFlags(accountId: string, emailId: string, flags: { + isRead?: boolean; + isFlagged?: boolean; + mailboxIds?: Record; + }): Promise + + async sendEmail(accountId: string, email: { + to: Address[]; cc?: Address[]; bcc?: Address[]; + subject: string; body: string; htmlBody?: string; + inReplyTo?: string; references?: string[]; + attachments?: Blob[]; + }): Promise // returns emailId + + async subscribe(accountId: string, onNewEmail: (email: Email) => void): EventSource +} +``` + +### 1.5 Account-Provisioning (bei User-Registrierung) + +In `mana-auth/src/routes/auth.ts` ergänzen (fire-and-forget Pattern): + +```typescript +// Nach erfolgreicher Registrierung: +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, + }), +}).catch(() => {}); +``` + +`mana-mail` erstellt dann: +1. Stalwart-Account via Admin-API (`POST /api/principal`) +2. `mail.accounts` Eintrag in DB +3. System-Labels (Inbox, Sent, Drafts, Trash, Spam, Archive) + +**Adress-Vergabe:** +- Default: `username@mana.how` (aus Auth-Username) +- Fallback bei Kollision: `username123@mana.how` +- Aliases möglich: `vorname.nachname@mana.how` + +### 1.6 Frontend-Modul: `modules/mail/` + +``` +modules/mail/ +├── module.config.ts # appId: 'mail', tables: [mailCache, mailDrafts] +├── types.ts # Thread, Message, Label, MailAccount +├── collections.ts # Dexie-Cache-Tabellen +├── queries.ts # useThreads(), useThread(), useLabels() +├── stores/ +│ ├── mail.svelte.ts # send, reply, markRead, star, archive, moveToLabel +│ └── drafts.svelte.ts # saveDraft, deleteDraft (local-first) +├── api.ts # fetchWithAuth Wrapper für mana-mail Service +├── ListView.svelte # Inbox-Ansicht (Thread-Liste) +├── views/ +│ ├── ThreadView.svelte # Thread-Detail mit Message-Liste +│ └── ComposeView.svelte # Neue Mail / Reply schreiben +└── index.ts +``` + +**Dexie-Cache (local-first für Offline):** + +``` +// database.ts v9 +mailCache: 'id, threadId, accountId, date, isRead, isFlagged, [accountId+date]' +mailDrafts: 'id, accountId, replyToId' +``` + +- `mailCache` speichert die letzten ~500 Thread-Headers für Offline-Zugriff +- `mailDrafts` speichert Entwürfe lokal (wie alle Module, encrypted) +- Encryption: `title` (subject), `snippet`, `body` in mailCache; `to`, `subject`, `body` in mailDrafts + +**ListView.svelte (Inbox):** +- Thread-Liste mit Absender, Betreff, Snippet, Datum, Unread-Badge +- Filter: Inbox / Sent / Drafts / Archive / Labels +- Suche über Betreff + Absender +- Pull-to-refresh / SSE für Live-Updates +- Swipe-Gesten: Links = Archivieren, Rechts = Markieren + +**ThreadView.svelte (Detail):** +- Chronologische Message-Liste im Thread +- Reply/Reply-All/Forward Buttons +- Attachment-Anzeige + Download +- "Als Aufgabe erstellen" → Todo-Modul +- "Termin erstellen" → Kalender-Modul + +**ComposeView.svelte (Schreiben):** +- To/Cc/Bcc mit Kontakte-Autocomplete +- Rich-Text-Editor (oder Markdown) +- Attachment-Upload via mana-media +- Signatur automatisch anhängen +- Entwurf-Auto-Save (local-first in Dexie) + +--- + +## Phase 2: KI-Features + +### 2.1 Thread-Zusammenfassungen +- Bei jedem neuen Thread: `mana-llm` oder `local-llm` erstellt Summary +- Gespeichert in `mail.thread_metadata.summary` +- Im ListView als Tooltip oder aufklappbarer Abschnitt + +### 2.2 Smart Reply +- 3 Antwort-Vorschläge basierend auf Thread-Kontext +- Generiert via `mana-llm` API +- One-Click-Send oder als Basis für eigene Antwort + +### 2.3 Auto-Kategorisierung +- Neue Mails werden automatisch kategorisiert: `important` / `newsletter` / `social` / `todo` +- Basis: Absender-Reputation, Betreff-Analyse, Inhalt +- User kann Kategorisierung korrigieren (→ lernt pro User) + +### 2.4 Aktions-Extraktion +- KI erkennt: "Meeting am Donnerstag um 14 Uhr" → Kalender-Vorschlag +- "Bitte bis Freitag erledigen" → Todo-Vorschlag +- "Rechnung anbei" → Finance-Modul Vorschlag +- Angezeigt als Action-Chips unter der Mail + +--- + +## Phase 3: Multi-Account + +### 3.1 Externe Accounts +- Gmail via Google OAuth + Gmail API +- Outlook via Microsoft OAuth + Microsoft Graph +- Generisches IMAP/SMTP für andere Provider + +### 3.2 Unified Inbox +- Alle Accounts in einer Thread-Liste +- Account-Badge pro Thread (welcher Account) +- Antwort automatisch vom richtigen Account + +--- + +## Phase 4: Erweitert + +### 4.1 Scheduling +- "Später senden" (Mail in Queue mit Zeitstempel) +- "Erinnere mich" (Snooze — Mail verschwindet und taucht zu Zeitpunkt wieder auf) + +### 4.2 Templates +- Häufige Antworten als wiederverwendbare Templates +- Variablen-Platzhalter: `{{name}}`, `{{datum}}` + +### 4.3 Mail-Regeln +- Automatische Label-Zuweisung basierend auf Absender/Betreff +- Auto-Forward, Auto-Archive +- Integration mit `automations` Modul + +--- + +## Infrastruktur-Voraussetzungen + +### Sofort nötig (vor Phase 1) +- [ ] Fritz!Box Port-Forwarding: 25, 587, 465 → Mac Mini (192.168.178.131) +- [ ] Stalwart JMAP-Port (8080 intern) im Docker-Netzwerk verfügbar machen +- [ ] Port 3042 in `PORT_SCHEMA.md` reservieren +- [ ] `MANA_MAIL_URL` zu `.env.development` hinzufügen + +### Docker-Compose Erweiterung +```yaml +mana-mail: + build: ./services/mana-mail + container_name: mana-mail-service + ports: + - "3042:3042" + environment: + PORT: 3042 + DATABASE_URL: postgresql://mana:${DB_PASSWORD}@postgres:5432/mana_platform + MANA_AUTH_URL: http://mana-auth:3001 + MANA_SERVICE_KEY: ${MANA_SERVICE_KEY} + STALWART_JMAP_URL: http://mana-mail:8080 + STALWART_ADMIN_USER: admin + STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD} + CORS_ORIGINS: http://localhost:5173,https://mana.how + depends_on: + - postgres + - mana-mail # Stalwart container + networks: + - mana-network +``` + +--- + +## Cross-Module-Integration + +| Aktion | Source | Target | Mechanismus | +|---|---|---|---| +| Mail → Aufgabe | mail | todo | `manaLinks` + `tasksStore.createTask()` | +| Mail → Termin | mail | calendar | `manaLinks` + Datums-Extraktion | +| Mail → Kontakt | mail | contacts | Absender-Matching / Auto-Create | +| Mail → Notiz | mail | notes | Inhalt kopieren + `manaLinks` | +| Mail → Datei | mail | storage | Attachment in MinIO ablegen | +| Mail → Rechnung | mail | finance | KI-Erkennung "Rechnung" | +| Kontakt → Mail | contacts | mail | "E-Mail senden" Button | +| Todo → Mail | todo | mail | "Per Mail teilen" | + +--- + +## Implementierungs-Reihenfolge + +1. **Infra**: Port-Forwarding, JMAP-Port, Port reservieren +2. **Service**: `mana-mail` scaffolden (Hono/Bun, Config, Health) +3. **JMAP-Client**: Stalwart-Verbindung, Thread/Email-Queries +4. **Account-Provisioning**: on-user-created Hook in mana-auth +5. **API**: Thread-Liste, Thread-Detail, Send, Draft +6. **Frontend**: Modul-Gerüst (types, collections, queries, stores) +7. **ListView**: Inbox-UI mit Thread-Liste +8. **ThreadView**: Detail-Ansicht mit Messages +9. **ComposeView**: Schreiben/Reply mit Kontakte-Autocomplete +10. **SSE**: Live-Updates für neue Mails +11. **KI Phase 2**: Zusammenfassungen, Smart Reply, Kategorisierung +12. **Multi-Account Phase 3**: Gmail/Outlook OAuth diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 46d296600..400074905 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -824,6 +824,23 @@ export const MANA_APPS: ManaApp[] = [ // calls behind it are credit-gated server-side regardless. requiredTier: 'guest', }, + { + id: 'meditate', + name: 'Meditate', + description: { + de: 'Meditation & Atemübungen', + en: 'Meditation & Breathing', + }, + longDescription: { + de: 'Meditations-Timer, geführte Atemübungen (Box Breathing, 4-7-8, Wim Hof), Body Scans und Stimmungstracking. Finde deine Ruhe — mit Streak-Tracking und Session-Verlauf.', + en: 'Meditation timer, guided breathing exercises (Box Breathing, 4-7-8, Wim Hof), body scans, and mood tracking. Find your calm — with streak tracking and session history.', + }, + icon: APP_ICONS.meditate, + color: '#8b5cf6', + comingSoon: false, + status: 'development', + requiredTier: 'guest', + }, ]; /** diff --git a/services/mana-auth/src/config.ts b/services/mana-auth/src/config.ts index 5465e0c70..d773215e3 100644 --- a/services/mana-auth/src/config.ts +++ b/services/mana-auth/src/config.ts @@ -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, }; } diff --git a/services/mana-auth/src/routes/auth.ts b/services/mana-auth/src/routes/auth.ts index 4971167fe..a33a8ef16 100644 --- a/services/mana-auth/src/routes/auth.ts +++ b/services/mana-auth/src/routes/auth.ts @@ -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); diff --git a/services/mana-mail/CLAUDE.md b/services/mana-mail/CLAUDE.md new file mode 100644 index 000000000..b30f76407 --- /dev/null +++ b/services/mana-mail/CLAUDE.md @@ -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 +CORS_ORIGINS=http://localhost:5173,https://mana.how +``` + +## Database + +Schema: `mail.*` in `mana_platform` + +Tables: +- `mail.accounts` — User-to-Stalwart account mapping, display name, signature +- `mail.thread_metadata` — AI-generated summaries, categories, cross-module links (Phase 2) + +## Architecture + +``` +Browser → mana-mail (Hono, :3042) → Stalwart (JMAP, :8080) + → Stalwart (SMTP, :587) +``` + +Mail content lives in Stalwart. This service acts as an authenticated proxy that: +1. Maps Mana JWT users to Stalwart accounts +2. Translates REST calls to JMAP protocol +3. Caches AI metadata in PostgreSQL +4. Handles account provisioning on user registration + +## Account Provisioning + +When a user registers in mana-auth, a fire-and-forget POST hits `/api/v1/internal/mail/on-user-created`. The service: +1. Generates a `username@mana.how` address from the user's name/email +2. Creates a Stalwart account via Admin API (`POST /api/principal`) +3. Assigns the `user` role (required for JMAP/SMTP access) +4. Saves the mapping in `mail.accounts` diff --git a/services/mana-mail/Dockerfile b/services/mana-mail/Dockerfile new file mode 100644 index 000000000..71955bb90 --- /dev/null +++ b/services/mana-mail/Dockerfile @@ -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"] diff --git a/services/mana-mail/drizzle.config.ts b/services/mana-mail/drizzle.config.ts new file mode 100644 index 000000000..3cda276e1 --- /dev/null +++ b/services/mana-mail/drizzle.config.ts @@ -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'], +}); diff --git a/services/mana-mail/package.json b/services/mana-mail/package.json new file mode 100644 index 000000000..86e66a0eb --- /dev/null +++ b/services/mana-mail/package.json @@ -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" + } +} diff --git a/services/mana-mail/src/config.ts b/services/mana-mail/src/config.ts new file mode 100644 index 000000000..c53b6b8a7 --- /dev/null +++ b/services/mana-mail/src/config.ts @@ -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 ', + insecureTls: process.env.SMTP_INSECURE_TLS === 'true', + }, + cors: { + origins: (process.env.CORS_ORIGINS || 'http://localhost:5173').split(','), + }, + }; +} diff --git a/services/mana-mail/src/db/connection.ts b/services/mana-mail/src/db/connection.ts new file mode 100644 index 000000000..aa63e328e --- /dev/null +++ b/services/mana-mail/src/db/connection.ts @@ -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> | null = null; + +export function getDb(databaseUrl: string) { + if (!db) { + const client = postgres(databaseUrl, { max: 10 }); + db = drizzle(client, { schema }); + } + return db; +} + +export type Database = ReturnType; diff --git a/services/mana-mail/src/db/schema/index.ts b/services/mana-mail/src/db/schema/index.ts new file mode 100644 index 000000000..efc83f65a --- /dev/null +++ b/services/mana-mail/src/db/schema/index.ts @@ -0,0 +1 @@ +export * from './mail'; diff --git a/services/mana-mail/src/db/schema/mail.ts b/services/mana-mail/src/db/schema/mail.ts new file mode 100644 index 000000000..77c3cc9ce --- /dev/null +++ b/services/mana-mail/src/db/schema/mail.ts @@ -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; diff --git a/services/mana-mail/src/index.ts b/services/mana-mail/src/index.ts new file mode 100644 index 000000000..7157a0015 --- /dev/null +++ b/services/mana-mail/src/index.ts @@ -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, +}; diff --git a/services/mana-mail/src/lib/errors.ts b/services/mana-mail/src/lib/errors.ts new file mode 100644 index 000000000..453491bfa --- /dev/null +++ b/services/mana-mail/src/lib/errors.ts @@ -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 }); + } +} diff --git a/services/mana-mail/src/lib/validation.ts b/services/mana-mail/src/lib/validation.ts new file mode 100644 index 000000000..eb4bc69d7 --- /dev/null +++ b/services/mana-mail/src/lib/validation.ts @@ -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(), +}); diff --git a/services/mana-mail/src/middleware/jwt-auth.ts b/services/mana-mail/src/middleware/jwt-auth.ts new file mode 100644 index 000000000..894f2aad3 --- /dev/null +++ b/services/mana-mail/src/middleware/jwt-auth.ts @@ -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 | null = null; + +function getJwks(authUrl: string) { + if (!jwks) { + jwks = createRemoteJWKSet(new URL('/api/auth/jwks', authUrl)); + } + return jwks; +} + +export interface AuthUser { + userId: string; + email: string; + role: string; +} + +/** + * Middleware that validates JWT tokens from Authorization: Bearer header. + * Sets c.set('user', { userId, email, role }) on success. + */ +export function jwtAuth(authUrl: string): MiddlewareHandler { + return async (c, next) => { + const authHeader = c.req.header('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + throw new UnauthorizedError('Missing or invalid Authorization header'); + } + + const token = authHeader.slice(7); + try { + const { payload } = await jwtVerify(token, getJwks(authUrl), { + issuer: authUrl, + audience: 'mana', + }); + + const user: AuthUser = { + userId: payload.sub || '', + email: (payload.email as string) || '', + role: (payload.role as string) || 'user', + }; + + c.set('user', user); + await next(); + } catch { + throw new UnauthorizedError('Invalid or expired token'); + } + }; +} diff --git a/services/mana-mail/src/middleware/service-auth.ts b/services/mana-mail/src/middleware/service-auth.ts new file mode 100644 index 000000000..a1012a11d --- /dev/null +++ b/services/mana-mail/src/middleware/service-auth.ts @@ -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(); + }; +} diff --git a/services/mana-mail/src/routes/accounts.ts b/services/mana-mail/src/routes/accounts.ts new file mode 100644 index 000000000..13383431f --- /dev/null +++ b/services/mana-mail/src/routes/accounts.ts @@ -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); + }); +} diff --git a/services/mana-mail/src/routes/health.ts b/services/mana-mail/src/routes/health.ts new file mode 100644 index 000000000..8766ebd32 --- /dev/null +++ b/services/mana-mail/src/routes/health.ts @@ -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() }) +); diff --git a/services/mana-mail/src/routes/internal.ts b/services/mana-mail/src/routes/internal.ts new file mode 100644 index 000000000..20acac7db --- /dev/null +++ b/services/mana-mail/src/routes/internal.ts @@ -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' }); + }); +} diff --git a/services/mana-mail/src/routes/labels.ts b/services/mana-mail/src/routes/labels.ts new file mode 100644 index 000000000..202d8a234 --- /dev/null +++ b/services/mana-mail/src/routes/labels.ts @@ -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); + }); +} diff --git a/services/mana-mail/src/routes/messages.ts b/services/mana-mail/src/routes/messages.ts new file mode 100644 index 000000000..e83a70019 --- /dev/null +++ b/services/mana-mail/src/routes/messages.ts @@ -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 }); + }); +} diff --git a/services/mana-mail/src/routes/send.ts b/services/mana-mail/src/routes/send.ts new file mode 100644 index 000000000..f0f406695 --- /dev/null +++ b/services/mana-mail/src/routes/send.ts @@ -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); + }); +} diff --git a/services/mana-mail/src/routes/threads.ts b/services/mana-mail/src/routes/threads.ts new file mode 100644 index 000000000..1c5847ced --- /dev/null +++ b/services/mana-mail/src/routes/threads.ts @@ -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); + }); +} diff --git a/services/mana-mail/src/services/account-service.ts b/services/mana-mail/src/services/account-service.ts new file mode 100644 index 000000000..10ff3660c --- /dev/null +++ b/services/mana-mail/src/services/account-service.ts @@ -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 { + // Hash the password with SHA512-crypt via Stalwart's own API + const createResponse = await fetch(`${this.config.jmapUrl}/api/principal`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: this.authHeader, + }, + body: JSON.stringify({ + type: 'individual', + name: username, + secrets: [password], + emails: [email], + }), + }); + + if (!createResponse.ok) { + const text = await createResponse.text(); + if (createResponse.status === 409 || text.includes('already exists')) { + throw new ConflictError(`Account ${email} already exists in Stalwart`); + } + throw new Error(`Failed to create Stalwart account: ${text}`); + } + + // Assign 'user' role (required for SMTP/JMAP access) + const roleResponse = await fetch(`${this.config.jmapUrl}/api/principal/${username}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: this.authHeader, + }, + body: JSON.stringify([{ action: 'set', field: 'roles', value: ['user'] }]), + }); + + if (!roleResponse.ok) { + console.error( + `[mana-mail] Warning: failed to set role for ${username}: ${await roleResponse.text()}` + ); + } + } + + /** Provision a new mail account for a user (called on registration). */ + async provisionAccount(userId: string, email: string, name?: string): Promise { + // Check if user already has an account + const existing = await this.db.query.accounts.findFirst({ + where: eq(accounts.userId, userId), + }); + if (existing) return existing; + + // Generate @mana.how address + let username = this.generateUsername(email, name); + let manaEmail = `${username}@${this.config.domain}`; + + // Handle collision — append random suffix + const emailExists = await this.db.query.accounts.findFirst({ + where: eq(accounts.email, manaEmail), + }); + if (emailExists) { + const suffix = Math.floor(Math.random() * 1000); + username = `${username}${suffix}`; + manaEmail = `${username}@${this.config.domain}`; + } + + // Create Stalwart account with a random password + // (users authenticate via Mana JWT, not mail credentials directly) + const mailPassword = crypto.randomUUID(); + await this.createStalwartAccount(username, mailPassword, manaEmail); + + // Save to database + const newAccount: NewMailAccount = { + userId, + email: manaEmail, + displayName: name || username, + provider: 'stalwart', + isDefault: true, + stalwartAccountId: username, + }; + + const [created] = await this.db.insert(accounts).values(newAccount).returning(); + return created; + } + + /** Get all mail accounts for a user. */ + async getAccounts(userId: string): Promise { + return this.db.query.accounts.findMany({ + where: eq(accounts.userId, userId), + }); + } + + /** Get the default (or first) account for a user. */ + async getDefaultAccount(userId: string): Promise { + const account = await this.db.query.accounts.findFirst({ + where: eq(accounts.userId, userId), + orderBy: (a, { desc }) => [desc(a.isDefault)], + }); + return account ?? null; + } + + /** Update account settings (display name, signature). */ + async updateAccount( + userId: string, + accountId: string, + update: { displayName?: string; signature?: string } + ): Promise { + const account = await this.db.query.accounts.findFirst({ + where: eq(accounts.id, accountId), + }); + if (!account || account.userId !== userId) { + throw new NotFoundError('Account not found'); + } + + const [updated] = await this.db + .update(accounts) + .set({ ...update, updatedAt: new Date() }) + .where(eq(accounts.id, accountId)) + .returning(); + return updated; + } +} diff --git a/services/mana-mail/src/services/jmap-client.ts b/services/mana-mail/src/services/jmap-client.ts new file mode 100644 index 000000000..9018bb4a2 --- /dev/null +++ b/services/mana-mail/src/services/jmap-client.ts @@ -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; + from: JmapEmailAddress[] | null; + to: JmapEmailAddress[] | null; + cc: JmapEmailAddress[] | null; + subject: string; + receivedAt: string; + preview: string; + size: number; + keywords: Record; + hasAttachment: boolean; + bodyValues?: Record; + 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 { + 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).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 { + 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).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; + } = {} + ): Promise<{ ids: string[]; total: number }> { + const filter: Record = { ...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; + 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 { + 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).list as JmapEmail[]) || []; + } + + /** Get full email with body content. */ + async getEmailWithBody(accountId: string, emailId: string): Promise { + 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 { + 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).list as JmapThread[]) || []; + } + + /** Update email keywords (read, flagged) or mailbox membership. */ + async updateEmail( + accountId: string, + emailId: string, + update: { + isRead?: boolean; + isFlagged?: boolean; + mailboxIds?: Record; + } + ): Promise { + const patch: Record = {}; + + 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 { + 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 = { + text: { value: email.textBody, charset: 'utf-8' }, + }; + if (email.htmlBody) { + bodyValues.html = { value: email.htmlBody, charset: 'utf-8' }; + } + + const emailCreate: Record = { + 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).created as Record< + string, + { id: string } + >; + return created?.[emailId]?.id ?? ''; + } +} diff --git a/services/mana-mail/src/services/mail-service.ts b/services/mana-mail/src/services/mail-service.ts new file mode 100644 index 000000000..9500a3f39 --- /dev/null +++ b/services/mana-mail/src/services/mail-service.ts @@ -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 { + 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 { + 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(); + 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 { + 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 } + ): Promise { + 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 }; + } +} diff --git a/services/mana-mail/tsconfig.json b/services/mana-mail/tsconfig.json new file mode 100644 index 000000000..354a2c2dd --- /dev/null +++ b/services/mana-mail/tsconfig.json @@ -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"] +}