mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-20 17:29:24 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
394
apps-archived/mail/apps/web/src/app.css
Normal file
394
apps-archived/mail/apps/web/src/app.css
Normal 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);
|
||||
}
|
||||
}
|
||||
13
apps-archived/mail/apps/web/src/app.html
Normal file
13
apps-archived/mail/apps/web/src/app.html
Normal 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>
|
||||
99
apps-archived/mail/apps/web/src/lib/api/accounts.ts
Normal file
99
apps-archived/mail/apps/web/src/lib/api/accounts.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
};
|
||||
65
apps-archived/mail/apps/web/src/lib/api/client.ts
Normal file
65
apps-archived/mail/apps/web/src/lib/api/client.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
119
apps-archived/mail/apps/web/src/lib/api/compose.ts
Normal file
119
apps-archived/mail/apps/web/src/lib/api/compose.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
};
|
||||
140
apps-archived/mail/apps/web/src/lib/api/emails.ts
Normal file
140
apps-archived/mail/apps/web/src/lib/api/emails.ts
Normal 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',
|
||||
}
|
||||
);
|
||||
},
|
||||
};
|
||||
68
apps-archived/mail/apps/web/src/lib/api/folders.ts
Normal file
68
apps-archived/mail/apps/web/src/lib/api/folders.ts
Normal 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',
|
||||
});
|
||||
},
|
||||
};
|
||||
78
apps-archived/mail/apps/web/src/lib/api/labels.ts
Normal file
78
apps-archived/mail/apps/web/src/lib/api/labels.ts
Normal 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 },
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
158
apps-archived/mail/apps/web/src/lib/components/EmailList.svelte
Normal file
158
apps-archived/mail/apps/web/src/lib/components/EmailList.svelte
Normal 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>
|
||||
137
apps-archived/mail/apps/web/src/lib/components/Sidebar.svelte
Normal file
137
apps-archived/mail/apps/web/src/lib/components/Sidebar.svelte
Normal 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>
|
||||
58
apps-archived/mail/apps/web/src/lib/i18n/index.ts
Normal file
58
apps-archived/mail/apps/web/src/lib/i18n/index.ts
Normal 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';
|
||||
105
apps-archived/mail/apps/web/src/lib/i18n/locales/de.json
Normal file
105
apps-archived/mail/apps/web/src/lib/i18n/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
105
apps-archived/mail/apps/web/src/lib/i18n/locales/en.json
Normal file
105
apps-archived/mail/apps/web/src/lib/i18n/locales/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
105
apps-archived/mail/apps/web/src/lib/i18n/locales/es.json
Normal file
105
apps-archived/mail/apps/web/src/lib/i18n/locales/es.json
Normal 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"
|
||||
}
|
||||
}
|
||||
105
apps-archived/mail/apps/web/src/lib/i18n/locales/fr.json
Normal file
105
apps-archived/mail/apps/web/src/lib/i18n/locales/fr.json
Normal 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"
|
||||
}
|
||||
}
|
||||
105
apps-archived/mail/apps/web/src/lib/i18n/locales/it.json
Normal file
105
apps-archived/mail/apps/web/src/lib/i18n/locales/it.json
Normal 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"
|
||||
}
|
||||
}
|
||||
211
apps-archived/mail/apps/web/src/lib/stores/accounts.svelte.ts
Normal file
211
apps-archived/mail/apps/web/src/lib/stores/accounts.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
160
apps-archived/mail/apps/web/src/lib/stores/auth.svelte.ts
Normal file
160
apps-archived/mail/apps/web/src/lib/stores/auth.svelte.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
327
apps-archived/mail/apps/web/src/lib/stores/compose.svelte.ts
Normal file
327
apps-archived/mail/apps/web/src/lib/stores/compose.svelte.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
337
apps-archived/mail/apps/web/src/lib/stores/emails.svelte.ts
Normal file
337
apps-archived/mail/apps/web/src/lib/stores/emails.svelte.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
160
apps-archived/mail/apps/web/src/lib/stores/folders.svelte.ts
Normal file
160
apps-archived/mail/apps/web/src/lib/stores/folders.svelte.ts
Normal 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
|
||||
);
|
||||
},
|
||||
};
|
||||
176
apps-archived/mail/apps/web/src/lib/stores/labels.svelte.ts
Normal file
176
apps-archived/mail/apps/web/src/lib/stores/labels.svelte.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
4
apps-archived/mail/apps/web/src/lib/stores/navigation.ts
Normal file
4
apps-archived/mail/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { writable } from 'svelte/store';
|
||||
|
||||
export const isSidebarMode = writable(false);
|
||||
export const isNavCollapsed = writable(false);
|
||||
6
apps-archived/mail/apps/web/src/lib/stores/theme.ts
Normal file
6
apps-archived/mail/apps/web/src/lib/stores/theme.ts
Normal 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'],
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
},
|
||||
};
|
||||
249
apps-archived/mail/apps/web/src/routes/(app)/+layout.svelte
Normal file
249
apps-archived/mail/apps/web/src/routes/(app)/+layout.svelte
Normal 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>
|
||||
108
apps-archived/mail/apps/web/src/routes/(app)/+page.svelte
Normal file
108
apps-archived/mail/apps/web/src/routes/(app)/+page.svelte
Normal 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}
|
||||
149
apps-archived/mail/apps/web/src/routes/(app)/drafts/+page.svelte
Normal file
149
apps-archived/mail/apps/web/src/routes/(app)/drafts/+page.svelte
Normal 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}
|
||||
|
|
@ -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} />
|
||||
101
apps-archived/mail/apps/web/src/routes/(app)/sent/+page.svelte
Normal file
101
apps-archived/mail/apps/web/src/routes/(app)/sent/+page.svelte
Normal 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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
36
apps-archived/mail/apps/web/src/routes/+layout.svelte
Normal file
36
apps-archived/mail/apps/web/src/routes/+layout.svelte
Normal 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}
|
||||
Loading…
Add table
Add a link
Reference in a new issue