Merge branch 'dev-1' into dev

This commit is contained in:
Wuesteon 2025-12-05 17:57:26 +01:00
commit d41d060bb3
1770 changed files with 168028 additions and 31031 deletions

View file

@ -0,0 +1,394 @@
@import "tailwindcss";
@import "@manacore/shared-tailwind/themes.css";
/* Scan shared packages for Tailwind classes */
@source "../../../packages/shared/src";
@source "../../../../../packages/shared-ui/src";
@source "../../../../../packages/shared-theme-ui/src";
/* Mail-specific CSS Variables */
@layer base {
:root {
/* Spacing */
--spacing-xs: 0.25rem;
--spacing-sm: 0.5rem;
--spacing-md: 1rem;
--spacing-lg: 1.5rem;
--spacing-xl: 2rem;
--spacing-2xl: 3rem;
/* Border Radius */
--radius-sm: 0.25rem;
--radius-md: 0.5rem;
--radius-lg: 0.75rem;
--radius-xl: 1rem;
--radius-full: 9999px;
/* Transitions */
--transition-fast: 150ms ease;
--transition-base: 200ms ease;
--transition-slow: 300ms ease;
/* Mail-specific */
--email-row-height: 56px;
--sidebar-width: 240px;
--detail-panel-width: 400px;
}
}
/* Email List Styles */
.email-row {
height: var(--email-row-height);
display: flex;
align-items: center;
padding: 0 var(--spacing-md);
border-bottom: 1px solid hsl(var(--color-border) / 0.5);
cursor: pointer;
transition: background-color var(--transition-fast);
}
.email-row:hover {
background-color: hsl(var(--color-muted) / 0.3);
}
.email-row.selected {
background-color: hsl(var(--color-primary) / 0.1);
}
.email-row.unread {
font-weight: 600;
}
.email-row.unread .email-subject {
color: hsl(var(--color-foreground));
}
/* Email Checkbox */
.email-checkbox {
width: 20px;
height: 20px;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition-fast);
}
.email-checkbox:checked {
background-color: hsl(var(--color-primary));
border-color: hsl(var(--color-primary));
}
/* Star button */
.star-button {
padding: var(--spacing-xs);
border-radius: var(--radius-full);
transition: all var(--transition-fast);
color: hsl(var(--color-muted-foreground));
}
.star-button:hover {
background-color: hsl(var(--color-muted) / 0.5);
}
.star-button.starred {
color: hsl(45 93% 47%);
}
/* Email Avatar */
.email-avatar {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
display: flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 0.875rem;
color: white;
flex-shrink: 0;
}
/* Email Content */
.email-content {
flex: 1;
min-width: 0;
overflow: hidden;
}
.email-subject {
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: hsl(var(--color-muted-foreground));
}
.email-snippet {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.email-date {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
flex-shrink: 0;
}
/* Folder List */
.folder-item {
display: flex;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
cursor: pointer;
transition: all var(--transition-fast);
color: hsl(var(--color-foreground));
}
.folder-item:hover {
background-color: hsl(var(--color-muted) / 0.5);
}
.folder-item.active {
background-color: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
.folder-badge {
background-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
font-size: 0.625rem;
padding: 2px 6px;
border-radius: var(--radius-full);
font-weight: 600;
}
/* Compose Button */
.compose-button {
display: flex;
align-items: center;
justify-content: center;
gap: var(--spacing-sm);
width: 100%;
padding: var(--spacing-md);
background-color: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-radius: var(--radius-lg);
font-weight: 600;
transition: all var(--transition-base);
}
.compose-button:hover {
background-color: hsl(var(--color-primary) / 0.9);
transform: translateY(-1px);
box-shadow: 0 4px 12px hsl(var(--color-primary) / 0.3);
}
/* Email Detail Panel */
.email-detail {
background-color: hsl(var(--color-surface));
border-left: 1px solid hsl(var(--color-border));
height: 100%;
overflow-y: auto;
}
.email-detail-header {
padding: var(--spacing-lg);
border-bottom: 1px solid hsl(var(--color-border));
}
.email-detail-body {
padding: var(--spacing-lg);
}
/* AI Summary Card */
.ai-summary-card {
background: linear-gradient(135deg, hsl(var(--color-primary) / 0.1), hsl(var(--color-secondary) / 0.1));
border: 1px solid hsl(var(--color-primary) / 0.2);
border-radius: var(--radius-lg);
padding: var(--spacing-md);
margin-bottom: var(--spacing-md);
}
.ai-summary-card .label {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-primary));
margin-bottom: var(--spacing-xs);
}
/* Smart Reply Chips */
.smart-reply-chip {
display: inline-flex;
padding: var(--spacing-sm) var(--spacing-md);
background-color: hsl(var(--color-muted));
border-radius: var(--radius-full);
font-size: 0.875rem;
cursor: pointer;
transition: all var(--transition-fast);
}
.smart-reply-chip:hover {
background-color: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
}
/* Category Badge */
.category-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
}
.category-badge.work {
background-color: hsl(217 91% 60% / 0.1);
color: hsl(217 91% 60%);
}
.category-badge.personal {
background-color: hsl(142 76% 36% / 0.1);
color: hsl(142 76% 36%);
}
.category-badge.newsletter {
background-color: hsl(262 83% 58% / 0.1);
color: hsl(262 83% 58%);
}
.category-badge.transactional {
background-color: hsl(31 97% 52% / 0.1);
color: hsl(31 97% 52%);
}
.category-badge.promotional {
background-color: hsl(350 89% 60% / 0.1);
color: hsl(350 89% 60%);
}
/* Label Chip */
.label-chip {
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 500;
}
/* Card styles */
.card {
background-color: hsl(var(--color-surface));
border-radius: var(--radius-lg);
padding: var(--spacing-lg);
border: 1px solid hsl(var(--color-border));
}
/* Button styles */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.5rem 1rem;
border-radius: var(--radius-md);
font-weight: 500;
font-size: 0.875rem;
transition: all var(--transition-base);
cursor: pointer;
border: none;
background: transparent;
}
.btn-primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.btn-primary:hover {
background: hsl(var(--color-primary) / 0.9);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: hsl(var(--color-secondary));
color: hsl(var(--color-secondary-foreground));
}
.btn-secondary:hover {
background: hsl(var(--color-secondary) / 0.8);
}
.btn-ghost {
background: transparent;
color: hsl(var(--color-foreground));
}
.btn-ghost:hover {
background: hsl(var(--color-muted));
}
.btn-icon {
padding: 0.5rem;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
}
/* Input styles */
.input {
display: block;
width: 100%;
padding: 0.5rem 0.75rem;
border: 2px solid hsl(var(--color-border));
border-radius: var(--radius-md);
background-color: hsl(var(--color-background));
color: hsl(var(--color-foreground));
font-size: 0.875rem;
transition: border-color var(--transition-fast);
}
.input:focus {
outline: none;
border-color: hsl(var(--color-primary));
}
.input::placeholder {
color: hsl(var(--color-muted-foreground));
}
/* Scrollbar styling */
@layer utilities {
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted-foreground) / 0.3);
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--muted-foreground) / 0.5);
}
}

