mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-23 14:46:43 +02:00
Merge branch 'dev-1' into dev
This commit is contained in:
commit
d41d060bb3
1770 changed files with 168028 additions and 31031 deletions
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));
|
||||
}
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue