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

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

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

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

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

View file

@ -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';
}

View 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>

View 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),
});
},
};

View 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');

View 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';

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const mailModuleConfig: ModuleConfig = {
appId: 'mail',
tables: [{ name: 'mailDrafts' }],
};

View 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' });
}

View file

@ -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(),
});
},
};

View 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;
},
};

View 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' },
};

View file

@ -0,0 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
{@render children()}

View 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 />