View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mail</title>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,99 @@
import { fetchApi } from './client';
export interface EmailAccount {
id: string;
userId: string;
name: string;
email: string;
provider: 'gmail' | 'outlook' | 'imap';
isDefault: boolean;
syncEnabled: boolean;
lastSyncAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateImapAccountDto {
name: string;
email: string;
password: string;
imapHost: string;
imapPort: number;
imapSecurity?: 'ssl' | 'starttls' | 'none';
smtpHost: string;
smtpPort: number;
smtpSecurity?: 'ssl' | 'starttls' | 'none';
}
export interface UpdateAccountDto {
name?: string;
syncEnabled?: boolean;
}
export const accountsApi = {
async list() {
return fetchApi<{ accounts: EmailAccount[] }>('/accounts');
},
async get(id: string) {
return fetchApi<{ account: EmailAccount }>(`/accounts/${id}`);
},
async create(data: CreateImapAccountDto) {
return fetchApi<{ account: EmailAccount }>('/accounts', {
method: 'POST',
body: data,
});
},
async update(id: string, data: UpdateAccountDto) {
return fetchApi<{ account: EmailAccount }>(`/accounts/${id}`, {
method: 'PATCH',
body: data,
});
},
async delete(id: string) {
return fetchApi<{ success: boolean }>(`/accounts/${id}`, {
method: 'DELETE',
});
},
async setDefault(id: string) {
return fetchApi<{ account: EmailAccount }>(`/accounts/${id}/default`, {
method: 'POST',
});
},
async testConnection(id: string) {
return fetchApi<{ success: boolean; error?: string }>(`/accounts/${id}/test`, {
method: 'POST',
});
},
async test(data: CreateImapAccountDto) {
return fetchApi<{ success: boolean; message?: string }>('/accounts/test', {
method: 'POST',
body: data,
});
},
async sync(id: string) {
return fetchApi<{ success: boolean; newEmails: number }>(`/sync/accounts/${id}`, {
method: 'POST',
});
},
// OAuth
async initGoogleOAuth() {
return fetchApi<{ authUrl: string }>('/oauth/google/init', {
method: 'POST',
});
},
async initMicrosoftOAuth() {
return fetchApi<{ authUrl: string }>('/oauth/microsoft/init', {
method: 'POST',
});
},
};

View file

@ -0,0 +1,65 @@
/**
* API Client for Mail Backend
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3017';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
isFormData?: boolean;
};
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {}
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token, isFormData = false } = options;
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const headers: Record<string, string> = {};
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api/v1${endpoint}`, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}

View file

@ -0,0 +1,119 @@
import { fetchApi } from './client';
import type { EmailAddress } from './emails';
export interface Draft {
id: string;
accountId: string;
userId: string;
replyToEmailId: string | null;
replyType: 'reply' | 'reply-all' | 'forward' | null;
subject: string | null;
toAddresses: EmailAddress[];
ccAddresses: EmailAddress[] | null;
bccAddresses: EmailAddress[] | null;
bodyHtml: string | null;
bodyPlain: string | null;
scheduledAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateDraftDto {
accountId: string;
subject?: string;
toAddresses?: EmailAddress[];
ccAddresses?: EmailAddress[];
bccAddresses?: EmailAddress[];
bodyHtml?: string;
bodyPlain?: string;
replyToEmailId?: string;
replyType?: 'reply' | 'reply-all' | 'forward';
scheduledAt?: string;
}
export interface UpdateDraftDto {
subject?: string;
toAddresses?: EmailAddress[];
ccAddresses?: EmailAddress[];
bccAddresses?: EmailAddress[];
bodyHtml?: string;
bodyPlain?: string;
scheduledAt?: string;
}
export interface SendEmailDto {
accountId: string;
subject?: string;
toAddresses: EmailAddress[];
ccAddresses?: EmailAddress[];
bccAddresses?: EmailAddress[];
bodyHtml?: string;
bodyPlain?: string;
replyToEmailId?: string;
replyType?: 'reply' | 'reply-all' | 'forward';
}
export const composeApi = {
// Drafts
async listDrafts(accountId?: string) {
const query = accountId ? `?accountId=${accountId}` : '';
return fetchApi<{ drafts: Draft[]; total: number }>(`/drafts${query}`);
},
async getDraft(id: string) {
return fetchApi<{ draft: Draft }>(`/drafts/${id}`);
},
async createDraft(data: CreateDraftDto) {
return fetchApi<{ draft: Draft }>('/drafts', {
method: 'POST',
body: data,
});
},
async updateDraft(id: string, data: UpdateDraftDto) {
return fetchApi<{ draft: Draft }>(`/drafts/${id}`, {
method: 'PATCH',
body: data,
});
},
async deleteDraft(id: string) {
return fetchApi<{ success: boolean }>(`/drafts/${id}`, {
method: 'DELETE',
});
},
async sendDraft(id: string) {
return fetchApi<{ success: boolean; messageId?: string }>(`/drafts/${id}/send`, {
method: 'POST',
});
},
// Direct send
async send(data: SendEmailDto) {
return fetchApi<{ success: boolean; messageId?: string }>('/send', {
method: 'POST',
body: data,
});
},
// Reply/Forward
async createReply(emailId: string) {
return fetchApi<{ draft: Draft }>(`/emails/${emailId}/reply`, {
method: 'POST',
});
},
async createReplyAll(emailId: string) {
return fetchApi<{ draft: Draft }>(`/emails/${emailId}/reply-all`, {
method: 'POST',
});
},
async createForward(emailId: string) {
return fetchApi<{ draft: Draft }>(`/emails/${emailId}/forward`, {
method: 'POST',
});
},
};

View file

@ -0,0 +1,140 @@
import { fetchApi } from './client';
export interface EmailAddress {
email: string;
name?: string;
}
export interface Email {
id: string;
accountId: string;
folderId: string | null;
userId: string;
threadId: string | null;
messageId: string;
externalId: string | null;
subject: string | null;
fromAddress: string | null;
fromName: string | null;
toAddresses: EmailAddress[];
ccAddresses: EmailAddress[] | null;
snippet: string | null;
bodyPlain: string | null;
bodyHtml: string | null;
sentAt: string | null;
receivedAt: string | null;
isRead: boolean;
isStarred: boolean;
isDraft: boolean;
hasAttachments: boolean;
aiSummary: string | null;
aiCategory: string | null;
aiPriority: string | null;
aiSuggestedReplies: { text: string; tone: string }[] | null;
createdAt: string;
updatedAt: string;
}
export interface EmailFilters {
accountId?: string;
folderId?: string;
isRead?: boolean;
isStarred?: boolean;
search?: string;
limit?: number;
offset?: number;
}
export interface UpdateEmailDto {
isRead?: boolean;
isStarred?: boolean;
folderId?: string;
}
export interface BatchOperation {
operation: 'markRead' | 'markUnread' | 'star' | 'unstar' | 'move' | 'delete';
emailIds: string[];
targetFolderId?: string;
}
export const emailsApi = {
async list(filters: EmailFilters = {}) {
const params = new URLSearchParams();
if (filters.accountId) params.set('accountId', filters.accountId);
if (filters.folderId) params.set('folderId', filters.folderId);
if (filters.isRead !== undefined) params.set('isRead', String(filters.isRead));
if (filters.isStarred !== undefined) params.set('isStarred', String(filters.isStarred));
if (filters.search) params.set('search', filters.search);
if (filters.limit) params.set('limit', String(filters.limit));
if (filters.offset) params.set('offset', String(filters.offset));
const query = params.toString() ? `?${params.toString()}` : '';
return fetchApi<{ emails: Email[]; total: number }>(`/emails${query}`);
},
async search(query: string, accountId?: string) {
const params = new URLSearchParams({ q: query });
if (accountId) params.set('accountId', accountId);
return fetchApi<{ emails: Email[] }>(`/emails/search?${params.toString()}`);
},
async get(id: string) {
return fetchApi<{ email: Email }>(`/emails/${id}`);
},
async getThread(id: string) {
return fetchApi<{ emails: Email[] }>(`/emails/${id}/thread`);
},
async update(id: string, data: UpdateEmailDto) {
return fetchApi<{ email: Email }>(`/emails/${id}`, {
method: 'PATCH',
body: data,
});
},
async delete(id: string) {
return fetchApi<{ success: boolean }>(`/emails/${id}`, {
method: 'DELETE',
});
},
async move(id: string, targetFolderId: string) {
return fetchApi<{ email: Email }>(`/emails/${id}/move`, {
method: 'POST',
body: { targetFolderId },
});
},
async batch(operation: BatchOperation) {
return fetchApi<{ affected: number }>('/emails/batch', {
method: 'POST',
body: operation,
});
},
// AI Features
async summarize(id: string) {
return fetchApi<{ summary: string; keyPoints?: string[] }>(`/emails/${id}/summarize`, {
method: 'POST',
});
},
async suggestReplies(id: string) {
return fetchApi<{ replies: { text: string; tone: string }[] }>(
`/emails/${id}/suggest-replies`,
{
method: 'POST',
}
);
},
async categorize(id: string) {
return fetchApi<{ category: string; confidence: number; priority: string }>(
`/emails/${id}/categorize`,
{
method: 'POST',
}
);
},
};

View file

@ -0,0 +1,68 @@
import { fetchApi } from './client';
export interface Folder {
id: string;
accountId: string;
userId: string;
name: string;
type: 'inbox' | 'sent' | 'drafts' | 'trash' | 'spam' | 'archive' | 'custom';
path: string;
unreadCount: number;
totalCount: number;
isSystem: boolean;
isHidden: boolean;
createdAt: string;
}
export interface CreateFolderDto {
accountId: string;
name: string;
type?: string;
}
export interface UpdateFolderDto {
name?: string;
}
export const foldersApi = {
async list(accountId?: string) {
const query = accountId ? `?accountId=${accountId}` : '';
return fetchApi<{ folders: Folder[] }>(`/folders${query}`);
},
async get(id: string) {
return fetchApi<{ folder: Folder }>(`/folders/${id}`);
},
async create(data: CreateFolderDto) {
return fetchApi<{ folder: Folder }>('/folders', {
method: 'POST',
body: data,
});
},
async update(id: string, data: UpdateFolderDto) {
return fetchApi<{ folder: Folder }>(`/folders/${id}`, {
method: 'PATCH',
body: data,
});
},
async delete(id: string) {
return fetchApi<{ success: boolean }>(`/folders/${id}`, {
method: 'DELETE',
});
},
async toggleHide(id: string) {
return fetchApi<{ folder: Folder }>(`/folders/${id}/hide`, {
method: 'POST',
});
},
async sync(accountId: string, folderId: string) {
return fetchApi<{ emails: number }>(`/sync/accounts/${accountId}/folders/${folderId}`, {
method: 'POST',
});
},
};

View file

@ -0,0 +1,78 @@
import { fetchApi } from './client';
export interface Label {
id: string;
userId: string;
accountId: string | null;
name: string;
color: string;
createdAt: string;
}
export interface CreateLabelDto {
name: string;
color: string;
accountId?: string;
}
export interface UpdateLabelDto {
name?: string;
color?: string;
}
export const labelsApi = {
async list(accountId?: string) {
const query = accountId ? `?accountId=${accountId}` : '';
return fetchApi<{ labels: Label[] }>(`/labels${query}`);
},
async get(id: string) {
return fetchApi<{ label: Label }>(`/labels/${id}`);
},
async create(data: CreateLabelDto) {
return fetchApi<{ label: Label }>('/labels', {
method: 'POST',
body: data,
});
},
async update(id: string, data: UpdateLabelDto) {
return fetchApi<{ label: Label }>(`/labels/${id}`, {
method: 'PATCH',
body: data,
});
},
async delete(id: string) {
return fetchApi<{ success: boolean }>(`/labels/${id}`, {
method: 'DELETE',
});
},
// Email-Label associations
async getEmailLabels(emailId: string) {
return fetchApi<{ labels: Label[] }>(`/labels/email/${emailId}`);
},
async addToEmail(emailId: string, labelIds: string[]) {
return fetchApi<{ success: boolean }>(`/labels/email/${emailId}/add`, {
method: 'POST',
body: { labelIds },
});
},
async removeFromEmail(emailId: string, labelIds: string[]) {
return fetchApi<{ success: boolean }>(`/labels/email/${emailId}/remove`, {
method: 'POST',
body: { labelIds },
});
},
async setEmailLabels(emailId: string, labelIds: string[]) {
return fetchApi<{ success: boolean }>(`/labels/email/${emailId}/set`, {
method: 'POST',
body: { labelIds },
});
},
};

View file

@ -0,0 +1,229 @@
<script lang="ts">
import { composeStore } from '$lib/stores/compose.svelte';
import type { EmailAddress } from '$lib/api/emails';
let toInput = $state('');
let ccInput = $state('');
let bccInput = $state('');
let showCc = $state(false);
let showBcc = $state(false);
function parseEmailInput(input: string): EmailAddress[] {
if (!input.trim()) return [];
return input.split(',').map((email) => {
const trimmed = email.trim();
const match = trimmed.match(/^(?:"?([^"<]*)"?\s*)?<?([^>]+)>?$/);
if (match) {
return {
email: match[2] || trimmed,
name: match[1]?.trim() || undefined,
};
}
return { email: trimmed };
});
}
function formatAddresses(addresses: EmailAddress[]): string {
return addresses.map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)).join(', ');
}
// Initialize inputs from form
$effect(() => {
toInput = formatAddresses(composeStore.composeForm.toAddresses);
ccInput = formatAddresses(composeStore.composeForm.ccAddresses);
bccInput = formatAddresses(composeStore.composeForm.bccAddresses);
showCc = composeStore.composeForm.ccAddresses.length > 0;
showBcc = composeStore.composeForm.bccAddresses.length > 0;
});
function handleToChange() {
composeStore.updateForm({ toAddresses: parseEmailInput(toInput) });
}
function handleCcChange() {
composeStore.updateForm({ ccAddresses: parseEmailInput(ccInput) });
}
function handleBccChange() {
composeStore.updateForm({ bccAddresses: parseEmailInput(bccInput) });
}
function handleSubjectChange(event: Event) {
const input = event.target as HTMLInputElement;
composeStore.updateForm({ subject: input.value });
}
function handleBodyChange(event: Event) {
const textarea = event.target as HTMLTextAreaElement;
composeStore.updateForm({ bodyHtml: textarea.value });
}
async function handleSend() {
const success = await composeStore.send();
if (success) {
// Success message would be shown via toast
}
}
async function handleSaveDraft() {
await composeStore.saveDraft();
}
function handleClose() {
composeStore.closeCompose();
}
function handleDiscard() {
if (composeStore.currentDraft) {
composeStore.deleteDraft(composeStore.currentDraft.id);
}
composeStore.closeCompose();
}
</script>
<!-- Backdrop -->
<div
class="fixed inset-0 bg-black/50 z-40"
onclick={handleClose}
role="button"
tabindex="-1"
onkeydown={(e) => e.key === 'Escape' && handleClose()}
></div>
<!-- Modal -->
<div
class="fixed bottom-4 right-4 w-[600px] max-h-[80vh] bg-surface rounded-xl shadow-2xl z-50 flex flex-col border border-border"
>
<!-- Header -->
<div
class="flex items-center justify-between px-4 py-3 border-b border-border bg-muted/30 rounded-t-xl"
>
<h3 class="font-semibold">
{composeStore.composeForm.replyType === 'reply'
? 'Reply'
: composeStore.composeForm.replyType === 'reply-all'
? 'Reply All'
: composeStore.composeForm.replyType === 'forward'
? 'Forward'
: 'New Message'}
</h3>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-icon" onclick={handleClose} title="Minimize"></button>
<button class="btn btn-ghost btn-icon" onclick={handleClose} title="Close"></button>
</div>
</div>
<!-- Form -->
<div class="flex-1 overflow-y-auto">
<!-- To -->
<div class="px-4 py-2 border-b border-border flex items-center gap-2">
<label class="text-sm text-muted-foreground w-12">To:</label>
<input
type="text"
class="flex-1 bg-transparent border-none outline-none text-sm"
bind:value={toInput}
onblur={handleToChange}
placeholder="Recipients"
/>
<div class="flex gap-1">
{#if !showCc}
<button
class="text-xs text-muted-foreground hover:text-foreground"
onclick={() => (showCc = true)}
>
Cc
</button>
{/if}
{#if !showBcc}
<button
class="text-xs text-muted-foreground hover:text-foreground"
onclick={() => (showBcc = true)}
>
Bcc
</button>
{/if}
</div>
</div>
<!-- Cc -->
{#if showCc}
<div class="px-4 py-2 border-b border-border flex items-center gap-2">
<label class="text-sm text-muted-foreground w-12">Cc:</label>
<input
type="text"
class="flex-1 bg-transparent border-none outline-none text-sm"
bind:value={ccInput}
onblur={handleCcChange}
placeholder="Cc recipients"
/>
</div>
{/if}
<!-- Bcc -->
{#if showBcc}
<div class="px-4 py-2 border-b border-border flex items-center gap-2">
<label class="text-sm text-muted-foreground w-12">Bcc:</label>
<input
type="text"
class="flex-1 bg-transparent border-none outline-none text-sm"
bind:value={bccInput}
onblur={handleBccChange}
placeholder="Bcc recipients"
/>
</div>
{/if}
<!-- Subject -->
<div class="px-4 py-2 border-b border-border">
<input
type="text"
class="w-full bg-transparent border-none outline-none text-sm"
value={composeStore.composeForm.subject}
oninput={handleSubjectChange}
placeholder="Subject"
/>
</div>
<!-- Body -->
<div class="p-4">
<textarea
class="w-full h-64 bg-transparent border-none outline-none text-sm resize-none"
value={composeStore.composeForm.bodyHtml}
oninput={handleBodyChange}
placeholder="Write your message..."
></textarea>
</div>
</div>
<!-- Footer -->
<div
class="flex items-center justify-between px-4 py-3 border-t border-border bg-muted/30 rounded-b-xl"
>
<div class="flex items-center gap-2">
<button class="btn btn-primary" onclick={handleSend} disabled={composeStore.sending}>
{composeStore.sending ? 'Sending...' : 'Send'}
</button>
<button class="btn btn-ghost" title="Formatting"> A </button>
<button class="btn btn-ghost" title="Attach file"> 📎 </button>
</div>
<div class="flex items-center gap-2">
<button
class="btn btn-ghost btn-sm"
onclick={handleSaveDraft}
disabled={composeStore.loading}
>
{composeStore.loading ? 'Saving...' : 'Save Draft'}
</button>
<button class="btn btn-ghost btn-sm text-destructive" onclick={handleDiscard}> 🗑️ </button>
</div>
</div>
<!-- Error Message -->
{#if composeStore.error}
<div
class="absolute bottom-16 left-4 right-4 bg-destructive/10 text-destructive text-sm p-2 rounded-md"
>
{composeStore.error}
</div>
{/if}
</div>

View file

@ -0,0 +1,153 @@
<script lang="ts">
import type { Email } from '$lib/api/emails';
import { emailsStore } from '$lib/stores/emails.svelte';
interface Props {
email: Email;
onClose: () => void;
onReply: () => void;
onReplyAll: () => void;
onForward: () => void;
}
let { email, onClose, onReply, onReplyAll, onForward }: Props = $props();
let summaryLoading = $state(false);
let repliesLoading = $state(false);
function formatDate(dateString: string | null): string {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function formatAddresses(addresses: { email: string; name?: string }[] | null): string {
if (!addresses || addresses.length === 0) return '';
return addresses.map((a) => (a.name ? `${a.name} <${a.email}>` : a.email)).join(', ');
}
async function handleSummarize() {
summaryLoading = true;
await emailsStore.summarize(email.id);
summaryLoading = false;
}
async function handleSuggestReplies() {
repliesLoading = true;
await emailsStore.suggestReplies(email.id);
repliesLoading = false;
}
</script>
<div class="email-detail h-full flex flex-col">
<!-- Header -->
<div class="email-detail-header">
<div class="flex items-center justify-between mb-4">
<button class="btn btn-ghost btn-icon" onclick={onClose} title="Close"></button>
<div class="flex items-center gap-2">
<button class="btn btn-ghost btn-sm" onclick={onReply} title="Reply"> ↩️ Reply </button>
<button class="btn btn-ghost btn-sm" onclick={onReplyAll} title="Reply All"> ↩️↩️ </button>
<button class="btn btn-ghost btn-sm" onclick={onForward} title="Forward">
↪️ Forward
</button>
</div>
</div>
<h2 class="text-xl font-semibold mb-2">{email.subject || '(No Subject)'}</h2>
<div class="flex items-start gap-3">
<div class="email-avatar" style="background-color: hsl(217, 91%, 60%)">
{(email.fromName || email.fromAddress || '?')[0].toUpperCase()}
</div>
<div class="flex-1 min-w-0">
<div class="font-medium">{email.fromName || email.fromAddress}</div>
<div class="text-sm text-muted-foreground">{email.fromAddress}</div>
<div class="text-sm text-muted-foreground mt-1">
To: {formatAddresses(email.toAddresses)}
</div>
{#if email.ccAddresses && email.ccAddresses.length > 0}
<div class="text-sm text-muted-foreground">
Cc: {formatAddresses(email.ccAddresses)}
</div>
{/if}
</div>
<div class="text-sm text-muted-foreground">
{formatDate(email.receivedAt || email.sentAt)}
</div>
</div>
{#if email.aiCategory}
<div class="mt-3">
<span class="category-badge {email.aiCategory}">{email.aiCategory}</span>
{#if email.aiPriority}
<span class="ml-2 text-sm text-muted-foreground">
Priority: {email.aiPriority}
</span>
{/if}
</div>
{/if}
</div>
<!-- AI Features -->
<div class="px-4 py-3 border-b border-border">
{#if email.aiSummary}
<div class="ai-summary-card">
<div class="label">✨ AI Summary</div>
<p class="text-sm">{email.aiSummary}</p>
</div>
{:else}
<button class="btn btn-secondary btn-sm" onclick={handleSummarize} disabled={summaryLoading}>
{summaryLoading ? 'Summarizing...' : '✨ Summarize'}
</button>
{/if}
{#if email.aiSuggestedReplies && email.aiSuggestedReplies.length > 0}
<div class="mt-3">
<div class="text-xs font-semibold text-muted-foreground mb-2">Smart Replies</div>
<div class="flex flex-wrap gap-2">
{#each email.aiSuggestedReplies as reply}
<button class="smart-reply-chip" title={reply.tone}>
{reply.text}
</button>
{/each}
</div>
</div>
{:else}
<button
class="btn btn-secondary btn-sm mt-2"
onclick={handleSuggestReplies}
disabled={repliesLoading}
>
{repliesLoading ? 'Generating...' : '💬 Suggest Replies'}
</button>
{/if}
</div>
<!-- Body -->
<div class="email-detail-body flex-1 overflow-y-auto scrollbar-thin">
{#if email.bodyHtml}
<div class="prose prose-sm max-w-none">
{@html email.bodyHtml}
</div>
{:else if email.bodyPlain}
<pre class="whitespace-pre-wrap font-sans text-sm">{email.bodyPlain}</pre>
{:else}
<p class="text-muted-foreground italic">No content</p>
{/if}
</div>
<!-- Attachments -->
{#if email.hasAttachments}
<div class="px-4 py-3 border-t border-border">
<div class="text-sm font-semibold mb-2">📎 Attachments</div>
<div class="text-sm text-muted-foreground">Attachments available (click to download)</div>
</div>
{/if}
</div>

View file

@ -0,0 +1,158 @@
<script lang="ts">
import type { Email } from '$lib/api/emails';
import { emailsStore } from '$lib/stores/emails.svelte';
interface Props {
emails: Email[];
loading: boolean;
selectedId: string | null;
onSelect: (id: string) => void;
}
let { emails, loading, selectedId, onSelect }: Props = $props();
let selectedIds = $state<Set<string>>(new Set());
function formatDate(dateString: string | null): string {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return date.toLocaleDateString('en-US', { weekday: 'short' });
} else {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
}
function getInitials(name: string | null, email: string | null): string {
if (name) {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
if (email) {
return email[0].toUpperCase();
}
return '?';
}
function getAvatarColor(email: string | null): string {
if (!email) return 'hsl(220, 13%, 50%)';
const colors = [
'hsl(217, 91%, 60%)',
'hsl(142, 76%, 36%)',
'hsl(262, 83%, 58%)',
'hsl(31, 97%, 52%)',
'hsl(350, 89%, 60%)',
'hsl(199, 89%, 48%)',
];
const hash = email.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0);
return colors[hash % colors.length];
}
function toggleSelect(id: string, event: Event) {
event.stopPropagation();
const newSelected = new Set(selectedIds);
if (newSelected.has(id)) {
newSelected.delete(id);
} else {
newSelected.add(id);
}
selectedIds = newSelected;
}
async function toggleStar(id: string, event: Event) {
event.stopPropagation();
await emailsStore.toggleStar(id);
}
</script>
<div class="h-full overflow-y-auto scrollbar-thin">
{#if loading && emails.length === 0}
<div class="flex items-center justify-center h-32">
<div class="text-muted-foreground">Loading emails...</div>
</div>
{:else if emails.length === 0}
<div class="flex items-center justify-center h-32">
<div class="text-center">
<div class="text-4xl mb-2">📭</div>
<div class="text-muted-foreground">No emails yet</div>
</div>
</div>
{:else}
{#each emails as email}
<div
class="email-row"
class:selected={selectedId === email.id}
class:unread={!email.isRead}
onclick={() => onSelect(email.id)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && onSelect(email.id)}
>
<!-- Checkbox -->
<input
type="checkbox"
class="email-checkbox mr-3"
checked={selectedIds.has(email.id)}
onclick={(e) => toggleSelect(email.id, e)}
/>
<!-- Star -->
<button
class="star-button mr-2"
class:starred={email.isStarred}
onclick={(e) => toggleStar(email.id, e)}
>
{email.isStarred ? '⭐' : '☆'}
</button>
<!-- Avatar -->
<div
class="email-avatar mr-3"
style="background-color: {getAvatarColor(email.fromAddress)}"
>
{getInitials(email.fromName, email.fromAddress)}
</div>
<!-- Content -->
<div class="email-content">
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">
{email.fromName || email.fromAddress || 'Unknown'}
</span>
{#if email.aiCategory}
<span class="category-badge {email.aiCategory}">{email.aiCategory}</span>
{/if}
</div>
<div class="email-subject">{email.subject || '(No Subject)'}</div>
<div class="email-snippet">{email.snippet || ''}</div>
</div>
<!-- Attachment indicator -->
{#if email.hasAttachments}
<span class="mr-2 text-muted-foreground" title="Has attachments">📎</span>
{/if}
<!-- Date -->
<div class="email-date">{formatDate(email.receivedAt || email.sentAt)}</div>
</div>
{/each}
{#if loading}
<div class="flex items-center justify-center py-4">
<div class="text-muted-foreground text-sm">Loading more...</div>
</div>
{/if}
{/if}
</div>

View file

@ -0,0 +1,137 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { foldersStore } from '$lib/stores/folders.svelte';
import { accountsStore } from '$lib/stores/accounts.svelte';
import { emailsStore } from '$lib/stores/emails.svelte';
import { composeStore } from '$lib/stores/compose.svelte';
interface Props {
onCompose: () => void;
}
let { onCompose }: Props = $props();
// Quick access items (routes)
const quickAccessItems = [
{ path: '/', icon: '📥', label: 'Inbox', type: 'inbox' },
{ path: '/starred', icon: '⭐', label: 'Starred', type: 'starred' },
{ path: '/sent', icon: '📤', label: 'Sent', type: 'sent' },
{ path: '/drafts', icon: '📝', label: 'Drafts', type: 'drafts' },
];
// Current path for active state
let currentPath = $derived($page.url.pathname);
// Get unread count for inbox
let inboxUnread = $derived(foldersStore.inboxFolder?.unreadCount || 0);
// Get drafts count
let draftsCount = $derived(composeStore.drafts.length);
function handleAccountChange(event: Event) {
const select = event.target as HTMLSelectElement;
accountsStore.setSelectedAccount(select.value);
foldersStore.fetchFolders(select.value);
}
function handleNavClick(path: string) {
goto(path);
}
</script>
<aside class="w-60 flex-shrink-0 flex flex-col gap-4">
<!-- Compose Button -->
<button class="compose-button" onclick={onCompose}>
<span class="text-lg">✏️</span>
<span>Compose</span>
</button>
<!-- Account Selector -->
{#if accountsStore.accounts.length > 1}
<select class="input" value={accountsStore.selectedAccountId} onchange={handleAccountChange}>
{#each accountsStore.accounts as account}
<option value={account.id}>{account.name}</option>
{/each}
</select>
{/if}
<!-- Quick Access Navigation -->
<nav class="flex flex-col gap-1">
<div class="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-3 py-2">
Mail
</div>
{#each quickAccessItems as item}
<button
class="folder-item"
class:active={currentPath === item.path}
onclick={() => handleNavClick(item.path)}
>
<span class="mr-3">{item.icon}</span>
<span class="flex-1 text-left">{item.label}</span>
{#if item.type === 'inbox' && inboxUnread > 0}
<span class="folder-badge">{inboxUnread}</span>
{/if}
{#if item.type === 'drafts' && draftsCount > 0}
<span class="folder-badge">{draftsCount}</span>
{/if}
</button>
{/each}
<!-- Custom Folders -->
{#if foldersStore.customFolders.length > 0}
<div
class="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-3 py-2 mt-4"
>
Labels
</div>
{#each foldersStore.customFolders as folder}
<button
class="folder-item"
onclick={() => {
foldersStore.setSelectedFolder(folder.id);
if (accountsStore.selectedAccountId) {
emailsStore.fetchEmails({
accountId: accountsStore.selectedAccountId,
folderId: folder.id,
});
goto('/');
}
}}
>
<span class="mr-3">📁</span>
<span class="flex-1 text-left">{folder.name}</span>
{#if folder.unreadCount > 0}
<span class="folder-badge">{folder.unreadCount}</span>
{/if}
</button>
{/each}
{/if}
<!-- Settings Link -->
<div
class="text-xs font-semibold text-muted-foreground uppercase tracking-wider px-3 py-2 mt-4"
>
More
</div>
<button
class="folder-item"
class:active={currentPath === '/settings'}
onclick={() => handleNavClick('/settings')}
>
<span class="mr-3">⚙️</span>
<span class="flex-1 text-left">Settings</span>
</button>
</nav>
<!-- Account Info -->
{#if accountsStore.selectedAccount}
<div class="mt-auto p-3 rounded-lg bg-muted/30 text-sm">
<div class="font-medium truncate">{accountsStore.selectedAccount.name}</div>
<div class="text-muted-foreground truncate text-xs">
{accountsStore.selectedAccount.email}
</div>
</div>
{/if}
</aside>

View file

@ -0,0 +1,58 @@
/**
* i18n setup for Mail app
* Supports: DE, EN, FR, ES, IT
*/
import { browser } from '$app/environment';
import { init, register, locale, getLocaleFromNavigator } from 'svelte-i18n';
// Supported locales
export const supportedLocales = ['de', 'en', 'fr', 'es', 'it'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
// Register locales
register('de', () => import('./locales/de.json'));
register('en', () => import('./locales/en.json'));
register('fr', () => import('./locales/fr.json'));
register('es', () => import('./locales/es.json'));
register('it', () => import('./locales/it.json'));
// Get initial locale
function getInitialLocale(): SupportedLocale {
if (browser) {
// Check localStorage first
const saved = localStorage.getItem('mail-locale');
if (saved && supportedLocales.includes(saved as SupportedLocale)) {
return saved as SupportedLocale;
}
// Fall back to browser language
const browserLocale = getLocaleFromNavigator();
if (browserLocale) {
const shortLocale = browserLocale.split('-')[0] as SupportedLocale;
if (supportedLocales.includes(shortLocale)) {
return shortLocale;
}
}
}
// Default to German
return 'de';
}
// Initialize i18n at module scope (required for SSR)
init({
fallbackLocale: 'de',
initialLocale: getInitialLocale(),
});
// Set locale and persist
export function setLocale(newLocale: SupportedLocale) {
locale.set(newLocale);
if (browser) {
localStorage.setItem('mail-locale', newLocale);
}
}
// Wait for locale to be loaded (useful for SSR)
export { waitLocale } from 'svelte-i18n';

View file

@ -0,0 +1,105 @@
{
"app": {
"name": "Mail",
"loading": "Laden..."
},
"nav": {
"inbox": "Posteingang",
"sent": "Gesendet",
"drafts": "Entwürfe",
"starred": "Markiert",
"archive": "Archiv",
"trash": "Papierkorb",
"spam": "Spam",
"settings": "Einstellungen",
"feedback": "Feedback"
},
"auth": {
"login": "Anmelden",
"register": "Registrieren",
"logout": "Abmelden",
"forgotPassword": "Passwort vergessen",
"email": "E-Mail",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen"
},
"email": {
"compose": "Neue E-Mail",
"reply": "Antworten",
"replyAll": "Allen antworten",
"forward": "Weiterleiten",
"delete": "Löschen",
"archive": "Archivieren",
"markRead": "Als gelesen markieren",
"markUnread": "Als ungelesen markieren",
"star": "Mit Stern markieren",
"unstar": "Stern entfernen",
"to": "An",
"cc": "CC",
"bcc": "BCC",
"subject": "Betreff",
"body": "Nachricht",
"send": "Senden",
"saveDraft": "Als Entwurf speichern",
"discard": "Verwerfen",
"attachments": "Anhänge",
"addAttachment": "Anhang hinzufügen",
"noEmails": "Keine E-Mails",
"noSubject": "(Kein Betreff)",
"from": "Von",
"date": "Datum"
},
"folders": {
"title": "Ordner",
"add": "Ordner hinzufügen",
"edit": "Ordner bearbeiten",
"delete": "Ordner löschen",
"name": "Ordnername"
},
"labels": {
"title": "Labels",
"add": "Label hinzufügen",
"edit": "Label bearbeiten",
"delete": "Label löschen",
"name": "Labelname",
"color": "Farbe"
},
"accounts": {
"title": "E-Mail-Konten",
"add": "Konto hinzufügen",
"edit": "Konto bearbeiten",
"delete": "Konto löschen",
"name": "Kontoname",
"address": "E-Mail-Adresse",
"default": "Standardkonto"
},
"settings": {
"title": "Einstellungen",
"general": "Allgemein",
"appearance": "Darstellung",
"accounts": "Konten",
"signature": "Signatur",
"language": "Sprache",
"theme": "Design",
"darkMode": "Dunkelmodus",
"notifications": "Benachrichtigungen"
},
"common": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"confirm": "Bestätigen",
"yes": "Ja",
"no": "Nein",
"ok": "OK",
"loading": "Laden...",
"error": "Fehler",
"success": "Erfolg",
"back": "Zurück",
"search": "Suchen",
"select": "Auswählen",
"selectAll": "Alle auswählen"
}
}

View file

@ -0,0 +1,105 @@
{
"app": {
"name": "Mail",
"loading": "Loading..."
},
"nav": {
"inbox": "Inbox",
"sent": "Sent",
"drafts": "Drafts",
"starred": "Starred",
"archive": "Archive",
"trash": "Trash",
"spam": "Spam",
"settings": "Settings",
"feedback": "Feedback"
},
"auth": {
"login": "Sign In",
"register": "Sign Up",
"logout": "Sign Out",
"forgotPassword": "Forgot Password",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm Password"
},
"email": {
"compose": "Compose",
"reply": "Reply",
"replyAll": "Reply All",
"forward": "Forward",
"delete": "Delete",
"archive": "Archive",
"markRead": "Mark as read",
"markUnread": "Mark as unread",
"star": "Star",
"unstar": "Unstar",
"to": "To",
"cc": "CC",
"bcc": "BCC",
"subject": "Subject",
"body": "Message",
"send": "Send",
"saveDraft": "Save as draft",
"discard": "Discard",
"attachments": "Attachments",
"addAttachment": "Add attachment",
"noEmails": "No emails",
"noSubject": "(No subject)",
"from": "From",
"date": "Date"
},
"folders": {
"title": "Folders",
"add": "Add Folder",
"edit": "Edit Folder",
"delete": "Delete Folder",
"name": "Folder name"
},
"labels": {
"title": "Labels",
"add": "Add Label",
"edit": "Edit Label",
"delete": "Delete Label",
"name": "Label name",
"color": "Color"
},
"accounts": {
"title": "Email Accounts",
"add": "Add Account",
"edit": "Edit Account",
"delete": "Delete Account",
"name": "Account name",
"address": "Email address",
"default": "Default account"
},
"settings": {
"title": "Settings",
"general": "General",
"appearance": "Appearance",
"accounts": "Accounts",
"signature": "Signature",
"language": "Language",
"theme": "Theme",
"darkMode": "Dark Mode",
"notifications": "Notifications"
},
"common": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"confirm": "Confirm",
"yes": "Yes",
"no": "No",
"ok": "OK",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"back": "Back",
"search": "Search",
"select": "Select",
"selectAll": "Select all"
}
}

