mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
40e1145e9f
commit
a3de6b3d81
41 changed files with 2908 additions and 1 deletions
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
574
apps/mana/apps/web/src/lib/modules/mail/ListView.svelte
Normal file
574
apps/mana/apps/web/src/lib/modules/mail/ListView.svelte
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
<!--
|
||||
Mail — ListView (Inbox)
|
||||
Thread list with mailbox sidebar, search, and compose.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { mailStore } from './stores/mail.svelte';
|
||||
import { formatSender, formatDate } from './queries';
|
||||
import { SYSTEM_MAILBOXES } from './types';
|
||||
import type { ThreadSummary, MailboxInfo } from './types';
|
||||
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
|
||||
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
|
||||
import { Trash, Star, EnvelopeOpen, Archive } from '@mana/shared-icons';
|
||||
|
||||
let showCompose = $state(false);
|
||||
let selectedThreadId = $state<string | null>(null);
|
||||
|
||||
// Compose form
|
||||
let composeTo = $state('');
|
||||
let composeSubject = $state('');
|
||||
let composeBody = $state('');
|
||||
let sending = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await Promise.all([mailStore.loadMailboxes(), mailStore.loadThreads()]);
|
||||
});
|
||||
|
||||
let inboxMailbox = $derived(mailStore.mailboxes.find((mb) => mb.role === 'inbox'));
|
||||
|
||||
function getMailboxLabel(mb: MailboxInfo): string {
|
||||
const sys = SYSTEM_MAILBOXES[mb.role ?? ''];
|
||||
return sys?.label ?? mb.name;
|
||||
}
|
||||
|
||||
async function selectMailbox(mailboxId: string) {
|
||||
selectedThreadId = null;
|
||||
await mailStore.loadThreads({ mailboxId });
|
||||
}
|
||||
|
||||
async function selectThread(thread: ThreadSummary) {
|
||||
selectedThreadId = thread.id;
|
||||
await mailStore.loadThread(thread.id);
|
||||
if (!thread.isRead && mailStore.activeThread?.messages[0]) {
|
||||
await mailStore.markRead(mailStore.activeThread.messages[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSend(e: Event) {
|
||||
e.preventDefault();
|
||||
if (!composeTo.trim() || !composeSubject.trim()) return;
|
||||
sending = true;
|
||||
try {
|
||||
await mailStore.sendEmail({
|
||||
to: [{ email: composeTo.trim() }],
|
||||
subject: composeSubject.trim(),
|
||||
body: composeBody,
|
||||
});
|
||||
composeTo = '';
|
||||
composeSubject = '';
|
||||
composeBody = '';
|
||||
showCompose = false;
|
||||
await mailStore.loadThreads({ mailboxId: mailStore.activeMailboxId ?? undefined });
|
||||
} catch (err) {
|
||||
console.error('[mail] Send failed:', err);
|
||||
} finally {
|
||||
sending = false;
|
||||
}
|
||||
}
|
||||
|
||||
const ctxMenu = useItemContextMenu<ThreadSummary>();
|
||||
let ctxMenuItems = $derived<ContextMenuItem[]>(
|
||||
ctxMenu.state.target
|
||||
? [
|
||||
{
|
||||
id: 'read',
|
||||
label: ctxMenu.state.target.isRead ? 'Als ungelesen' : 'Als gelesen',
|
||||
icon: EnvelopeOpen,
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
id: 'star',
|
||||
label: ctxMenu.state.target.isFlagged ? 'Stern entfernen' : 'Markieren',
|
||||
icon: Star,
|
||||
action: () => {},
|
||||
},
|
||||
{
|
||||
id: 'archive',
|
||||
label: 'Archivieren',
|
||||
icon: Archive,
|
||||
action: () => {},
|
||||
},
|
||||
{ id: 'div', label: '', type: 'divider' as const },
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Löschen',
|
||||
icon: Trash,
|
||||
variant: 'danger' as const,
|
||||
action: () => {},
|
||||
},
|
||||
]
|
||||
: []
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="mail-view">
|
||||
<!-- Mailbox Sidebar -->
|
||||
<div class="mailbox-sidebar">
|
||||
<button class="compose-btn" onclick={() => (showCompose = true)}>Neue Mail</button>
|
||||
<div class="mailbox-list">
|
||||
{#each mailStore.mailboxes as mb (mb.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="mailbox-item"
|
||||
class:active={mailStore.activeMailboxId === mb.id}
|
||||
onclick={() => selectMailbox(mb.id)}
|
||||
>
|
||||
<span class="mailbox-name">{getMailboxLabel(mb)}</span>
|
||||
{#if mb.unreadEmails > 0}
|
||||
<span class="unread-badge">{mb.unreadEmails}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Thread List -->
|
||||
<div class="thread-list">
|
||||
{#if mailStore.loading && mailStore.threads.length === 0}
|
||||
<div class="loading">Lade Mails...</div>
|
||||
{:else if mailStore.error}
|
||||
<div class="error-state">
|
||||
<p>{mailStore.error}</p>
|
||||
<button class="retry-btn" onclick={() => mailStore.loadThreads()}>Erneut versuchen</button>
|
||||
</div>
|
||||
{:else if mailStore.threads.length === 0}
|
||||
<div class="empty">
|
||||
<p>Keine Mails</p>
|
||||
<p class="empty-hint">Dein Postfach ist leer.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each mailStore.threads as thread (thread.id)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="thread-row"
|
||||
class:unread={!thread.isRead}
|
||||
class:active={selectedThreadId === thread.id}
|
||||
onclick={() => selectThread(thread)}
|
||||
oncontextmenu={(e) => ctxMenu.open(e, thread)}
|
||||
>
|
||||
<div class="thread-from">
|
||||
{#if thread.isFlagged}<span class="star-icon"><Star size={10} weight="fill" /></span
|
||||
>{/if}
|
||||
<span class:font-bold={!thread.isRead}>{formatSender(thread.from)}</span>
|
||||
</div>
|
||||
<div class="thread-subject" class:font-bold={!thread.isRead}>{thread.subject}</div>
|
||||
<div class="thread-snippet">{thread.snippet}</div>
|
||||
<div class="thread-meta">
|
||||
<span class="thread-date">{formatDate(thread.lastMessageAt)}</span>
|
||||
{#if thread.messageCount > 1}
|
||||
<span class="thread-count">{thread.messageCount}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Thread Detail / Compose -->
|
||||
<div class="detail-pane">
|
||||
{#if showCompose}
|
||||
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
||||
<form
|
||||
class="compose-form"
|
||||
onsubmit={handleSend}
|
||||
onkeydown={(e) => e.key === 'Escape' && (showCompose = false)}
|
||||
>
|
||||
<div class="compose-header">Neue Nachricht</div>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
class="compose-input"
|
||||
type="email"
|
||||
placeholder="An"
|
||||
bind:value={composeTo}
|
||||
autofocus
|
||||
/>
|
||||
<input
|
||||
class="compose-input"
|
||||
type="text"
|
||||
placeholder="Betreff"
|
||||
bind:value={composeSubject}
|
||||
/>
|
||||
<textarea
|
||||
class="compose-body"
|
||||
placeholder="Nachricht schreiben..."
|
||||
bind:value={composeBody}
|
||||
rows="8"
|
||||
></textarea>
|
||||
<div class="compose-actions">
|
||||
<button type="button" class="btn-cancel" onclick={() => (showCompose = false)}
|
||||
>Abbrechen</button
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-send"
|
||||
disabled={sending || !composeTo.trim() || !composeSubject.trim()}
|
||||
>
|
||||
{sending ? 'Wird gesendet...' : 'Senden'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if mailStore.activeThread}
|
||||
<div class="thread-detail">
|
||||
<h2 class="thread-detail-subject">{mailStore.activeThread.subject}</h2>
|
||||
{#each mailStore.activeThread.messages as msg (msg.id)}
|
||||
<div class="message-card">
|
||||
<div class="message-header">
|
||||
<span class="message-from"
|
||||
>{msg.from?.[0]?.name || msg.from?.[0]?.email || 'Unbekannt'}</span
|
||||
>
|
||||
<span class="message-date">{formatDate(msg.date)}</span>
|
||||
</div>
|
||||
{#if msg.to}
|
||||
<div class="message-to">An: {msg.to.map((t) => t.name || t.email).join(', ')}</div>
|
||||
{/if}
|
||||
<div class="message-body">
|
||||
{#if msg.bodyHtml}
|
||||
{@html msg.bodyHtml}
|
||||
{:else}
|
||||
<pre class="message-text">{msg.bodyText || msg.preview}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="empty-detail">
|
||||
<p>Wähle eine Nachricht aus</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<ContextMenu
|
||||
visible={ctxMenu.state.visible}
|
||||
x={ctxMenu.state.x}
|
||||
y={ctxMenu.state.y}
|
||||
items={ctxMenuItems}
|
||||
onClose={ctxMenu.close}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mail-view {
|
||||
display: grid;
|
||||
grid-template-columns: 140px 260px 1fr;
|
||||
height: 100%;
|
||||
min-height: 400px;
|
||||
gap: 1px;
|
||||
background: hsl(var(--color-border));
|
||||
}
|
||||
|
||||
/* ── Mailbox Sidebar ─────────────────────────── */
|
||||
.mailbox-sidebar {
|
||||
background: hsl(var(--color-background));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.compose-btn {
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.compose-btn:hover {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.mailbox-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
.mailbox-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.mailbox-item:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
.mailbox-item.active {
|
||||
background: hsl(var(--color-primary) / 0.12);
|
||||
color: hsl(var(--color-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
.mailbox-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.unread-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
padding: 0 0.375rem;
|
||||
border-radius: 1rem;
|
||||
min-width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ── Thread List ─────────────────────────────── */
|
||||
.thread-list {
|
||||
background: hsl(var(--color-background));
|
||||
overflow-y: auto;
|
||||
}
|
||||
.thread-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.0625rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.thread-row:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
.thread-row.active {
|
||||
background: hsl(var(--color-primary) / 0.08);
|
||||
}
|
||||
.thread-row.unread {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
|
||||
.thread-from {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.star-icon {
|
||||
color: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.font-bold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.thread-subject {
|
||||
font-size: 0.75rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.thread-snippet {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.thread-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.thread-date {
|
||||
font-size: 0.625rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.thread-count {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
background: hsl(var(--color-border));
|
||||
padding: 0 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
/* ── Detail Pane ─────────────────────────────── */
|
||||
.detail-pane {
|
||||
background: hsl(var(--color-background));
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
.empty-detail,
|
||||
.empty,
|
||||
.loading,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: 200px;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.8125rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.empty-hint {
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Thread Detail ───────────────────────────── */
|
||||
.thread-detail-subject {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
.message-card {
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
.message-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.message-from {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.message-date {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.message-to {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.message-body {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
line-height: 1.5;
|
||||
}
|
||||
.message-text {
|
||||
white-space: pre-wrap;
|
||||
font-family: inherit;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Compose ─────────────────────────────────── */
|
||||
.compose-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.compose-header {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.compose-input {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
outline: none;
|
||||
}
|
||||
.compose-input:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.compose-input::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.compose-body {
|
||||
padding: 0.5rem 0.625rem;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-family: inherit;
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
.compose-body:focus {
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.compose-body::placeholder {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.compose-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.btn-cancel,
|
||||
.btn-send {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.btn-cancel {
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.btn-cancel:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
.btn-send {
|
||||
background: hsl(var(--color-primary));
|
||||
color: white;
|
||||
}
|
||||
.btn-send:hover:not(:disabled) {
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
.btn-send:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.mail-view {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
}
|
||||
.mailbox-sidebar {
|
||||
flex-direction: row;
|
||||
overflow-x: auto;
|
||||
padding: 0.375rem;
|
||||
}
|
||||
.mailbox-list {
|
||||
flex-direction: row;
|
||||
}
|
||||
.compose-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.detail-pane {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
88
apps/mana/apps/web/src/lib/modules/mail/api.ts
Normal file
88
apps/mana/apps/web/src/lib/modules/mail/api.ts
Normal file
|
|
@ -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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
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<ThreadDetail> {
|
||||
return fetchWithAuth(`/api/v1/mail/threads/${threadId}`);
|
||||
},
|
||||
|
||||
async updateMessage(
|
||||
emailId: string,
|
||||
update: { isRead?: boolean; isFlagged?: boolean; mailboxIds?: Record<string, boolean> }
|
||||
): Promise<void> {
|
||||
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<MailboxInfo[]> {
|
||||
return fetchWithAuth('/api/v1/mail/labels');
|
||||
},
|
||||
|
||||
async getAccounts(): Promise<MailAccount[]> {
|
||||
return fetchWithAuth('/api/v1/mail/accounts');
|
||||
},
|
||||
|
||||
async updateAccount(
|
||||
accountId: string,
|
||||
update: { displayName?: string; signature?: string }
|
||||
): Promise<MailAccount> {
|
||||
return fetchWithAuth(`/api/v1/mail/accounts/${accountId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(update),
|
||||
});
|
||||
},
|
||||
};
|
||||
11
apps/mana/apps/web/src/lib/modules/mail/collections.ts
Normal file
11
apps/mana/apps/web/src/lib/modules/mail/collections.ts
Normal file
|
|
@ -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<LocalMailDraft>('mailDrafts');
|
||||
20
apps/mana/apps/web/src/lib/modules/mail/index.ts
Normal file
20
apps/mana/apps/web/src/lib/modules/mail/index.ts
Normal file
|
|
@ -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';
|
||||
6
apps/mana/apps/web/src/lib/modules/mail/module.config.ts
Normal file
6
apps/mana/apps/web/src/lib/modules/mail/module.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const mailModuleConfig: ModuleConfig = {
|
||||
appId: 'mail',
|
||||
tables: [{ name: 'mailDrafts' }],
|
||||
};
|
||||
65
apps/mana/apps/web/src/lib/modules/mail/queries.ts
Normal file
65
apps/mana/apps/web/src/lib/modules/mail/queries.ts
Normal file
|
|
@ -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<LocalMailDraft>('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' });
|
||||
}
|
||||
|
|
@ -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<string, unknown>;
|
||||
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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
111
apps/mana/apps/web/src/lib/modules/mail/stores/mail.svelte.ts
Normal file
111
apps/mana/apps/web/src/lib/modules/mail/stores/mail.svelte.ts
Normal file
|
|
@ -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<ThreadSummary[]>([]);
|
||||
let totalThreads = $state(0);
|
||||
let activeThread = $state<ThreadDetail | null>(null);
|
||||
let mailboxes = $state<MailboxInfo[]>([]);
|
||||
let activeMailboxId = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(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;
|
||||
},
|
||||
};
|
||||
99
apps/mana/apps/web/src/lib/modules/mail/types.ts
Normal file
99
apps/mana/apps/web/src/lib/modules/mail/types.ts
Normal file
|
|
@ -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<string, { label: string; icon: string }> = {
|
||||
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' },
|
||||
};
|
||||
7
apps/mana/apps/web/src/routes/(app)/mail/+layout.svelte
Normal file
7
apps/mana/apps/web/src/routes/(app)/mail/+layout.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
9
apps/mana/apps/web/src/routes/(app)/mail/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/mail/+page.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<script lang="ts">
|
||||
import ListView from '$lib/modules/mail/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mail - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<ListView />
|
||||
|
|
@ -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
|
||||
|
|
|
|||
394
docs/plans/mail-module-plan.md
Normal file
394
docs/plans/mail-module-plan.md
Normal file
|
|
@ -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<Thread[]>
|
||||
|
||||
async getThread(accountId: string, threadId: string): Promise<ThreadDetail>
|
||||
|
||||
async getEmails(accountId: string, emailIds: string[]): Promise<Email[]>
|
||||
|
||||
async setEmailFlags(accountId: string, emailId: string, flags: {
|
||||
isRead?: boolean;
|
||||
isFlagged?: boolean;
|
||||
mailboxIds?: Record<string, boolean>;
|
||||
}): Promise<void>
|
||||
|
||||
async sendEmail(accountId: string, email: {
|
||||
to: Address[]; cc?: Address[]; bcc?: Address[];
|
||||
subject: string; body: string; htmlBody?: string;
|
||||
inReplyTo?: string; references?: string[];
|
||||
attachments?: Blob[];
|
||||
}): Promise<string> // 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
|
||||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
96
services/mana-mail/CLAUDE.md
Normal file
96
services/mana-mail/CLAUDE.md
Normal 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`
|
||||
40
services/mana-mail/Dockerfile
Normal file
40
services/mana-mail/Dockerfile
Normal 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"]
|
||||
11
services/mana-mail/drizzle.config.ts
Normal file
11
services/mana-mail/drizzle.config.ts
Normal 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'],
|
||||
});
|
||||
25
services/mana-mail/package.json
Normal file
25
services/mana-mail/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
64
services/mana-mail/src/config.ts
Normal file
64
services/mana-mail/src/config.ts
Normal 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(','),
|
||||
},
|
||||
};
|
||||
}
|
||||
19
services/mana-mail/src/db/connection.ts
Normal file
19
services/mana-mail/src/db/connection.ts
Normal 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>;
|
||||
1
services/mana-mail/src/db/schema/index.ts
Normal file
1
services/mana-mail/src/db/schema/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './mail';
|
||||
72
services/mana-mail/src/db/schema/mail.ts
Normal file
72
services/mana-mail/src/db/schema/mail.ts
Normal 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;
|
||||
72
services/mana-mail/src/index.ts
Normal file
72
services/mana-mail/src/index.ts
Normal 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,
|
||||
};
|
||||
31
services/mana-mail/src/lib/errors.ts
Normal file
31
services/mana-mail/src/lib/errors.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
42
services/mana-mail/src/lib/validation.ts
Normal file
42
services/mana-mail/src/lib/validation.ts
Normal 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(),
|
||||
});
|
||||
57
services/mana-mail/src/middleware/jwt-auth.ts
Normal file
57
services/mana-mail/src/middleware/jwt-auth.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
26
services/mana-mail/src/middleware/service-auth.ts
Normal file
26
services/mana-mail/src/middleware/service-auth.ts
Normal 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();
|
||||
};
|
||||
}
|
||||
24
services/mana-mail/src/routes/accounts.ts
Normal file
24
services/mana-mail/src/routes/accounts.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
5
services/mana-mail/src/routes/health.ts
Normal file
5
services/mana-mail/src/routes/health.ts
Normal 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() })
|
||||
);
|
||||
29
services/mana-mail/src/routes/internal.ts
Normal file
29
services/mana-mail/src/routes/internal.ts
Normal 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' });
|
||||
});
|
||||
}
|
||||
15
services/mana-mail/src/routes/labels.ts
Normal file
15
services/mana-mail/src/routes/labels.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
18
services/mana-mail/src/routes/messages.ts
Normal file
18
services/mana-mail/src/routes/messages.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
17
services/mana-mail/src/routes/send.ts
Normal file
17
services/mana-mail/src/routes/send.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
25
services/mana-mail/src/routes/threads.ts
Normal file
25
services/mana-mail/src/routes/threads.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
164
services/mana-mail/src/services/account-service.ts
Normal file
164
services/mana-mail/src/services/account-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
322
services/mana-mail/src/services/jmap-client.ts
Normal file
322
services/mana-mail/src/services/jmap-client.ts
Normal 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 ?? '';
|
||||
}
|
||||
}
|
||||
233
services/mana-mail/src/services/mail-service.ts
Normal file
233
services/mana-mail/src/services/mail-service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
17
services/mana-mail/tsconfig.json
Normal file
17
services/mana-mail/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue