mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 23:21:25 +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 />
|
||||
Loading…
Add table
Add a link
Reference in a new issue