View file

@ -0,0 +1,105 @@
{
"app": {
"name": "Mail",
"loading": "Cargando..."
},
"nav": {
"inbox": "Bandeja de entrada",
"sent": "Enviados",
"drafts": "Borradores",
"starred": "Destacados",
"archive": "Archivo",
"trash": "Papelera",
"spam": "Spam",
"settings": "Configuración",
"feedback": "Feedback"
},
"auth": {
"login": "Iniciar sesión",
"register": "Registrarse",
"logout": "Cerrar sesión",
"forgotPassword": "Olvidé mi contraseña",
"email": "Correo electrónico",
"password": "Contraseña",
"confirmPassword": "Confirmar contraseña"
},
"email": {
"compose": "Redactar",
"reply": "Responder",
"replyAll": "Responder a todos",
"forward": "Reenviar",
"delete": "Eliminar",
"archive": "Archivar",
"markRead": "Marcar como leído",
"markUnread": "Marcar como no leído",
"star": "Destacar",
"unstar": "Quitar destacado",
"to": "Para",
"cc": "CC",
"bcc": "CCO",
"subject": "Asunto",
"body": "Mensaje",
"send": "Enviar",
"saveDraft": "Guardar como borrador",
"discard": "Descartar",
"attachments": "Adjuntos",
"addAttachment": "Añadir adjunto",
"noEmails": "Sin correos",
"noSubject": "(Sin asunto)",
"from": "De",
"date": "Fecha"
},
"folders": {
"title": "Carpetas",
"add": "Añadir carpeta",
"edit": "Editar carpeta",
"delete": "Eliminar carpeta",
"name": "Nombre de carpeta"
},
"labels": {
"title": "Etiquetas",
"add": "Añadir etiqueta",
"edit": "Editar etiqueta",
"delete": "Eliminar etiqueta",
"name": "Nombre de etiqueta",
"color": "Color"
},
"accounts": {
"title": "Cuentas de correo",
"add": "Añadir cuenta",
"edit": "Editar cuenta",
"delete": "Eliminar cuenta",
"name": "Nombre de cuenta",
"address": "Dirección de correo",
"default": "Cuenta predeterminada"
},
"settings": {
"title": "Configuración",
"general": "General",
"appearance": "Apariencia",
"accounts": "Cuentas",
"signature": "Firma",
"language": "Idioma",
"theme": "Tema",
"darkMode": "Modo oscuro",
"notifications": "Notificaciones"
},
"common": {
"save": "Guardar",
"cancel": "Cancelar",
"delete": "Eliminar",
"edit": "Editar",
"add": "Añadir",
"confirm": "Confirmar",
"yes": "Sí",
"no": "No",
"ok": "OK",
"loading": "Cargando...",
"error": "Error",
"success": "Éxito",
"back": "Atrás",
"search": "Buscar",
"select": "Seleccionar",
"selectAll": "Seleccionar todo"
}
}

