Files
picpeak/frontend/src/services/email.service.ts
T
Luca 1e08a4fb15 fix(messages): PR #769 nits — server-side search, bare-email recipient, DE i18n
- Search now hits the backend (debounced) so results aren't truncated to the
  first loaded page: /received gains a `q` filter (sender/subject); the frontend
  passes the debounced term to every list query. The instant client-side filter
  stays for responsiveness.
- Reply/compose recipient extracts the bare address from a "Name <addr>" From
  header (extractEmail) — also used for the customer-lookup key.
- Added the full de + en `messages.*` and `email.customerMailbox.*` translation
  namespaces (were English inline-fallbacks only). Swiss-German spelling.
2026-07-07 18:39:04 +02:00

343 lines
12 KiB
TypeScript

import { api } from '../config/api';
export type EmailQueueStatus = 'pending' | 'sent' | 'failed';
export interface EmailQueueItem {
id: number;
recipientEmail: string;
emailType: string;
status: EmailQueueStatus;
createdAt: string;
scheduledAt: string | null;
sentAt: string | null;
errorMessage: string | null;
retryCount: number;
eventId: number | null;
eventName: string | null;
eventSlug: string | null;
/** 'system' = app-generated (Automated), 'manual' = admin-composed (Customers Sent). */
origin?: 'system' | 'manual';
}
export interface EmailQueueListResponse {
items: EmailQueueItem[];
pagination: { total: number; page: number; pageSize: number; totalPages: number };
}
/** Single sent/queued email including its rendered body — Messages reading pane. */
export interface EmailQueueDetail extends EmailQueueItem {
/** Exact HTML that was sent (migration 119); null for pre-migration rows. */
renderedHtml: string | null;
cc: string | null;
/** Attachment filenames only — disk paths are never exposed. */
attachments: { filename: string; contentType: string | null }[];
}
export interface EmailConfig {
smtp_host: string;
smtp_port: number;
smtp_secure: boolean;
smtp_user: string;
smtp_pass: string;
from_email: string;
from_name: string;
tls_reject_unauthorized: boolean;
}
export interface EmailTemplateTranslation {
subject: string;
body_html: string;
body_text?: string;
}
/**
* Top-level grouping in the admin Templates UI. Forward-compatible:
* unknown values fall back to the 'core' section.
*/
export type EmailTemplateCategory = 'core' | 'customers' | 'billing' | 'quotes' | 'calendar' | string;
/**
* Second-level grouping inside 'core' (which is busy enough to deserve
* its own sub-headers). Other categories ignore this field.
*/
export type EmailTemplateSubcategory = 'gallery' | 'admin' | 'backup' | 'system' | string;
export interface EmailTemplate {
id: number;
template_key: string;
variables: string[];
translations: Record<string, EmailTemplateTranslation>;
/** Display group (migration 098). Defaults to 'core' if absent. */
category?: EmailTemplateCategory;
/**
* Second-level group inside `core`. Migration 098 backfill assigns
* one of 'gallery' | 'admin' | 'backup' | 'system'; NULL for
* templates outside `core`.
*/
subcategory?: EmailTemplateSubcategory | null;
/**
* Name of the feature flag whose `false` state should mark this
* template as "Feature off" in the admin UI. `null` = always
* active. Migration 098 backfills these.
*/
feature_flag?: string | null;
updated_at: string;
}
export interface EmailPreview {
subject: string;
body_html: string;
body_text: string;
}
export interface IncomingMailConfig {
imap_host: string;
imap_port: number;
imap_secure: boolean;
imap_user: string;
imap_pass: string;
imap_folder: string;
}
export interface ImapFolder {
path: string;
name: string;
/** IMAP special-use flag, e.g. '\\Inbox', '\\Sent' — used to auto-select. */
specialUse: string | null;
}
export interface ImapTestResult {
ok: boolean;
folder: string;
messages: number;
unseen: number;
}
export interface ImapRoundTripResult {
ok: boolean;
seconds?: number;
recipient?: string;
}
export interface ImapPollResult {
processed?: number;
skipped?: 'disabled' | 'unconfigured' | 'busy';
}
export interface ReceivedEmail {
id: number;
message_id: string | null;
account_key?: string | null;
from_address: string | null;
to_address?: string | null;
subject: string | null;
received_at: string | null;
attachment_count: number;
status: string;
inbound_document_id: number | null;
error: string | null;
}
/** Single received email including its captured, server-sanitized body. */
export interface ReceivedEmailDetail extends ReceivedEmail {
body_html: string | null;
body_text: string | null;
}
export interface ReceivedEmailsResponse {
items: ReceivedEmail[];
pagination: { page: number; pageSize: number; total: number; totalPages: number };
}
/** An additional inbound mailbox beyond the primary accounting IMAP. */
export interface MailAccount {
id?: number;
account_key: string;
label?: string | null;
imap_host?: string | null;
imap_port?: number;
imap_secure?: boolean;
imap_user?: string | null;
imap_pass?: string;
imap_folder?: string;
// Outgoing (SMTP) identity — replies from this mailbox send from here.
smtp_host?: string | null;
smtp_port?: number;
smtp_secure?: boolean;
smtp_user?: string | null;
smtp_pass?: string;
from_email?: string | null;
from_name?: string | null;
enabled?: boolean;
}
/** Resolved sender/mailbox addresses for the Messages sidebar. */
export interface MailIdentities {
automated: string | null;
accounting: string | null;
customers: string | null;
}
export const emailService = {
// Get email configuration
async getConfig(): Promise<EmailConfig> {
const response = await api.get<EmailConfig>('/admin/email/config');
return response.data;
},
// Update email configuration
async updateConfig(config: EmailConfig): Promise<void> {
await api.post('/admin/email/config', config);
},
// Incoming mail (IMAP) configuration
async getIncomingConfig(): Promise<IncomingMailConfig> {
const response = await api.get<IncomingMailConfig>('/admin/email/incoming-config');
return response.data;
},
async updateIncomingConfig(config: IncomingMailConfig): Promise<void> {
await api.post('/admin/email/incoming-config', config);
},
// Auto-detect mailbox folders. Sends the current form values so detection
// works before the config is saved (masked password falls back server-side).
async listIncomingFolders(config?: Partial<IncomingMailConfig>): Promise<ImapFolder[]> {
const response = await api.post<{ folders: ImapFolder[] }>('/admin/email/incoming-config/folders', config || {});
return response.data.folders;
},
// Test the IMAP connection: opens the configured folder, reports counts.
async testIncoming(config?: Partial<IncomingMailConfig>): Promise<ImapTestResult> {
const response = await api.post<ImapTestResult>('/admin/email/incoming-config/test', config || {});
return response.data;
},
// End-to-end: send via SMTP to the IMAP mailbox and confirm it arrives.
// Uses saved config for both sides — no body. May take up to ~30s.
async roundTripIncoming(): Promise<ImapRoundTripResult> {
const response = await api.post<ImapRoundTripResult>('/admin/email/incoming-config/roundtrip', {});
return response.data;
},
// Run the poller on demand. Returns { processed } or { skipped: '…' }.
async pollIncoming(): Promise<ImapPollResult> {
const response = await api.post<ImapPollResult>('/admin/email/incoming-config/poll', {});
return response.data;
},
async listReceived(params: { page?: number; pageSize?: number; account?: string; state?: 'active' | 'archived' | 'deleted'; q?: string } = {}): Promise<ReceivedEmailsResponse> {
const response = await api.get<ReceivedEmailsResponse>('/admin/email/received', { params });
return response.data;
},
/** Archive / Delete (soft) / Restore an email. kind = 'queue' | 'received'. */
async setItemState(kind: 'queue' | 'received', id: number, state: 'active' | 'archived' | 'deleted'): Promise<void> {
await api.post(`/admin/email/item/${kind}/${id}/state`, { state });
},
/** Permanently delete an email (only from the Deleted folder). */
async deleteItem(kind: 'queue' | 'received', id: number): Promise<void> {
await api.delete(`/admin/email/item/${kind}/${id}`);
},
async getReceivedItem(id: number): Promise<ReceivedEmailDetail> {
const response = await api.get<ReceivedEmailDetail>(`/admin/email/received/${id}`);
return response.data;
},
// Additional inbound mailboxes (e.g. the customer hello@ box).
async listMailAccounts(): Promise<MailAccount[]> {
const response = await api.get<{ items: MailAccount[] }>('/admin/email/accounts');
return response.data.items;
},
async saveMailAccount(account: MailAccount): Promise<void> {
await api.post('/admin/email/accounts', account);
},
async testMailAccount(account: Partial<MailAccount>): Promise<ImapTestResult> {
const response = await api.post<ImapTestResult>('/admin/email/accounts/test', account);
return response.data;
},
// Test email configuration
async testEmail(testEmail: string): Promise<void> {
await api.post('/admin/email/test', { test_email: testEmail });
},
/** Flush the email queue immediately. Sends every pending email now,
* bypassing the business-hours floor — the escape hatch for draining
* the queue before maintenance/updates. */
async flushQueue(): Promise<{ processed: number; sent: number; failed: number }> {
const response = await api.post<{ processed: number; sent: number; failed: number }>(
'/admin/email/flush-queue'
);
return response.data;
},
/** Read-only "Sent emails" feed — paginated view of the email_queue
* table with filters. email_data is never returned. */
async listQueue(params: {
status?: EmailQueueStatus;
emailType?: string;
origin?: 'system' | 'manual';
state?: 'active' | 'archived' | 'deleted';
q?: string;
from?: string;
to?: string;
page?: number;
pageSize?: number;
} = {}): Promise<EmailQueueListResponse> {
const response = await api.get<EmailQueueListResponse>('/admin/email/queue', { params });
return response.data;
},
/** Single sent email with its rendered body + attachment filenames. */
async getQueueItem(id: number): Promise<EmailQueueDetail> {
const response = await api.get<EmailQueueDetail>(`/admin/email/queue/${id}`);
return response.data;
},
/** Send a human-composed (edited) email — reply or document message. */
async sendMessage(payload: { to: string; cc?: string; subject: string; html: string; replyToReceivedId?: number; accountKey?: string }): Promise<void> {
await api.post('/admin/email/send', payload);
},
/** Resolved sender/mailbox addresses for the Messages sidebar. */
async getIdentities(): Promise<MailIdentities> {
const response = await api.get<MailIdentities>('/admin/email/identities');
return response.data;
},
// Get all email templates
async getTemplates(): Promise<EmailTemplate[]> {
const response = await api.get<EmailTemplate[]>('/admin/email/templates');
return response.data;
},
// Get single template
async getTemplate(key: string): Promise<EmailTemplate> {
const response = await api.get<EmailTemplate>(`/admin/email/templates/${key}`);
return response.data;
},
// Update email template
async updateTemplate(key: string, data: { translations: Record<string, EmailTemplateTranslation> }): Promise<void> {
await api.put(`/admin/email/templates/${key}`, data);
},
/** Create a new email template. Used by ReminderTemplatesPage to
* spawn a per-event-type reminder (e.g. `event_reminder_wedding`)
* initialised with the default catch-all's content. Returns 409 if
* the key already exists. */
async createTemplate(payload: {
template_key: string;
translations: Record<string, EmailTemplateTranslation>;
category?: string;
subcategory?: string;
feature_flag?: string;
variables?: string[];
}): Promise<{ template_key: string; id: number }> {
const response = await api.post<{ template_key: string; id: number }>('/admin/email/templates', payload);
return response.data;
},
// Preview email template
async previewTemplate(key: string, previewData: Record<string, string>, language: string = 'en'): Promise<EmailPreview> {
const response = await api.post<EmailPreview>(
`/admin/email/templates/${key}/preview`,
{ preview_data: previewData, language }
);
return response.data;
}
};