import { env } from '../config/env'; import { logger } from '../utils/logger'; // --- Types --- export interface ListmonkList { id: number; uuid: string; name: string; type: 'public' | 'private' | 'temporary'; optin: 'single' | 'double'; tags: string[]; subscriber_count: number; created_at: string; updated_at: string; } export interface ListmonkSubscriber { id: number; uuid: string; email: string; name: string; status: 'enabled' | 'disabled' | 'blocklisted'; lists: ListmonkList[]; attribs: Record; created_at: string; updated_at: string; } export interface BulkSyncResult { total: number; success: number; failed: number; errors: string[]; } // --- Client --- class ListmonkClient { private get baseUrl(): string { return env.LISTMONK_URL; } private get authHeader(): string { return 'Basic ' + Buffer.from(`${env.LISTMONK_ADMIN_USER}:${env.LISTMONK_ADMIN_PASSWORD}`).toString('base64'); } private get enabled(): boolean { return env.LISTMONK_SYNC_ENABLED === 'true'; } private assertEnabled(): void { if (!this.enabled) { throw new Error('Listmonk sync is disabled. Set LISTMONK_SYNC_ENABLED=true to enable.'); } } private async request(method: string, path: string, body?: unknown): Promise { const url = `${this.baseUrl}${path}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { const res = await fetch(url, { method, headers: { 'Authorization': this.authHeader, 'Content-Type': 'application/json', }, body: body ? JSON.stringify(body) : undefined, signal: controller.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Listmonk API ${method} ${path} returned ${res.status}: ${text}`); } return await res.json() as T; } finally { clearTimeout(timeout); } } async checkHealth(): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { const res = await fetch(`${this.baseUrl}/api/health`, { headers: { 'Authorization': this.authHeader }, signal: controller.signal, }); return res.ok; } finally { clearTimeout(timeout); } } catch (err) { logger.warn('Listmonk health check failed:', err instanceof Error ? err.message : err); return false; } } async getLists(): Promise { this.assertEnabled(); try { const res = await this.request<{ data: { results: ListmonkList[] } }>('GET', '/api/lists?per_page=all'); return res.data.results || []; } catch (err) { logger.warn('Failed to fetch Listmonk lists:', err instanceof Error ? err.message : err); throw err; } } async createList(name: string, type: 'public' | 'private', tags: string[]): Promise { this.assertEnabled(); try { const res = await this.request<{ data: ListmonkList }>('POST', '/api/lists', { name, type, optin: 'single', tags, }); return res.data; } catch (err) { logger.warn(`Failed to create Listmonk list "${name}":`, err instanceof Error ? err.message : err); throw err; } } async findSubscriberByEmail(email: string): Promise { this.assertEnabled(); try { // Validate email format and sanitize for Listmonk query language // Strip all characters except valid email chars to prevent query injection const safeEmail = email.replace(/[^a-zA-Z0-9@._+\-]/g, '').replace(/'/g, "''"); const query = encodeURIComponent(`subscribers.email='${safeEmail}'`); const res = await this.request<{ data: { results: ListmonkSubscriber[] } }>( 'GET', `/api/subscribers?query=${query}&per_page=1`, ); const results = res.data.results; return results && results.length > 0 ? results[0] : null; } catch (err) { logger.warn(`Failed to find subscriber ${email}:`, err instanceof Error ? err.message : err); return null; } } async createSubscriber( email: string, name: string, listIds: number[], attribs: Record, ): Promise { this.assertEnabled(); const res = await this.request<{ data: ListmonkSubscriber }>('POST', '/api/subscribers', { email, name, status: 'enabled', lists: listIds, attribs, }); return res.data; } async updateSubscriber( id: number, data: { email: string; name?: string; lists?: number[]; attribs?: Record }, ): Promise { this.assertEnabled(); const res = await this.request<{ data: ListmonkSubscriber }>('PUT', `/api/subscribers/${id}`, data); return res.data; } async updateList( id: number, data: { name?: string; tags?: string[] }, ): Promise { this.assertEnabled(); const res = await this.request<{ data: ListmonkList }>('PUT', `/api/lists/${id}`, data); return res.data; } async deleteList(id: number): Promise { this.assertEnabled(); await this.request('DELETE', `/api/lists/${id}`); } async removeSubscriberFromLists( subscriberId: number, listIdsToRemove: number[], currentEmail: string, currentListIds: number[], ): Promise { this.assertEnabled(); const filteredIds = currentListIds.filter(id => !listIdsToRemove.includes(id)); return this.updateSubscriber(subscriberId, { email: currentEmail, lists: filteredIds, }); } /** * Get campaigns with stats (for dashboard) */ async getCampaigns(): Promise> { try { const res = await this.request<{ data: { results: Array<{ id: number; name: string; status: string; stats: { sent: number; views: number; clicks: number }; started_at: string | null; updated_at: string; }> } }>('GET', '/api/campaigns?per_page=all&order_by=updated_at&order=desc'); return (res.data.results || []).map(c => ({ id: c.id, name: c.name, status: c.status, sent: c.stats?.sent || 0, views: c.stats?.views || 0, clicks: c.stats?.clicks || 0, started_at: c.started_at, updated_at: c.updated_at, })); } catch (err) { logger.warn('Failed to fetch Listmonk campaigns:', err instanceof Error ? err.message : err); return []; } } async upsertSubscriber( email: string, name: string, listIds: number[], attribs: Record, ): Promise { this.assertEnabled(); const existing = await this.findSubscriberByEmail(email); if (existing) { // Merge lists: combine existing + new, deduplicate const existingListIds = existing.lists.map(l => l.id); const mergedListIds = [...new Set([...existingListIds, ...listIds])]; // Merge attribs const mergedAttribs = { ...existing.attribs, ...attribs }; return this.updateSubscriber(existing.id, { email, name: name || existing.name, lists: mergedListIds, attribs: mergedAttribs, }); } return this.createSubscriber(email, name, listIds, attribs); } } export const listmonkClient = new ListmonkClient();