View file

@ -0,0 +1,105 @@
{
"app": {
"name": "Mail",
"loading": "Chargement..."
},
"nav": {
"inbox": "Boîte de réception",
"sent": "Envoyés",
"drafts": "Brouillons",
"starred": "Favoris",
"archive": "Archives",
"trash": "Corbeille",
"spam": "Spam",
"settings": "Paramètres",
"feedback": "Feedback"
},
"auth": {
"login": "Se connecter",
"register": "S'inscrire",
"logout": "Se déconnecter",
"forgotPassword": "Mot de passe oublié",
"email": "E-mail",
"password": "Mot de passe",
"confirmPassword": "Confirmer le mot de passe"
},
"email": {
"compose": "Nouveau message",
"reply": "Répondre",
"replyAll": "Répondre à tous",
"forward": "Transférer",
"delete": "Supprimer",
"archive": "Archiver",
"markRead": "Marquer comme lu",
"markUnread": "Marquer comme non lu",
"star": "Ajouter aux favoris",
"unstar": "Retirer des favoris",
"to": "À",
"cc": "CC",
"bcc": "CCI",
"subject": "Objet",
"body": "Message",
"send": "Envoyer",
"saveDraft": "Enregistrer comme brouillon",
"discard": "Annuler",
"attachments": "Pièces jointes",
"addAttachment": "Ajouter une pièce jointe",
"noEmails": "Aucun e-mail",
"noSubject": "(Sans objet)",
"from": "De",
"date": "Date"
},
"folders": {
"title": "Dossiers",
"add": "Ajouter un dossier",
"edit": "Modifier le dossier",
"delete": "Supprimer le dossier",
"name": "Nom du dossier"
},
"labels": {
"title": "Libellés",
"add": "Ajouter un libellé",
"edit": "Modifier le libellé",
"delete": "Supprimer le libellé",
"name": "Nom du libellé",
"color": "Couleur"
},
"accounts": {
"title": "Comptes e-mail",
"add": "Ajouter un compte",
"edit": "Modifier le compte",
"delete": "Supprimer le compte",
"name": "Nom du compte",
"address": "Adresse e-mail",
"default": "Compte par défaut"
},
"settings": {
"title": "Paramètres",
"general": "Général",
"appearance": "Apparence",
"accounts": "Comptes",
"signature": "Signature",
"language": "Langue",
"theme": "Thème",
"darkMode": "Mode sombre",
"notifications": "Notifications"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",
"delete": "Supprimer",
"edit": "Modifier",
"add": "Ajouter",
"confirm": "Confirmer",
"yes": "Oui",
"no": "Non",
"ok": "OK",
"loading": "Chargement...",
"error": "Erreur",
"success": "Succès",
"back": "Retour",
"search": "Rechercher",
"select": "Sélectionner",
"selectAll": "Tout sélectionner"
}
}

View file

@ -0,0 +1,105 @@
{
"app": {
"name": "Mail",
"loading": "Caricamento..."
},
"nav": {
"inbox": "Posta in arrivo",
"sent": "Inviati",
"drafts": "Bozze",
"starred": "Speciali",
"archive": "Archivio",
"trash": "Cestino",
"spam": "Spam",
"settings": "Impostazioni",
"feedback": "Feedback"
},
"auth": {
"login": "Accedi",
"register": "Registrati",
"logout": "Esci",
"forgotPassword": "Password dimenticata",
"email": "E-mail",
"password": "Password",
"confirmPassword": "Conferma password"
},
"email": {
"compose": "Scrivi",
"reply": "Rispondi",
"replyAll": "Rispondi a tutti",
"forward": "Inoltra",
"delete": "Elimina",
"archive": "Archivia",
"markRead": "Segna come letto",
"markUnread": "Segna come non letto",
"star": "Aggiungi a speciali",
"unstar": "Rimuovi da speciali",
"to": "A",
"cc": "CC",
"bcc": "CCN",
"subject": "Oggetto",
"body": "Messaggio",
"send": "Invia",
"saveDraft": "Salva come bozza",
"discard": "Elimina",
"attachments": "Allegati",
"addAttachment": "Aggiungi allegato",
"noEmails": "Nessuna email",
"noSubject": "(Nessun oggetto)",
"from": "Da",
"date": "Data"
},
"folders": {
"title": "Cartelle",
"add": "Aggiungi cartella",
"edit": "Modifica cartella",
"delete": "Elimina cartella",
"name": "Nome cartella"
},
"labels": {
"title": "Etichette",
"add": "Aggiungi etichetta",
"edit": "Modifica etichetta",
"delete": "Elimina etichetta",
"name": "Nome etichetta",
"color": "Colore"
},
"accounts": {
"title": "Account email",
"add": "Aggiungi account",
"edit": "Modifica account",
"delete": "Elimina account",
"name": "Nome account",
"address": "Indirizzo email",
"default": "Account predefinito"
},
"settings": {
"title": "Impostazioni",
"general": "Generale",
"appearance": "Aspetto",
"accounts": "Account",
"signature": "Firma",
"language": "Lingua",
"theme": "Tema",
"darkMode": "Modalità scura",
"notifications": "Notifiche"
},
"common": {
"save": "Salva",
"cancel": "Annulla",
"delete": "Elimina",
"edit": "Modifica",
"add": "Aggiungi",
"confirm": "Conferma",
"yes": "Sì",
"no": "No",
"ok": "OK",
"loading": "Caricamento...",
"error": "Errore",
"success": "Successo",
"back": "Indietro",
"search": "Cerca",
"select": "Seleziona",
"selectAll": "Seleziona tutto"
}
}

View file

