T08c: Frontend Mail UI + Global Search UI — 44 tests, tsc clean, vite build pass

- Mail page: 3-pane layout (folder tree + mail list + reading pane)
- Compose modal: rich text editor (bold/italic/link), template picker, reply/forward pre-fill
- Mail settings: accounts, signatures, rules, labels, vacation, PGP (6 tabs)
- Shared mailbox selector: switch between personal + shared accounts
- Mail search bar + attachment download + create-event-from-mail
- Global search: tabs for companies/contacts/mails/files/events
- Search autocomplete in TopBar (existing SearchDropdown)
- API client: mail.ts (all endpoints)
- Routes: /mail, /mail/settings
- i18n: de.json + en.json mail + search translations
- 44 new tests (4 test files), full regression 318/318 pass
- tsc --noEmit: 0 errors, vite build: 267 modules
This commit is contained in:
leocrm-bot
2026-07-01 20:43:49 +02:00
parent 0962f3a961
commit 0070fb3aea
30 changed files with 4312 additions and 191 deletions
+553
View File
@@ -0,0 +1,553 @@
/**
* Mail plugin API client.
*
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target the
* Mail plugin routes under `/mail/...`.
*/
import { apiClient, apiDelete, apiGet, apiPatch, apiPost } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export interface MailAccount {
id: string;
email: string;
display_name: string;
imap_host: string;
imap_port: number;
smtp_host: string;
smtp_port: number;
is_shared: boolean;
is_active: boolean;
created_at?: string | null;
updated_at?: string | null;
}
export interface MailFolder {
id: string;
account_id: string;
name: string;
parent_id: string | null;
unread_count: number;
total_count: number;
children?: MailFolder[];
}
export interface MailAttachment {
id: string;
mail_id: string;
filename: string;
mime_type: string;
size: number;
content_id?: string | null;
is_inline: boolean;
}
export interface Mail {
id: string;
folder_id: string; account_id: string;
from_address: string;
from_name: string;
to_addresses: string[];
cc_addresses: string[];
bcc_addresses: string[];
subject: string;
body_text: string;
body_html: string | null;
sanitized_html: string | null;
date: string;
is_seen: boolean;
is_flagged: boolean;
is_answered: boolean;
has_attachments: boolean;
attachments?: MailAttachment[];
labels?: MailLabel[];
thread_id?: string | null;
in_reply_to?: string | null;
message_id?: string | null;
}
export interface MailListResult {
mails: Mail[];
total: number;
page: number;
page_size: number;
}
export interface MailTemplate {
id: string;
name: string;
subject: string;
body: string;
variables: string[];
created_at?: string | null;
updated_at?: string | null;
}
export interface MailSignature {
id: string;
name: string;
body_html: string;
is_default: boolean;
created_at?: string | null;
updated_at?: string | null;
}
export type RuleConditionType = 'from_contains' | 'to_contains' | 'subject_contains' | 'has_attachment' | 'body_contains';
export type RuleActionType = 'move_to_folder' | 'mark_as_read' | 'mark_as_flagged' | 'forward_to' | 'delete';
export interface RuleCondition {
type: RuleConditionType;
value: string;
}
export interface RuleAction {
type: RuleActionType;
value: string;
}
export interface MailRule {
id: string;
name: string;
account_id: string;
priority: number;
is_active: boolean;
conditions: RuleCondition[];
actions: RuleAction[];
created_at?: string | null;
updated_at?: string | null;
}
export interface MailLabel {
id: string;
name: string;
color: string;
created_at?: string | null;
}
export interface PgpKey {
id: string;
key_id: string;
fingerprint: string;
user_id: string;
is_private: boolean;
expires_at: string | null;
created_at?: string | null;
}
export interface ContactPgpKey {
contact_id: string;
contact_name: string;
key_id: string;
fingerprint: string;
added_at: string;
}
export interface VacationConfig {
enabled: boolean;
start_date: string | null;
end_date: string | null;
subject: string;
body: string;
dedup_days: number;
}
export interface SharedMailboxUser {
user_id: string;
user_name: string;
permissions: string[];
}
export interface DelegateAccess {
id: string;
delegate_user_id: string;
delegate_user_name: string;
permissions: string[];
folder_ids: string[];
}
export interface SendPermission {
id: string;
grantee_user_id: string;
grantee_user_name: string;
from_address: string;
}
export interface ThreadResult {
thread_id: string;
subject: string;
mails: Mail[];
}
// ─── Payloads ──────────────────────────────────────────────────────────────
export interface CreateAccountPayload {
email: string;
display_name: string;
imap_host: string;
imap_port: number;
smtp_host: string;
smtp_port: number;
password: string;
}
export interface UpdateAccountPayload {
display_name?: string;
imap_host?: string;
imap_port?: number;
smtp_host?: string;
smtp_port?: number;
password?: string;
is_active?: boolean;
}
export interface CreateFolderPayload {
account_id: string;
name: string;
parent_id?: string | null;
}
export interface UpdateFolderPayload {
name?: string;
parent_id?: string | null;
}
export interface SendMailPayload {
account_id: string;
to: string[];
cc?: string[];
bcc?: string[];
subject: string;
body: string;
is_html: boolean;
in_reply_to?: string | null;
attachments?: string[];
signature_id?: string | null;
}
export interface ReplyPayload {
account_id: string;
body: string;
is_html: boolean;
to?: string[];
cc?: string[];
signature_id?: string | null;
}
export interface ForwardPayload {
account_id: string;
to: string[];
body?: string;
is_html: boolean;
signature_id?: string | null;
}
export interface FlagUpdatePayload {
seen?: boolean;
flagged?: boolean;
}
export interface LinkMailPayload {
contact_id?: string | null;
company_id?: string | null;
}
export interface CreateEventFromMailPayload {
title: string;
start: string;
end: string;
description?: string;
location?: string;
calendar_id?: string;
}
export interface AssignLabelPayload {
label_id: string;
}
export interface CreateTemplatePayload {
name: string;
subject: string;
body: string;
variables?: string[];
}
export interface SubstituteTemplatePayload {
template_id: string;
variables: Record<string, string>;
}
export interface SubstituteResult {
subject: string;
body: string;
}
export interface CreateSignaturePayload {
name: string;
body_html: string;
is_default?: boolean;
}
export interface CreateRulePayload {
name: string;
account_id: string;
priority: number;
is_active: boolean;
conditions: RuleCondition[];
actions: RuleAction[];
}
export interface CreateLabelPayload {
name: string;
color: string;
}
export interface VacationPayload {
enabled: boolean;
start_date: string | null;
end_date: string | null;
subject: string;
body: string;
dedup_days?: number;
}
export interface ImportPgpKeyPayload {
private_key: string;
passphrase?: string;
}
export interface StoreContactPgpKeyPayload {
public_key: string;
}
export interface AssignSharedMailboxUsersPayload {
user_ids: string[];
permissions: string[];
}
export interface CreateDelegatePayload {
delegate_user_id: string;
permissions: string[];
folder_ids: string[];
}
export interface GrantSendPermissionPayload {
grantee_user_id: string;
from_address: string;
}
// ─── Accounts ──────────────────────────────────────────────────────────────
export function fetchAccounts(): Promise<MailAccount[]> {
return apiGet<MailAccount[]>('/mail/accounts');
}
export function createAccount(payload: CreateAccountPayload): Promise<MailAccount> {
return apiPost<MailAccount>('/mail/accounts', payload);
}
export function updateAccount(accountId: string, payload: UpdateAccountPayload): Promise<MailAccount> {
return apiPatch<MailAccount>(`/mail/accounts/${accountId}`, payload);
}
export function deleteAccount(accountId: string): Promise<void> {
return apiDelete<void>(`/mail/accounts/${accountId}`);
}
export function fetchSharedAccounts(): Promise<MailAccount[]> {
return apiGet<MailAccount[]>('/mail/accounts/shared');
}
export function assignSharedMailboxUsers(accountId: string, payload: AssignSharedMailboxUsersPayload): Promise<void> {
return apiPost<void>(`/mail/accounts/${accountId}/users`, payload);
}
export function createDelegate(accountId: string, payload: CreateDelegatePayload): Promise<DelegateAccess> {
return apiPost<DelegateAccess>(`/mail/accounts/${accountId}/delegates`, payload);
}
export function grantSendPermission(accountId: string, payload: GrantSendPermissionPayload): Promise<SendPermission> {
return apiPost<SendPermission>(`/mail/accounts/${accountId}/send-permissions`, payload);
}
export function testConnection(accountId: string): Promise<{ success: boolean; message: string }> {
return apiPost<{ success: boolean; message: string }>(`/mail/accounts/${accountId}/test-connection`);
}
export function triggerSync(accountId: string): Promise<{ success: boolean; synced_count: number }> {
return apiPost<{ success: boolean; synced_count: number }>(`/mail/accounts/${accountId}/sync`);
}
// ─── Folders ────────────────────────────────────────────────────────────────
export function fetchFolders(accountId: string): Promise<MailFolder[]> {
const qs = new URLSearchParams({ account_id: accountId }).toString();
return apiGet<MailFolder[]>(`/mail/folders?${qs}`);
}
export function createFolder(payload: CreateFolderPayload): Promise<MailFolder> {
return apiPost<MailFolder>('/mail/folders', payload);
}
export function updateFolder(folderId: string, payload: UpdateFolderPayload): Promise<MailFolder> {
return apiPatch<MailFolder>(`/mail/folders/${folderId}`, payload);
}
export function deleteFolder(folderId: string): Promise<void> {
return apiDelete<void>(`/mail/folders/${folderId}`);
}
// ─── Mails ──────────────────────────────────────────────────────────────────
export function fetchMails(folderId: string, page: number): Promise<MailListResult> {
const qs = new URLSearchParams({ folder_id: folderId, page: String(page) }).toString();
return apiGet<MailListResult>(`/mail/?${qs}`);
}
export function getMail(mailId: string): Promise<Mail> {
return apiGet<Mail>(`/mail/${mailId}`);
}
export function sendMail(payload: SendMailPayload): Promise<{ id: string; success: boolean }> {
return apiPost<{ id: string; success: boolean }>('/mail/send', payload);
}
export function replyMail(mailId: string, payload: ReplyPayload): Promise<{ id: string; success: boolean }> {
return apiPost<{ id: string; success: boolean }>(`/mail/${mailId}/reply`, payload);
}
export function forwardMail(mailId: string, payload: ForwardPayload): Promise<{ id: string; success: boolean }> {
return apiPost<{ id: string; success: boolean }>(`/mail/${mailId}/forward`, payload);
}
export function updateFlags(mailId: string, payload: FlagUpdatePayload): Promise<void> {
return apiPatch<void>(`/mail/${mailId}/flags`, payload);
}
export function linkMail(mailId: string, payload: LinkMailPayload): Promise<void> {
return apiPost<void>(`/mail/${mailId}/link`, payload);
}
export function createEventFromMail(mailId: string, payload: CreateEventFromMailPayload): Promise<{ id: string; success: boolean }> {
return apiPost<{ id: string; success: boolean }>(`/mail/${mailId}/create-event`, payload);
}
export function assignLabel(mailId: string, payload: AssignLabelPayload): Promise<void> {
return apiPost<void>(`/mail/${mailId}/labels`, payload);
}
// ─── Search & Threads ──────────────────────────────────────────────────────
export function searchMails(query: string): Promise<MailListResult> {
const qs = new URLSearchParams({ q: query }).toString();
return apiGet<MailListResult>(`/mail/search?${qs}`);
}
export function fetchThreads(accountId: string): Promise<ThreadResult[]> {
const qs = new URLSearchParams({ account_id: accountId }).toString();
return apiGet<ThreadResult[]>(`/mail/threads?${qs}`);
}
// ─── Attachments ─────────────────────────────────────────────────────────────
export function getAttachmentDownloadUrl(mailId: string, attachmentId: string): string {
return `/api/v1/mail/${mailId}/attachments/${attachmentId}`;
}
export function downloadAttachment(mailId: string, attachmentId: string): Promise<Blob> {
return apiClient.get<Blob>(`/mail/${mailId}/attachments/${attachmentId}`, {
responseType: 'blob',
}).then((res) => res.data);
}
// ─── Templates ──────────────────────────────────────────────────────────────
export function createTemplate(payload: CreateTemplatePayload): Promise<MailTemplate> {
return apiPost<MailTemplate>('/mail/templates', payload);
}
export function fetchTemplates(): Promise<MailTemplate[]> {
return apiGet<MailTemplate[]>('/mail/templates');
}
export function substituteTemplate(payload: SubstituteTemplatePayload): Promise<SubstituteResult> {
return apiPost<SubstituteResult>('/mail/templates/substitute', payload);
}
// ─── Signatures ──────────────────────────────────────────────────────────────
export function createSignature(payload: CreateSignaturePayload): Promise<MailSignature> {
return apiPost<MailSignature>('/mail/signatures', payload);
}
export function fetchSignatures(): Promise<MailSignature[]> {
return apiGet<MailSignature[]>('/mail/signatures');
}
export function deleteSignature(signatureId: string): Promise<void> {
return apiDelete<void>(`/mail/signatures/${signatureId}`);
}
export function updateSignature(signatureId: string, payload: Partial<CreateSignaturePayload>): Promise<MailSignature> {
return apiPatch<MailSignature>(`/mail/signatures/${signatureId}`, payload);
}
// ─── Rules ───────────────────────────────────────────────────────────────────
export function createRule(payload: CreateRulePayload): Promise<MailRule> {
return apiPost<MailRule>('/mail/rules', payload);
}
export function fetchRules(): Promise<MailRule[]> {
return apiGet<MailRule[]>('/mail/rules');
}
export function deleteRule(ruleId: string): Promise<void> {
return apiDelete<void>(`/mail/rules/${ruleId}`);
}
// ─── Vacation ─────────────────────────────────────────────────────────────────
export function configureVacation(payload: VacationPayload): Promise<{ success: boolean }> {
return apiPost<{ success: boolean }>('/mail/vacation', payload);
}
export function testVacationDedup(): Promise<{ dedup_count: number }> {
return apiPost<{ dedup_count: number }>('/mail/vacation/test-dedup');
}
// ─── PGP ──────────────────────────────────────────────────────────────────────
export function importPgpKey(payload: ImportPgpKeyPayload): Promise<PgpKey> {
return apiPost<PgpKey>('/mail/pgp/keys', payload);
}
export function fetchPgpKeys(): Promise<PgpKey[]> {
return apiGet<PgpKey[]>('/mail/pgp/keys');
}
export function encryptMessage(payload: { recipient_key_id: string; message: string }): Promise<{ encrypted: string }> {
return apiPost<{ encrypted: string }>('/mail/pgp/encrypt', payload);
}
export function storeContactPgpKey(contactId: string, payload: StoreContactPgpKeyPayload): Promise<void> {
return apiPost<void>(`/mail/contacts/${contactId}/pgp-key`, payload);
}
export function fetchContactPgpKeys(): Promise<ContactPgpKey[]> {
return apiGet<ContactPgpKey[]>('/mail/contacts/pgp-keys');
}
// ─── Labels ───────────────────────────────────────────────────────────────────
export function createLabel(payload: CreateLabelPayload): Promise<MailLabel> {
return apiPost<MailLabel>('/mail/labels', payload);
}
export function fetchLabels(): Promise<MailLabel[]> {
return apiGet<MailLabel[]>('/mail/labels');
}
export function deleteLabel(labelId: string): Promise<void> {
return apiDelete<void>(`/mail/labels/${labelId}`);
}