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:
@@ -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}`);
|
||||
}
|
||||
Reference in New Issue
Block a user