@ -0,0 +1,211 @@
/**
* Accounts Store - Manages email accounts using Svelte 5 runes
*/
import { accountsApi, type EmailAccount } from '$lib/api/accounts';
let accounts = $state<EmailAccount[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedAccountId = $state<string | null>(null);
export const accountsStore = {
get accounts() {
return accounts;
},
get loading() {
return loading;
},
get error() {
return error;
},
get selectedAccountId() {
return selectedAccountId;
},
get selectedAccount() {
return accounts.find((a) => a.id === selectedAccountId) || null;
},
get defaultAccount() {
return accounts.find((a) => a.isDefault) || accounts[0] || null;
},
setSelectedAccount(id: string | null) {
selectedAccountId = id;
},
async fetchAccounts() {
loading = true;
error = null;
const result = await accountsApi.list();
if (result.error) {
error = result.error.message;
loading = false;
return;
}
accounts = result.data?.accounts || [];
// Auto-select default or first account
if (!selectedAccountId && accounts.length > 0) {
const defaultAcc = accounts.find((a) => a.isDefault);
selectedAccountId = defaultAcc?.id || accounts[0].id;
}
loading = false;
},
async createImapAccount(data: Parameters<typeof accountsApi.create>[0]) {
loading = true;
error = null;
const result = await accountsApi.create(data);
if (result.error) {
error = result.error.message;
loading = false;
return null;
}
if (result.data?.account) {
accounts = [...accounts, result.data.account];
if (accounts.length === 1) {
selectedAccountId = result.data.account.id;
}
}
loading = false;
return result.data?.account || null;
},
async updateAccount(id: string, data: Parameters<typeof accountsApi.update>[1]) {
const result = await accountsApi.update(id, data);
if (result.error) {
error = result.error.message;
return null;
}
if (result.data?.account) {
accounts = accounts.map((a) => (a.id === id ? result.data!.account : a));
}
return result.data?.account || null;
},
async deleteAccount(id: string) {
const result = await accountsApi.delete(id);
if (result.error) {
error = result.error.message;
return false;
}
accounts = accounts.filter((a) => a.id !== id);
if (selectedAccountId === id) {
selectedAccountId = accounts[0]?.id || null;
}
return true;
},
async setDefaultAccount(id: string) {
const result = await accountsApi.setDefault(id);
if (result.error) {
error = result.error.message;
return false;
}
accounts = accounts.map((a) => ({
...a,
isDefault: a.id === id,
}));
return true;
},
async syncAccount(id: string) {
const result = await accountsApi.sync(id);
if (result.error) {
error = result.error.message;
return null;
}
// Update lastSyncAt
accounts = accounts.map((a) =>
a.id === id ? { ...a, lastSyncAt: new Date().toISOString() } : a
);
return result.data;
},
async initGoogleOAuth() {
const result = await accountsApi.initGoogleOAuth();
if (result.data?.authUrl) {
window.location.href = result.data.authUrl;
}
return result;
},
async initMicrosoftOAuth() {
const result = await accountsApi.initMicrosoftOAuth();
if (result.data?.authUrl) {
window.location.href = result.data.authUrl;
}
return result;
},
// Aliases for settings page
async addImapAccount(data: {
name: string;
email: string;
imapHost: string;
imapPort: number;
smtpHost: string;
smtpPort: number;
password: string;
}) {
return this.createImapAccount(data);
},
async removeAccount(id: string) {
return this.deleteAccount(id);
},
async testConnection(data: {
imapHost: string;
imapPort: number;
smtpHost: string;
smtpPort: number;
email: string;
password: string;
}) {
const result = await accountsApi.test({
...data,
name: 'Test',
});
if (result.error) {
return { success: false, message: result.error.message };
}
return {
success: result.data?.success || false,
message: result.data?.success
? 'Connection successful!'
: result.data?.message || 'Connection failed',
};
},
initiateGoogleOAuth() {
return this.initGoogleOAuth();
},
initiateMicrosoftOAuth() {
return this.initMicrosoftOAuth();
},
};

View file

@ -0,0 +1,160 @@
/**
* Auth Store - Manages authentication state using Svelte 5 runes
* Uses Mana Core Auth
*/
import { browser } from '$app/environment';
import { initializeWebAuth } from '@manacore/shared-auth';
import type { UserData } from '@manacore/shared-auth';
const MANA_AUTH_URL = 'http://localhost:3001';
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
function getAuthService() {
if (!browser) return null;
if (!_authService) {
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
_authService = auth.authService;
_tokenManager = auth.tokenManager;
}
return _authService;
}
let user = $state<UserData | null>(null);
let loading = $state(true);
let initialized = $state(false);
export const authStore = {
get user() {
return user;
},
get loading() {
return loading;
},
get isAuthenticated() {
return !!user;
},
get initialized() {
return initialized;
},
async initialize() {
if (initialized) return;
const authService = getAuthService();
if (!authService) {
initialized = true;
loading = false;
return;
}
loading = true;
try {
const authenticated = await authService.isAuthenticated();
if (authenticated) {
const userData = await authService.getUserFromToken();
user = userData;
}
initialized = true;
} catch (error) {
console.error('Failed to initialize auth:', error);
user = null;
} finally {
loading = false;
}
},
async signIn(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.signIn(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Login failed' };
}
const userData = await authService.getUserFromToken();
user = userData;
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
async signUp(email: string, password: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server', needsVerification: false };
}
try {
const result = await authService.signUp(email, password);
if (!result.success) {
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
}
if (result.needsVerification) {
return { success: true, needsVerification: true };
}
const signInResult = await this.signIn(email, password);
return { ...signInResult, needsVerification: false };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage, needsVerification: false };
}
},
async signOut() {
const authService = getAuthService();
if (!authService) {
user = null;
return;
}
try {
await authService.signOut();
user = null;
} catch (error) {
console.error('Sign out error:', error);
user = null;
}
},
async resetPassword(email: string) {
const authService = getAuthService();
if (!authService) {
return { success: false, error: 'Auth not available on server' };
}
try {
const result = await authService.forgotPassword(email);
if (!result.success) {
return { success: false, error: result.error || 'Password reset failed' };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
return { success: false, error: errorMessage };
}
},
async getAccessToken() {
const authService = getAuthService();
if (!authService) {
return null;
}
return await authService.getAppToken();
},
};

View file

@ -0,0 +1,327 @@
/**
* Compose Store - Manages draft composition using Svelte 5 runes
*/
import {
composeApi,
type Draft,
type CreateDraftDto,
type UpdateDraftDto,
type SendEmailDto,
} from '$lib/api/compose';
import type { EmailAddress } from '$lib/api/emails';
let drafts = $state<Draft[]>([]);
let loading = $state(false);
let sending = $state(false);
let error = $state<string | null>(null);
let isComposeOpen = $state(false);
let currentDraft = $state<Draft | null>(null);
// Compose form state
let composeForm = $state<{
accountId: string;
subject: string;
toAddresses: EmailAddress[];
ccAddresses: EmailAddress[];
bccAddresses: EmailAddress[];
bodyHtml: string;
replyToEmailId?: string;
replyType?: 'reply' | 'reply-all' | 'forward';
}>({
accountId: '',
subject: '',
toAddresses: [],
ccAddresses: [],
bccAddresses: [],
bodyHtml: '',
});
export const composeStore = {
get drafts() {
return drafts;
},
get loading() {
return loading;
},
get sending() {
return sending;
},
get error() {
return error;
},
get isComposeOpen() {
return isComposeOpen;
},
get currentDraft() {
return currentDraft;
},
get composeForm() {
return composeForm;
},
// Compose modal controls
openCompose(accountId: string) {
composeForm = {
accountId,
subject: '',
toAddresses: [],
ccAddresses: [],
bccAddresses: [],
bodyHtml: '',
};
currentDraft = null;
isComposeOpen = true;
},
closeCompose() {
isComposeOpen = false;
currentDraft = null;
},
updateForm(updates: Partial<typeof composeForm>) {
composeForm = { ...composeForm, ...updates };
},
// Draft management
async fetchDrafts(accountId?: string) {
loading = true;
error = null;
const result = await composeApi.listDrafts(accountId);
if (result.error) {
error = result.error.message;
loading = false;
return;
}
drafts = result.data?.drafts || [];
loading = false;
},
async saveDraft() {
loading = true;
error = null;
if (currentDraft) {
// Update existing draft
const updateData: UpdateDraftDto = {
subject: composeForm.subject,
toAddresses: composeForm.toAddresses,
ccAddresses: composeForm.ccAddresses,
bccAddresses: composeForm.bccAddresses,
bodyHtml: composeForm.bodyHtml,
};
const result = await composeApi.updateDraft(currentDraft.id, updateData);
if (result.error) {
error = result.error.message;
loading = false;
return null;
}
if (result.data?.draft) {
currentDraft = result.data.draft;
drafts = drafts.map((d) => (d.id === currentDraft!.id ? result.data!.draft : d));
}
loading = false;
return result.data?.draft || null;
} else {
// Create new draft
const createData: CreateDraftDto = {
accountId: composeForm.accountId,
subject: composeForm.subject,
toAddresses: composeForm.toAddresses,
ccAddresses: composeForm.ccAddresses,
bccAddresses: composeForm.bccAddresses,
bodyHtml: composeForm.bodyHtml,
replyToEmailId: composeForm.replyToEmailId,
replyType: composeForm.replyType,
};
const result = await composeApi.createDraft(createData);
if (result.error) {
error = result.error.message;
loading = false;
return null;
}
if (result.data?.draft) {
currentDraft = result.data.draft;
drafts = [result.data.draft, ...drafts];
}
loading = false;
return result.data?.draft || null;
}
},
async deleteDraft(id: string) {
const result = await composeApi.deleteDraft(id);
if (result.error) {
error = result.error.message;
return false;
}
drafts = drafts.filter((d) => d.id !== id);
if (currentDraft?.id === id) {
currentDraft = null;
}
return true;
},
async openDraft(draft: Draft) {
currentDraft = draft;
composeForm = {
accountId: draft.accountId,
subject: draft.subject || '',
toAddresses: draft.toAddresses || [],
ccAddresses: draft.ccAddresses || [],
bccAddresses: draft.bccAddresses || [],
bodyHtml: draft.bodyHtml || '',
replyToEmailId: draft.replyToEmailId || undefined,
replyType: draft.replyType || undefined,
};
isComposeOpen = true;
},
// Send email
async send() {
if (composeForm.toAddresses.length === 0) {
error = 'Please add at least one recipient';
return false;
}
sending = true;
error = null;
let result;
if (currentDraft) {
// Save draft first, then send
await this.saveDraft();
result = await composeApi.sendDraft(currentDraft.id);
} else {
// Send directly
const sendData: SendEmailDto = {
accountId: composeForm.accountId,
subject: composeForm.subject,
toAddresses: composeForm.toAddresses,
ccAddresses: composeForm.ccAddresses,
bccAddresses: composeForm.bccAddresses,
bodyHtml: composeForm.bodyHtml,
replyToEmailId: composeForm.replyToEmailId,
replyType: composeForm.replyType,
};
result = await composeApi.send(sendData);
}
if (result.error) {
error = result.error.message;
sending = false;
return false;
}
// Remove draft from list if it was a draft
if (currentDraft) {
drafts = drafts.filter((d) => d.id !== currentDraft!.id);
}
// Close compose
this.closeCompose();
sending = false;
return true;
},
// Reply/Forward
async createReply(emailId: string) {
loading = true;
error = null;
const result = await composeApi.createReply(emailId);
if (result.error) {
error = result.error.message;
loading = false;
return;
}
if (result.data?.draft) {
await this.openDraft(result.data.draft);
drafts = [result.data.draft, ...drafts];
}
loading = false;
},
async createReplyAll(emailId: string) {
loading = true;
error = null;
const result = await composeApi.createReplyAll(emailId);
if (result.error) {
error = result.error.message;
loading = false;
return;
}
if (result.data?.draft) {
await this.openDraft(result.data.draft);
drafts = [result.data.draft, ...drafts];
}
loading = false;
},
async createForward(emailId: string) {
loading = true;
error = null;
const result = await composeApi.createForward(emailId);
if (result.error) {
error = result.error.message;
loading = false;
return;
}
if (result.data?.draft) {
await this.openDraft(result.data.draft);
drafts = [result.data.draft, ...drafts];
}
loading = false;
},
// Alias for drafts page - opens a draft by ID
async editDraft(draftId: string) {
const draft = drafts.find((d) => d.id === draftId);
if (draft) {
await this.openDraft(draft);
} else {
// Fetch draft if not in list
loading = true;
const result = await composeApi.getDraft(draftId);
loading = false;
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.draft) {
await this.openDraft(result.data.draft);
}
}
},
};

View file

@ -0,0 +1,337 @@
/**
* Emails Store - Manages emails using Svelte 5 runes
*/
import { emailsApi, type Email, type EmailFilters } from '$lib/api/emails';
import { foldersStore } from './folders.svelte';
let emails = $state<Email[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let total = $state(0);
let selectedEmailId = $state<string | null>(null);
let currentFilters = $state<EmailFilters>({});
export const emailsStore = {
get emails() {
return emails;
},
get loading() {
return loading;
},
get error() {
return error;
},
get total() {
return total;
},
get selectedEmailId() {
return selectedEmailId;
},
get selectedEmail() {
return emails.find((e) => e.id === selectedEmailId) || null;
},
get currentFilters() {
return currentFilters;
},
// Filtered getters
get unreadEmails() {
return emails.filter((e) => !e.isRead);
},
get starredEmails() {
return emails.filter((e) => e.isStarred);
},
setSelectedEmail(id: string | null) {
selectedEmailId = id;
},
async fetchEmails(filters: EmailFilters = {}) {
loading = true;
error = null;
currentFilters = filters;
const result = await emailsApi.list(filters);
if (result.error) {
error = result.error.message;
loading = false;
return;
}
emails = result.data?.emails || [];
total = result.data?.total || 0;
loading = false;
},
async loadMore() {
if (loading) return;
const offset = emails.length;
loading = true;
const result = await emailsApi.list({ ...currentFilters, offset });
if (result.error) {
error = result.error.message;
loading = false;
return;
}
if (result.data?.emails) {
emails = [...emails, ...result.data.emails];
}
loading = false;
},
async search(query: string, accountId?: string) {
loading = true;
error = null;
const result = await emailsApi.search(query, accountId);
if (result.error) {
error = result.error.message;
loading = false;
return [];
}
loading = false;
return result.data?.emails || [];
},
async getEmail(id: string) {
const result = await emailsApi.get(id);
if (result.error) {
error = result.error.message;
return null;
}
// Update email in list if exists
if (result.data?.email) {
const index = emails.findIndex((e) => e.id === id);
if (index >= 0) {
emails = [...emails.slice(0, index), result.data.email, ...emails.slice(index + 1)];
}
}
return result.data?.email || null;
},
async getThread(id: string) {
const result = await emailsApi.getThread(id);
return result.data?.emails || [];
},
async markAsRead(id: string) {
const email = emails.find((e) => e.id === id);
if (!email || email.isRead) return;
const result = await emailsApi.update(id, { isRead: true });
if (result.error) {
error = result.error.message;
return;
}
emails = emails.map((e) => (e.id === id ? { ...e, isRead: true } : e));
// Update folder unread count
if (email.folderId) {
foldersStore.updateUnreadCount(email.folderId, -1);
}
},
async markAsUnread(id: string) {
const email = emails.find((e) => e.id === id);
if (!email || !email.isRead) return;
const result = await emailsApi.update(id, { isRead: false });
if (result.error) {
error = result.error.message;
return;
}
emails = emails.map((e) => (e.id === id ? { ...e, isRead: false } : e));
// Update folder unread count
if (email.folderId) {
foldersStore.updateUnreadCount(email.folderId, 1);
}
},
async toggleStar(id: string) {
const email = emails.find((e) => e.id === id);
if (!email) return;
const result = await emailsApi.update(id, { isStarred: !email.isStarred });
if (result.error) {
error = result.error.message;
return;
}
emails = emails.map((e) => (e.id === id ? { ...e, isStarred: !e.isStarred } : e));
},
async moveToFolder(id: string, targetFolderId: string) {
const email = emails.find((e) => e.id === id);
if (!email) return;
const result = await emailsApi.move(id, targetFolderId);
if (result.error) {
error = result.error.message;
return;
}
// Update folder counts
if (email.folderId && !email.isRead) {
foldersStore.updateUnreadCount(email.folderId, -1);
foldersStore.updateUnreadCount(targetFolderId, 1);
}
// Remove from current list if viewing a specific folder
if (currentFilters.folderId) {
emails = emails.filter((e) => e.id !== id);
} else {
emails = emails.map((e) => (e.id === id ? { ...e, folderId: targetFolderId } : e));
}
},
async deleteEmail(id: string) {
const email = emails.find((e) => e.id === id);
if (!email) return;
const result = await emailsApi.delete(id);
if (result.error) {
error = result.error.message;
return;
}
// Update folder counts
if (email.folderId && !email.isRead) {
foldersStore.updateUnreadCount(email.folderId, -1);
}
emails = emails.filter((e) => e.id !== id);
if (selectedEmailId === id) {
selectedEmailId = null;
}
},
// Batch operations
async batchMarkAsRead(ids: string[]) {
const result = await emailsApi.batch({ operation: 'markRead', emailIds: ids });
if (result.error) {
error = result.error.message;
return;
}
emails = emails.map((e) => (ids.includes(e.id) ? { ...e, isRead: true } : e));
},
async batchMarkAsUnread(ids: string[]) {
const result = await emailsApi.batch({ operation: 'markUnread', emailIds: ids });
if (result.error) {
error = result.error.message;
return;
}
emails = emails.map((e) => (ids.includes(e.id) ? { ...e, isRead: false } : e));
},
async batchDelete(ids: string[]) {
const result = await emailsApi.batch({ operation: 'delete', emailIds: ids });
if (result.error) {
error = result.error.message;
return;
}
emails = emails.filter((e) => !ids.includes(e.id));
if (selectedEmailId && ids.includes(selectedEmailId)) {
selectedEmailId = null;
}
},
async batchMove(ids: string[], targetFolderId: string) {
const result = await emailsApi.batch({
operation: 'move',
emailIds: ids,
targetFolderId,
});
if (result.error) {
error = result.error.message;
return;
}
if (currentFilters.folderId) {
emails = emails.filter((e) => !ids.includes(e.id));
}
},
// AI Features
async summarize(id: string) {
const result = await emailsApi.summarize(id);
if (result.error) {
error = result.error.message;
return null;
}
// Update email with summary
if (result.data) {
emails = emails.map((e) => (e.id === id ? { ...e, aiSummary: result.data!.summary } : e));
}
return result.data;
},
async suggestReplies(id: string) {
const result = await emailsApi.suggestReplies(id);
if (result.error) {
error = result.error.message;
return null;
}
// Update email with suggestions
if (result.data) {
emails = emails.map((e) =>
e.id === id ? { ...e, aiSuggestedReplies: result.data!.replies } : e
);
}
return result.data;
},
async categorize(id: string) {
const result = await emailsApi.categorize(id);
if (result.error) {
error = result.error.message;
return null;
}
// Update email with category
if (result.data) {
emails = emails.map((e) =>
e.id === id
? { ...e, aiCategory: result.data!.category, aiPriority: result.data!.priority }
: e
);
}
return result.data;
},
};

View file

@ -0,0 +1,160 @@
/**
* Folders Store - Manages email folders using Svelte 5 runes
*/
import { foldersApi, type Folder } from '$lib/api/folders';
let folders = $state<Folder[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedFolderId = $state<string | null>(null);
export const foldersStore = {
get folders() {
return folders;
},
get loading() {
return loading;
},
get error() {
return error;
},
get selectedFolderId() {
return selectedFolderId;
},
get selectedFolder() {
return folders.find((f) => f.id === selectedFolderId) || null;
},
// Filtered getters
get systemFolders() {
return folders.filter((f) => f.isSystem && !f.isHidden);
},
get customFolders() {
return folders.filter((f) => !f.isSystem && !f.isHidden);
},
get inboxFolder() {
return folders.find((f) => f.type === 'inbox');
},
get sentFolder() {
return folders.find((f) => f.type === 'sent');
},
get draftsFolder() {
return folders.find((f) => f.type === 'drafts');
},
get trashFolder() {
return folders.find((f) => f.type === 'trash');
},
get spamFolder() {
return folders.find((f) => f.type === 'spam');
},
setSelectedFolder(id: string | null) {
selectedFolderId = id;
},
async fetchFolders(accountId?: string) {
loading = true;
error = null;
const result = await foldersApi.list(accountId);
if (result.error) {
error = result.error.message;
loading = false;
return;
}
folders = result.data?.folders || [];
// Auto-select inbox
if (!selectedFolderId) {
const inbox = folders.find((f) => f.type === 'inbox');
selectedFolderId = inbox?.id || folders[0]?.id || null;
}
loading = false;
},
async createFolder(data: Parameters<typeof foldersApi.create>[0]) {
loading = true;
error = null;
const result = await foldersApi.create(data);
if (result.error) {
error = result.error.message;
loading = false;
return null;
}
if (result.data?.folder) {
folders = [...folders, result.data.folder];
}
loading = false;
return result.data?.folder || null;
},
async updateFolder(id: string, data: Parameters<typeof foldersApi.update>[1]) {
const result = await foldersApi.update(id, data);
if (result.error) {
error = result.error.message;
return null;
}
if (result.data?.folder) {
folders = folders.map((f) => (f.id === id ? result.data!.folder : f));
}
return result.data?.folder || null;
},
async deleteFolder(id: string) {
const result = await foldersApi.delete(id);
if (result.error) {
error = result.error.message;
return false;
}
folders = folders.filter((f) => f.id !== id);
if (selectedFolderId === id) {
const inbox = folders.find((f) => f.type === 'inbox');
selectedFolderId = inbox?.id || folders[0]?.id || null;
}
return true;
},
async toggleHide(id: string) {
const result = await foldersApi.toggleHide(id);
if (result.error) {
error = result.error.message;
return null;
}
if (result.data?.folder) {
folders = folders.map((f) => (f.id === id ? result.data!.folder : f));
}
return result.data?.folder || null;
},
getFolderById(id: string) {
return folders.find((f) => f.id === id);
},
getFoldersByAccount(accountId: string) {
return folders.filter((f) => f.accountId === accountId);
},
updateUnreadCount(folderId: string, delta: number) {
folders = folders.map((f) =>
f.id === folderId ? { ...f, unreadCount: Math.max(0, f.unreadCount + delta) } : f
);
},
};

View file

@ -0,0 +1,176 @@
/**
* Labels Store - Manages custom labels using Svelte 5 runes
*/
import { labelsApi, type Label, type CreateLabelDto, type UpdateLabelDto } from '$lib/api/labels';
let labels = $state<Label[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
// Email-label associations cache
let emailLabels = $state<Map<string, Label[]>>(new Map());
export const labelsStore = {
get labels() {
return labels;
},
get loading() {
return loading;
},
get error() {
return error;
},
getEmailLabels(emailId: string) {
return emailLabels.get(emailId) || [];
},
async fetchLabels(accountId?: string) {
loading = true;
error = null;
const result = await labelsApi.list(accountId);
if (result.error) {
error = result.error.message;
loading = false;
return;
}
labels = result.data?.labels || [];
loading = false;
},
async createLabel(data: CreateLabelDto) {
loading = true;
error = null;
const result = await labelsApi.create(data);
if (result.error) {
error = result.error.message;
loading = false;
return null;
}
if (result.data?.label) {
labels = [...labels, result.data.label];
}
loading = false;
return result.data?.label || null;
},
async updateLabel(id: string, data: UpdateLabelDto) {
const result = await labelsApi.update(id, data);
if (result.error) {
error = result.error.message;
return null;
}
if (result.data?.label) {
labels = labels.map((l) => (l.id === id ? result.data!.label : l));
}
return result.data?.label || null;
},
async deleteLabel(id: string) {
const result = await labelsApi.delete(id);
if (result.error) {
error = result.error.message;
return false;
}
labels = labels.filter((l) => l.id !== id);
// Remove from email associations
const newEmailLabels = new Map(emailLabels);
for (const [emailId, lbls] of newEmailLabels) {
newEmailLabels.set(
emailId,
lbls.filter((l) => l.id !== id)
);
}
emailLabels = newEmailLabels;
return true;
},
async fetchEmailLabels(emailId: string) {
const result = await labelsApi.getEmailLabels(emailId);
if (result.error) {
error = result.error.message;
return [];
}
const lbls = result.data?.labels || [];
emailLabels = new Map(emailLabels).set(emailId, lbls);
return lbls;
},
async addLabelsToEmail(emailId: string, labelIds: string[]) {
const result = await labelsApi.addToEmail(emailId, labelIds);
if (result.error) {
error = result.error.message;
return false;
}
// Update local cache
const currentLabels = emailLabels.get(emailId) || [];
const newLabels = labels.filter((l) => labelIds.includes(l.id));
const combined = [
...currentLabels,
...newLabels.filter((l) => !currentLabels.some((c) => c.id === l.id)),
];
emailLabels = new Map(emailLabels).set(emailId, combined);
return true;
},
async removeLabelsFromEmail(emailId: string, labelIds: string[]) {
const result = await labelsApi.removeFromEmail(emailId, labelIds);
if (result.error) {
error = result.error.message;
return false;
}
// Update local cache
const currentLabels = emailLabels.get(emailId) || [];
emailLabels = new Map(emailLabels).set(
emailId,
currentLabels.filter((l) => !labelIds.includes(l.id))
);
return true;
},
async setEmailLabels(emailId: string, labelIds: string[]) {
const result = await labelsApi.setEmailLabels(emailId, labelIds);
if (result.error) {
error = result.error.message;
return false;
}
// Update local cache
const newLabels = labels.filter((l) => labelIds.includes(l.id));
emailLabels = new Map(emailLabels).set(emailId, newLabels);
return true;
},
getLabelById(id: string) {
return labels.find((l) => l.id === id);
},
getLabelsByAccount(accountId: string) {
return labels.filter((l) => l.accountId === accountId || l.accountId === null);
},
};

View file

@ -0,0 +1,4 @@
import { writable } from 'svelte/store';
export const isSidebarMode = writable(false);
export const isNavCollapsed = writable(false);

View file

@ -0,0 +1,6 @@
import { createTheme, type ThemeStore } from '@manacore/shared-theme';
export const theme: ThemeStore = createTheme({
storagePrefix: 'mail',
variants: ['default', 'ocean', 'blue', 'purple', 'green', 'orange'],
});

View file

@ -0,0 +1,51 @@
/**
* User Settings Store for Mail
* Manages user preferences and settings
*/
interface UserSettings {
nav: {
desktopPosition: 'left' | 'center' | 'right';
};
}
const defaultSettings: UserSettings = {
nav: {
desktopPosition: 'center',
},
};
let settings = $state<UserSettings>({ ...defaultSettings });
let isLoaded = $state(false);
export const userSettings = {
get nav() {
return settings.nav;
},
get isLoaded() {
return isLoaded;
},
async load() {
if (typeof window === 'undefined') return;
// Load from localStorage
const saved = localStorage.getItem('mail-user-settings');
if (saved) {
try {
const parsed = JSON.parse(saved);
settings = { ...defaultSettings, ...parsed };
} catch {
// Ignore parse errors
}
}
isLoaded = true;
},
update(updates: Partial<UserSettings>) {
settings = { ...settings, ...updates };
if (typeof localStorage !== 'undefined') {
localStorage.setItem('mail-user-settings', JSON.stringify(settings));
}
},
};

View file

@ -0,0 +1,249 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { locale } from 'svelte-i18n';
import { PillNavigation } from '@manacore/shared-ui';
import type { PillNavItem, PillDropdownItem } from '@manacore/shared-ui';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { accountsStore } from '$lib/stores/accounts.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
import { theme } from '$lib/stores/theme';
import {
isSidebarMode as sidebarModeStore,
isNavCollapsed as collapsedStore,
} from '$lib/stores/navigation';
import { userSettings } from '$lib/stores/user-settings.svelte';
let { children } = $props();
let isSidebarMode = $state(false);
let isCollapsed = $state(false);
// App switcher items
const appItems = getPillAppItems('mail');
// Use theme store's isDark directly
let isDark = $derived(theme.isDark);
// Theme variant dropdown items
let themeVariantItems = $derived<PillDropdownItem[]>([
...theme.variants.map((variant) => ({
id: variant,
label: THEME_DEFINITIONS[variant].label,
icon: THEME_DEFINITIONS[variant].icon,
onClick: () => theme.setVariant(variant),
active: theme.variant === variant,
})),
{
id: 'all-themes',
label: 'Alle Themes',
icon: 'palette',
onClick: () => goto('/themes'),
active: false,
},
]);
// Current theme variant label
let currentThemeVariantLabel = $derived(THEME_DEFINITIONS[theme.variant].label);
// Language selector items
let currentLocale = $derived($locale || 'de');
function handleLocaleChange(newLocale: string) {
setLocale(newLocale as any);
}
let languageItems = $derived(
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
);
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// User email for user dropdown
let userEmail = $derived(authStore.user?.email || 'Menü');
// Navigation items for Mail
const navItems: PillNavItem[] = [
{ href: '/', label: 'Inbox', icon: 'inbox' },
{ href: '/sent', label: 'Gesendet', icon: 'send' },
{ href: '/drafts', label: 'Entwürfe', icon: 'file' },
{ href: '/starred', label: 'Markiert', icon: 'star' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{ href: '/feedback', label: 'Feedback', icon: 'chat' },
];
// Navigation shortcuts (Ctrl+1-6)
const navRoutes = navItems.map((item) => item.href);
function handleKeydown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
return;
}
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
const num = parseInt(event.key);
if (num >= 1 && num <= navRoutes.length) {
event.preventDefault();
const route = navRoutes[num - 1];
if (route) {
goto(route);
}
}
}
}
function handleModeChange(isSidebar: boolean) {
isSidebarMode = isSidebar;
sidebarModeStore.set(isSidebar);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('mail-nav-sidebar', String(isSidebar));
}
}
function handleCollapsedChange(collapsed: boolean) {
isCollapsed = collapsed;
collapsedStore.set(collapsed);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('mail-nav-collapsed', String(collapsed));
}
}
function handleToggleTheme() {
theme.toggleMode();
}
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
theme.setMode(mode);
}
async function handleLogout() {
await authStore.signOut();
goto('/login');
}
onMount(async () => {
// Redirect to login if not authenticated
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
// Load user settings
await userSettings.load();
// Load data
await accountsStore.fetchAccounts();
if (accountsStore.selectedAccountId) {
await foldersStore.fetchFolders(accountsStore.selectedAccountId);
}
// Initialize sidebar mode from localStorage
const savedSidebar = localStorage.getItem('mail-nav-sidebar');
if (savedSidebar === 'true') {
isSidebarMode = true;
sidebarModeStore.set(true);
}
// Initialize collapsed state from localStorage
const savedCollapsed = localStorage.getItem('mail-nav-collapsed');
if (savedCollapsed === 'true') {
isCollapsed = true;
collapsedStore.set(true);
}
});
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="layout-container">
<PillNavigation
items={navItems}
currentPath={$page.url.pathname}
appName="Mail"
homeRoute="/"
onToggleTheme={handleToggleTheme}
{isDark}
{isSidebarMode}
onModeChange={handleModeChange}
{isCollapsed}
onCollapsedChange={handleCollapsedChange}
desktopPosition={userSettings.nav.desktopPosition}
showThemeToggle={true}
showThemeVariants={true}
{themeVariantItems}
{currentThemeVariantLabel}
themeMode={theme.mode}
onThemeModeChange={handleThemeModeChange}
showLanguageSwitcher={true}
{languageItems}
{currentLanguageLabel}
showLogout={authStore.isAuthenticated}
onLogout={handleLogout}
loginHref="/login"
primaryColor="#6366f1"
showAppSwitcher={true}
{appItems}
{userEmail}
settingsHref="/settings"
manaHref="/mana"
profileHref="/profile"
allAppsHref="/apps"
/>
<main
class="main-content bg-background"
class:sidebar-mode={isSidebarMode && !isCollapsed}
class:floating-mode={!isSidebarMode && !isCollapsed}
>
<div class="content-wrapper">
{@render children()}
</div>
</main>
</div>
<style>
.layout-container {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.main-content {
transition: all 300ms ease;
position: relative;
z-index: 0;
}
.main-content.floating-mode {
padding-top: 70px;
}
.main-content.sidebar-mode {
padding-left: 180px;
}
.content-wrapper {
max-width: 100%;
margin-left: auto;
margin-right: auto;
padding: 1rem;
position: relative;
z-index: 0;
}
@media (min-width: 640px) {
.content-wrapper {
padding: 1.5rem;
}
}
@media (min-width: 1024px) {
.content-wrapper {
padding: 2rem;
}
}
</style>

View file

@ -0,0 +1,108 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { accountsStore } from '$lib/stores/accounts.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
import { emailsStore } from '$lib/stores/emails.svelte';
import { composeStore } from '$lib/stores/compose.svelte';
import EmailList from '$lib/components/EmailList.svelte';
import EmailDetail from '$lib/components/EmailDetail.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import ComposeModal from '$lib/components/ComposeModal.svelte';
let showDetail = $state(false);
// Redirect if not authenticated
$effect(() => {
if (authStore.initialized && !authStore.isAuthenticated) {
goto('/login');
}
});
// Fetch inbox emails when folder changes
$effect(() => {
const inbox = foldersStore.inboxFolder;
if (inbox && accountsStore.selectedAccountId) {
emailsStore.fetchEmails({
accountId: accountsStore.selectedAccountId,
folderId: inbox.id,
});
}
});
function handleEmailSelect(emailId: string) {
emailsStore.setSelectedEmail(emailId);
emailsStore.markAsRead(emailId);
showDetail = true;
}
function handleCloseDetail() {
showDetail = false;
emailsStore.setSelectedEmail(null);
}
function handleCompose() {
if (accountsStore.selectedAccountId) {
composeStore.openCompose(accountsStore.selectedAccountId);
}
}
</script>
<svelte:head>
<title>Inbox | Mail</title>
</svelte:head>
{#if authStore.isAuthenticated}
<div class="flex h-[calc(100vh-100px)] gap-4">
<!-- Sidebar -->
<Sidebar onCompose={handleCompose} />
<!-- Email List -->
<div class="flex-1 flex flex-col min-w-0">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">Inbox</h1>
<div class="flex items-center gap-2">
{#if foldersStore.inboxFolder}
<span class="text-sm text-muted-foreground">
{foldersStore.inboxFolder.unreadCount} unread
</span>
{/if}
</div>
</div>
<div class="flex-1 flex gap-4 min-h-0">
<div class="flex-1 overflow-hidden rounded-lg border border-border bg-surface">
<EmailList
emails={emailsStore.emails}
loading={emailsStore.loading}
selectedId={emailsStore.selectedEmailId}
onSelect={handleEmailSelect}
/>
</div>
<!-- Email Detail Panel -->
{#if showDetail && emailsStore.selectedEmail}
<div class="w-[450px] flex-shrink-0 overflow-hidden rounded-lg border border-border">
<EmailDetail
email={emailsStore.selectedEmail}
onClose={handleCloseDetail}
onReply={() => composeStore.createReply(emailsStore.selectedEmailId!)}
onReplyAll={() => composeStore.createReplyAll(emailsStore.selectedEmailId!)}
onForward={() => composeStore.createForward(emailsStore.selectedEmailId!)}
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Compose Modal -->
{#if composeStore.isComposeOpen}
<ComposeModal />
{/if}
{:else}
<div class="flex items-center justify-center h-96">
<p class="text-muted-foreground">Loading...</p>
</div>
{/if}

View file

@ -0,0 +1,149 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { accountsStore } from '$lib/stores/accounts.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
import { composeStore } from '$lib/stores/compose.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import ComposeModal from '$lib/components/ComposeModal.svelte';
// Redirect if not authenticated
$effect(() => {
if (authStore.initialized && !authStore.isAuthenticated) {
goto('/login');
}
});
// Fetch drafts when account changes
$effect(() => {
if (accountsStore.selectedAccountId) {
composeStore.fetchDrafts(accountsStore.selectedAccountId);
}
});
function handleCompose() {
if (accountsStore.selectedAccountId) {
composeStore.openCompose(accountsStore.selectedAccountId);
}
}
function handleDraftClick(draftId: string) {
composeStore.editDraft(draftId);
}
function handleDeleteDraft(draftId: string, event: Event) {
event.stopPropagation();
composeStore.deleteDraft(draftId);
}
function formatDate(dateString: string | null): string {
if (!dateString) return '';
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) {
return date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
} else if (days === 1) {
return 'Yesterday';
} else if (days < 7) {
return date.toLocaleDateString('en-US', { weekday: 'short' });
} else {
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
}
</script>
<svelte:head>
<title>Drafts | Mail</title>
</svelte:head>
{#if authStore.isAuthenticated}
<div class="flex h-[calc(100vh-100px)] gap-4">
<!-- Sidebar -->
<Sidebar onCompose={handleCompose} />
<!-- Drafts List -->
<div class="flex-1 flex flex-col min-w-0">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">Drafts</h1>
<span class="text-sm text-muted-foreground">
{composeStore.drafts.length} draft{composeStore.drafts.length !== 1 ? 's' : ''}
</span>
</div>
<div class="flex-1 overflow-hidden rounded-lg border border-border bg-surface">
<div class="h-full overflow-y-auto scrollbar-thin">
{#if composeStore.loading && composeStore.drafts.length === 0}
<div class="flex items-center justify-center h-32">
<div class="text-muted-foreground">Loading drafts...</div>
</div>
{:else if composeStore.drafts.length === 0}
<div class="flex items-center justify-center h-32">
<div class="text-center">
<div class="text-4xl mb-2">📝</div>
<div class="text-muted-foreground">No drafts</div>
</div>
</div>
{:else}
{#each composeStore.drafts as draft}
<div
class="email-row cursor-pointer"
onclick={() => handleDraftClick(draft.id)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && handleDraftClick(draft.id)}
>
<!-- Draft Icon -->
<span class="mr-3 text-muted-foreground">📝</span>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2">
<span class="font-medium text-sm truncate">
{#if draft.toAddresses && draft.toAddresses.length > 0}
To: {draft.toAddresses.map((a) => a.name || a.email).join(', ')}
{:else}
<span class="text-muted-foreground italic">No recipients</span>
{/if}
</span>
</div>
<div class="text-sm font-medium truncate">
{draft.subject || '(No Subject)'}
</div>
<div class="text-xs text-muted-foreground truncate">
{draft.bodyHtml?.replace(/<[^>]*>/g, '').slice(0, 100) || 'No content'}
</div>
</div>
<!-- Delete Button -->
<button
class="btn btn-ghost btn-sm text-destructive opacity-0 group-hover:opacity-100 transition-opacity"
onclick={(e) => handleDeleteDraft(draft.id, e)}
title="Delete draft"
>
🗑️
</button>
<!-- Date -->
<div class="text-xs text-muted-foreground ml-2">
{formatDate(draft.updatedAt)}
</div>
</div>
{/each}
{/if}
</div>
</div>
</div>
</div>
<!-- Compose Modal -->
{#if composeStore.isComposeOpen}
<ComposeModal />
{/if}
{:else}
<div class="flex items-center justify-center h-96">
<p class="text-muted-foreground">Loading...</p>
</div>
{/if}

View file

@ -0,0 +1,20 @@
<script lang="ts">
import { FeedbackPage } from '@manacore/shared-feedback-ui';
import { createFeedbackService } from '@manacore/shared-feedback-service';
import { authStore } from '$lib/stores/auth.svelte';
const feedbackService = createFeedbackService({
appName: 'mail',
apiUrl: 'http://localhost:3001', // Mana Core API
});
async function handleSubmit(data: { type: string; message: string; email?: string }) {
const token = await authStore.getAccessToken();
return feedbackService.submit({
...data,
token: token || undefined,
});
}
</script>
<FeedbackPage appName="Mail" onSubmit={handleSubmit} userEmail={authStore.user?.email} />

View file

@ -0,0 +1,101 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { accountsStore } from '$lib/stores/accounts.svelte';
import { foldersStore } from '$lib/stores/folders.svelte';
import { emailsStore } from '$lib/stores/emails.svelte';
import { composeStore } from '$lib/stores/compose.svelte';
import EmailList from '$lib/components/EmailList.svelte';
import EmailDetail from '$lib/components/EmailDetail.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import ComposeModal from '$lib/components/ComposeModal.svelte';
let showDetail = $state(false);
// Redirect if not authenticated
$effect(() => {
if (authStore.initialized && !authStore.isAuthenticated) {
goto('/login');
}
});
// Fetch sent emails when folder changes
$effect(() => {
const sentFolder = foldersStore.sentFolder;
if (sentFolder && accountsStore.selectedAccountId) {
emailsStore.fetchEmails({
accountId: accountsStore.selectedAccountId,
folderId: sentFolder.id,
});
}
});
function handleEmailSelect(emailId: string) {
emailsStore.setSelectedEmail(emailId);
emailsStore.markAsRead(emailId);
showDetail = true;
}
function handleCloseDetail() {
showDetail = false;
emailsStore.setSelectedEmail(null);
}
function handleCompose() {
if (accountsStore.selectedAccountId) {
composeStore.openCompose(accountsStore.selectedAccountId);
}
}
</script>
<svelte:head>
<title>Sent | Mail</title>
</svelte:head>
{#if authStore.isAuthenticated}
<div class="flex h-[calc(100vh-100px)] gap-4">
<!-- Sidebar -->
<Sidebar onCompose={handleCompose} />
<!-- Email List -->
<div class="flex-1 flex flex-col min-w-0">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">Sent</h1>
</div>
<div class="flex-1 flex gap-4 min-h-0">
<div class="flex-1 overflow-hidden rounded-lg border border-border bg-surface">
<EmailList
emails={emailsStore.emails}
loading={emailsStore.loading}
selectedId={emailsStore.selectedEmailId}
onSelect={handleEmailSelect}
/>
</div>
<!-- Email Detail Panel -->
{#if showDetail && emailsStore.selectedEmail}
<div class="w-[450px] flex-shrink-0 overflow-hidden rounded-lg border border-border">
<EmailDetail
email={emailsStore.selectedEmail}
onClose={handleCloseDetail}
onReply={() => composeStore.createReply(emailsStore.selectedEmailId!)}
onReplyAll={() => composeStore.createReplyAll(emailsStore.selectedEmailId!)}
onForward={() => composeStore.createForward(emailsStore.selectedEmailId!)}
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Compose Modal -->
{#if composeStore.isComposeOpen}
<ComposeModal />
{/if}
{:else}
<div class="flex items-center justify-center h-96">
<p class="text-muted-foreground">Loading...</p>
</div>
{/if}

View file

@ -0,0 +1,342 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { accountsStore } from '$lib/stores/accounts.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import ComposeModal from '$lib/components/ComposeModal.svelte';
import { composeStore } from '$lib/stores/compose.svelte';
let showAddAccount = $state(false);
let newAccountForm = $state({
name: '',
email: '',
imapHost: '',
imapPort: 993,
smtpHost: '',
smtpPort: 587,
password: '',
});
let testing = $state(false);
let testResult = $state<{ success: boolean; message: string } | null>(null);
// Redirect if not authenticated
$effect(() => {
if (authStore.initialized && !authStore.isAuthenticated) {
goto('/login');
}
});
function handleCompose() {
if (accountsStore.selectedAccountId) {
composeStore.openCompose(accountsStore.selectedAccountId);
}
}
async function handleAddImapAccount() {
const success = await accountsStore.addImapAccount(newAccountForm);
if (success) {
showAddAccount = false;
newAccountForm = {
name: '',
email: '',
imapHost: '',
imapPort: 993,
smtpHost: '',
smtpPort: 587,
password: '',
};
}
}
async function handleTestConnection() {
testing = true;
testResult = null;
const result = await accountsStore.testConnection(newAccountForm);
testResult = result;
testing = false;
}
async function handleRemoveAccount(accountId: string) {
if (confirm('Are you sure you want to remove this account?')) {
await accountsStore.removeAccount(accountId);
}
}
async function handleSetDefault(accountId: string) {
await accountsStore.setDefaultAccount(accountId);
}
async function handleSyncAccount(accountId: string) {
await accountsStore.syncAccount(accountId);
}
function handleGoogleOAuth() {
accountsStore.initiateGoogleOAuth();
}
function handleMicrosoftOAuth() {
accountsStore.initiateMicrosoftOAuth();
}
</script>
<svelte:head>
<title>Settings | Mail</title>
</svelte:head>
{#if authStore.isAuthenticated}
<div class="flex h-[calc(100vh-100px)] gap-4">
<!-- Sidebar -->
<Sidebar onCompose={handleCompose} />
<!-- Settings Content -->
<div class="flex-1 flex flex-col min-w-0 overflow-y-auto">
<h1 class="text-2xl font-bold mb-6">Settings</h1>
<!-- Email Accounts Section -->
<div class="bg-surface rounded-lg border border-border p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">Email Accounts</h2>
<button class="btn btn-primary" onclick={() => (showAddAccount = true)}>
+ Add Account
</button>
</div>
{#if accountsStore.accounts.length === 0}
<div class="text-center py-8">
<div class="text-4xl mb-2">📧</div>
<p class="text-muted-foreground">No email accounts connected</p>
<p class="text-sm text-muted-foreground mt-1">
Add an account to start receiving emails
</p>
</div>
{:else}
<div class="space-y-3">
{#each accountsStore.accounts as account}
<div class="flex items-center justify-between p-4 rounded-lg bg-muted/30">
<div class="flex items-center gap-3">
<div
class="w-10 h-10 rounded-full flex items-center justify-center text-white font-semibold"
style="background-color: hsl(217, 91%, 60%)"
>
{account.name[0].toUpperCase()}
</div>
<div>
<div class="font-medium flex items-center gap-2">
{account.name}
{#if account.isDefault}
<span class="text-xs bg-primary/20 text-primary px-2 py-0.5 rounded">
Default
</span>
{/if}
</div>
<div class="text-sm text-muted-foreground">{account.email}</div>
<div class="text-xs text-muted-foreground capitalize">{account.provider}</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
class="btn btn-ghost btn-sm"
onclick={() => handleSyncAccount(account.id)}
disabled={accountsStore.loading}
title="Sync now"
>
🔄
</button>
{#if !account.isDefault}
<button
class="btn btn-ghost btn-sm"
onclick={() => handleSetDefault(account.id)}
title="Set as default"
>
</button>
{/if}
<button
class="btn btn-ghost btn-sm text-destructive"
onclick={() => handleRemoveAccount(account.id)}
title="Remove account"
>
🗑️
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Add Account Modal -->
{#if showAddAccount}
<div class="fixed inset-0 bg-black/50 z-40 flex items-center justify-center">
<div class="bg-surface rounded-xl shadow-2xl w-[500px] max-h-[80vh] overflow-y-auto">
<div class="p-6 border-b border-border">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold">Add Email Account</h3>
<button class="btn btn-ghost btn-icon" onclick={() => (showAddAccount = false)}>
</button>
</div>
</div>
<div class="p-6">
<!-- OAuth Options -->
<div class="mb-6">
<p class="text-sm text-muted-foreground mb-3">Connect with OAuth:</p>
<div class="flex gap-3">
<button class="btn btn-secondary flex-1" onclick={handleGoogleOAuth}>
<span class="mr-2">📧</span> Google
</button>
<button class="btn btn-secondary flex-1" onclick={handleMicrosoftOAuth}>
<span class="mr-2">📧</span> Microsoft
</button>
</div>
</div>
<div class="relative my-6">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-border"></div>
</div>
<div class="relative flex justify-center text-xs uppercase">
<span class="bg-surface px-2 text-muted-foreground">Or add manually</span>
</div>
</div>
<!-- Manual IMAP/SMTP Form -->
<div class="space-y-4">
<div>
<label class="text-sm font-medium mb-1 block">Account Name</label>
<input
type="text"
class="input w-full"
bind:value={newAccountForm.name}
placeholder="Work Email"
/>
</div>
<div>
<label class="text-sm font-medium mb-1 block">Email Address</label>
<input
type="email"
class="input w-full"
bind:value={newAccountForm.email}
placeholder="you@example.com"
/>
</div>
<div>
<label class="text-sm font-medium mb-1 block">Password</label>
<input
type="password"
class="input w-full"
bind:value={newAccountForm.password}
placeholder="••••••••"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium mb-1 block">IMAP Host</label>
<input
type="text"
class="input w-full"
bind:value={newAccountForm.imapHost}
placeholder="imap.example.com"
/>
</div>
<div>
<label class="text-sm font-medium mb-1 block">IMAP Port</label>
<input
type="number"
class="input w-full"
bind:value={newAccountForm.imapPort}
placeholder="993"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium mb-1 block">SMTP Host</label>
<input
type="text"
class="input w-full"
bind:value={newAccountForm.smtpHost}
placeholder="smtp.example.com"
/>
</div>
<div>
<label class="text-sm font-medium mb-1 block">SMTP Port</label>
<input
type="number"
class="input w-full"
bind:value={newAccountForm.smtpPort}
placeholder="587"
/>
</div>
</div>
{#if testResult}
<div
class="p-3 rounded-lg text-sm {testResult.success
? 'bg-green-500/10 text-green-600'
: 'bg-destructive/10 text-destructive'}"
>
{testResult.message}
</div>
{/if}
<div class="flex gap-3 pt-4">
<button
class="btn btn-secondary flex-1"
onclick={handleTestConnection}
disabled={testing}
>
{testing ? 'Testing...' : 'Test Connection'}
</button>
<button
class="btn btn-primary flex-1"
onclick={handleAddImapAccount}
disabled={accountsStore.loading}
>
{accountsStore.loading ? 'Adding...' : 'Add Account'}
</button>
</div>
</div>
</div>
</div>
</div>
{/if}
<!-- User Info Section -->
<div class="bg-surface rounded-lg border border-border p-6">
<h2 class="text-lg font-semibold mb-4">User Information</h2>
<div class="space-y-2">
<div class="flex justify-between">
<span class="text-muted-foreground">Email:</span>
<span>{authStore.user?.email}</span>
</div>
<div class="flex justify-between">
<span class="text-muted-foreground">User ID:</span>
<span class="text-sm font-mono">{authStore.user?.id}</span>
</div>
</div>
<button
class="btn btn-destructive mt-6 w-full"
onclick={() => authStore.signOut().then(() => goto('/login'))}
>
Sign Out
</button>
</div>
</div>
</div>
<!-- Compose Modal -->
{#if composeStore.isComposeOpen}
<ComposeModal />
{/if}
{:else}
<div class="flex items-center justify-center h-96">
<p class="text-muted-foreground">Loading...</p>
</div>
{/if}

View file

@ -0,0 +1,98 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { accountsStore } from '$lib/stores/accounts.svelte';
import { emailsStore } from '$lib/stores/emails.svelte';
import { composeStore } from '$lib/stores/compose.svelte';
import EmailList from '$lib/components/EmailList.svelte';
import EmailDetail from '$lib/components/EmailDetail.svelte';
import Sidebar from '$lib/components/Sidebar.svelte';
import ComposeModal from '$lib/components/ComposeModal.svelte';
let showDetail = $state(false);
// Redirect if not authenticated
$effect(() => {
if (authStore.initialized && !authStore.isAuthenticated) {
goto('/login');
}
});
// Fetch starred emails
$effect(() => {
if (accountsStore.selectedAccountId) {
emailsStore.fetchEmails({
accountId: accountsStore.selectedAccountId,
isStarred: true,
});
}
});
function handleEmailSelect(emailId: string) {
emailsStore.setSelectedEmail(emailId);
emailsStore.markAsRead(emailId);
showDetail = true;
}
function handleCloseDetail() {
showDetail = false;
emailsStore.setSelectedEmail(null);
}
function handleCompose() {
if (accountsStore.selectedAccountId) {
composeStore.openCompose(accountsStore.selectedAccountId);
}
}
</script>
<svelte:head>
<title>Starred | Mail</title>
</svelte:head>
{#if authStore.isAuthenticated}
<div class="flex h-[calc(100vh-100px)] gap-4">
<!-- Sidebar -->
<Sidebar onCompose={handleCompose} />
<!-- Email List -->
<div class="flex-1 flex flex-col min-w-0">
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">⭐ Starred</h1>
</div>
<div class="flex-1 flex gap-4 min-h-0">
<div class="flex-1 overflow-hidden rounded-lg border border-border bg-surface">
<EmailList
emails={emailsStore.emails}
loading={emailsStore.loading}
selectedId={emailsStore.selectedEmailId}
onSelect={handleEmailSelect}
/>
</div>
<!-- Email Detail Panel -->
{#if showDetail && emailsStore.selectedEmail}
<div class="w-[450px] flex-shrink-0 overflow-hidden rounded-lg border border-border">
<EmailDetail
email={emailsStore.selectedEmail}
onClose={handleCloseDetail}
onReply={() => composeStore.createReply(emailsStore.selectedEmailId!)}
onReplyAll={() => composeStore.createReplyAll(emailsStore.selectedEmailId!)}
onForward={() => composeStore.createForward(emailsStore.selectedEmailId!)}
/>
</div>
{/if}
</div>
</div>
</div>
<!-- Compose Modal -->
{#if composeStore.isComposeOpen}
<ComposeModal />
{/if}
{:else}
<div class="flex items-center justify-center h-96">
<p class="text-muted-foreground">Loading...</p>
</div>
{/if}

View file

@ -0,0 +1,37 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
import { MailLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const translations = {
titleForm: 'Reset Password',
titleSuccess: 'Email Sent',
description: 'Enter your email to receive a password reset link',
emailPlaceholder: 'you@example.com',
sendResetLinkButton: 'Send Reset Link',
sending: 'Sending...',
backToLogin: 'Back to sign in',
successMessage: 'Check your email for a password reset link',
};
async function handleForgotPassword(email: string) {
return authStore.resetPassword(email);
}
</script>
<svelte:head>
<title>{translations.titleForm} | Mail</title>
</svelte:head>
<ForgotPasswordPage
appName="Mail"
logo={MailLogo}
primaryColor="#6366f1"
onForgotPassword={handleForgotPassword}
{goto}
loginPath="/login"
lightBackground="#e0e7ff"
darkBackground="#1e1b4b"
{translations}
/>

View file

@ -0,0 +1,47 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { LoginPage } from '@manacore/shared-auth-ui';
import { MailLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/');
const translations = {
title: 'Sign In',
subtitle: 'Enter your credentials to access your email',
emailPlaceholder: 'you@example.com',
passwordPlaceholder: 'Enter your password',
signInButton: 'Sign In',
signingIn: 'Signing in...',
success: 'Success!',
noAccount: "Don't have an account?",
createAccount: 'Sign up',
forgotPassword: 'Forgot password?',
orDivider: 'Or continue with',
};
async function handleSignIn(email: string, password: string) {
return authStore.signIn(email, password);
}
</script>
<svelte:head>
<title>{translations.title} | Mail</title>
</svelte:head>
<LoginPage
appName="Mail"
logo={MailLogo}
primaryColor="#6366f1"
onSignIn={handleSignIn}
{goto}
enableGoogle={false}
enableApple={false}
successRedirect={redirectTo}
registerPath="/register"
forgotPasswordPath="/forgot-password"
lightBackground="#e0e7ff"
darkBackground="#1e1b4b"
{translations}
/>

View file

@ -0,0 +1,38 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { RegisterPage } from '@manacore/shared-auth-ui';
import { MailLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
const translations = {
title: 'Create Account',
emailPlaceholder: 'you@example.com',
passwordPlaceholder: 'Create a password',
confirmPasswordPlaceholder: 'Confirm your password',
createAccountButton: 'Create Account',
creatingAccount: 'Creating account...',
backToLogin: 'Already have an account? Sign in',
passwordRequirements: 'Password must be at least 8 characters',
};
async function handleSignUp(email: string, password: string) {
return authStore.signUp(email, password);
}
</script>
<svelte:head>
<title>{translations.title} | Mail</title>
</svelte:head>
<RegisterPage
appName="Mail"
logo={MailLogo}
primaryColor="#6366f1"
onSignUp={handleSignUp}
{goto}
successRedirect="/"
loginPath="/login"
lightBackground="#e0e7ff"
darkBackground="#1e1b4b"
{translations}
/>

View file

@ -0,0 +1,36 @@
<script lang="ts">
import '../app.css';
import '$lib/i18n';
import { onMount } from 'svelte';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
let { children } = $props();
let loading = $state(true);
onMount(async () => {
// Initialize theme
theme.initialize();
// Initialize auth
await authStore.initialize();
loading = false;
});
</script>
{#if loading}
<div class="flex min-h-screen items-center justify-center bg-background">
<div class="text-center">
<div
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
<p class="text-muted-foreground">Laden...</p>
</div>
</div>
{:else}
<div class="min-h-screen bg-background text-foreground">
{@render children()}
</div>
{/if}