UNPKG

payload-plugin-newsletter

Version:

Complete newsletter management plugin for Payload CMS with subscriber management, magic link authentication, and email service integration

504 lines (499 loc) 16.4 kB
// src/types/newsletter.ts var NewsletterProviderError = class extends Error { constructor(message, code, provider, details) { super(message); this.code = code; this.provider = provider; this.details = details; this.name = "NewsletterProviderError"; } }; // src/types/broadcast.ts var BroadcastProviderError = class extends Error { constructor(message, code, provider, details) { super(message); this.code = code; this.provider = provider; this.details = details; this.name = "BroadcastProviderError"; } }; // src/types/providers.ts var BaseBroadcastProvider = class { constructor(config) { this.config = config; } /** * Schedule a broadcast - default implementation throws not supported */ async schedule(_id, _scheduledAt) { const capabilities = this.getCapabilities(); if (!capabilities.supportsScheduling) { throw new BroadcastProviderError( "Scheduling is not supported by this provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } throw new Error("Method not implemented"); } /** * Cancel scheduled broadcast - default implementation throws not supported */ async cancelSchedule(_id) { const capabilities = this.getCapabilities(); if (!capabilities.supportsScheduling) { throw new BroadcastProviderError( "Scheduling is not supported by this provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } throw new Error("Method not implemented"); } /** * Get analytics - default implementation returns zeros */ async getAnalytics(_id) { const capabilities = this.getCapabilities(); if (!capabilities.supportsAnalytics) { throw new BroadcastProviderError( "Analytics are not supported by this provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } return { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 }; } /** * Helper method to validate required fields */ validateRequiredFields(data, fields) { const missing = fields.filter((field) => !data[field]); if (missing.length > 0) { throw new BroadcastProviderError( `Missing required fields: ${missing.join(", ")}`, "VALIDATION_ERROR" /* VALIDATION_ERROR */, this.name ); } } /** * Helper method to check if a status transition is allowed */ canEditInStatus(status) { const capabilities = this.getCapabilities(); return capabilities.editableStatuses.includes(status); } /** * Helper to build pagination response */ buildListResponse(items, total, options = {}) { const limit = options.limit || 20; const offset = options.offset || 0; return { items, total, limit, offset, hasMore: offset + items.length < total }; } }; // src/providers/broadcast/broadcast.ts var BroadcastApiProvider = class extends BaseBroadcastProvider { constructor(config) { super(config); this.name = "broadcast"; this.apiUrl = config.apiUrl.replace(/\/$/, ""); this.token = config.token; if (!this.token) { throw new BroadcastProviderError( "Broadcast API token is required", "CONFIGURATION_ERROR" /* CONFIGURATION_ERROR */, this.name ); } } // Broadcast Management Methods async list(options) { try { const params = new URLSearchParams(); if (options?.limit) params.append("limit", options.limit.toString()); if (options?.offset) params.append("offset", options.offset.toString()); const response = await fetch(`${this.apiUrl}/api/v1/broadcasts?${params}`, { method: "GET", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" } }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const data = await response.json(); const broadcasts = data.data.map((broadcast) => this.transformBroadcastFromApi(broadcast)); return this.buildListResponse(broadcasts, data.total, options); } catch (error) { throw new BroadcastProviderError( `Failed to list broadcasts: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async get(id) { try { console.log("[BroadcastApiProvider] Getting broadcast with ID:", id); const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "GET", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" } }); if (!response.ok) { if (response.status === 404) { throw new BroadcastProviderError( `Broadcast not found: ${id}`, "NOT_FOUND" /* NOT_FOUND */, this.name ); } const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const broadcast = await response.json(); console.log("[BroadcastApiProvider] GET response:", broadcast); return this.transformBroadcastFromApi(broadcast); } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to get broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async create(data) { try { this.validateRequiredFields(data, ["name", "subject", "content"]); const requestBody = { broadcast: { name: data.name, subject: data.subject, preheader: data.preheader, body: data.content, html_body: true, track_opens: data.trackOpens ?? true, track_clicks: data.trackClicks ?? true, reply_to: data.replyTo, segment_ids: data.audienceIds } }; console.log("[BroadcastApiProvider] Creating broadcast:", { url: `${this.apiUrl}/api/v1/broadcasts`, method: "POST", hasToken: !!this.token, tokenLength: this.token?.length, body: JSON.stringify(requestBody, null, 2) }); const response = await fetch(`${this.apiUrl}/api/v1/broadcasts`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify(requestBody) }); console.log("[BroadcastApiProvider] Response status:", response.status); console.log("[BroadcastApiProvider] Response headers:", Object.fromEntries(response.headers.entries())); if (!response.ok) { const errorText = await response.text(); console.error("[BroadcastApiProvider] Error response body:", errorText); let errorDetails; try { errorDetails = JSON.parse(errorText); console.error("[BroadcastApiProvider] Parsed error:", errorDetails); } catch { } throw new Error(`Broadcast API error: ${response.status} - ${errorText}`); } const responseText = await response.text(); console.log("[BroadcastApiProvider] Success response body:", responseText); let result; try { result = JSON.parse(responseText); } catch { throw new Error(`Failed to parse response as JSON: ${responseText}`); } console.log("[BroadcastApiProvider] Parsed result:", result); if (!result.id) { throw new Error(`Response missing expected 'id' field: ${JSON.stringify(result)}`); } return this.get(result.id.toString()); } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to create broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async update(id, data) { try { const existing = await this.get(id); if (!this.canEditInStatus(existing.sendStatus)) { throw new BroadcastProviderError( `Cannot update broadcast in status: ${existing.sendStatus}`, "INVALID_STATUS" /* INVALID_STATUS */, this.name ); } const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "PATCH", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ broadcast: { name: data.name, subject: data.subject, preheader: data.preheader, body: data.content, track_opens: data.trackOpens, track_clicks: data.trackClicks, reply_to: data.replyTo, segment_ids: data.audienceIds } }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const broadcast = await response.json(); return this.transformBroadcastFromApi(broadcast); } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to update broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async delete(id) { try { const existing = await this.get(id); if (!this.canEditInStatus(existing.sendStatus)) { throw new BroadcastProviderError( `Cannot delete broadcast in status: ${existing.sendStatus}`, "INVALID_STATUS" /* INVALID_STATUS */, this.name ); } const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "DELETE", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" } }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to delete broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async send(id, options) { try { if (options?.testMode && options.testRecipients?.length) { throw new BroadcastProviderError( "Test send is not yet implemented for Broadcast provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}/send_broadcast`, { method: "POST", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ segment_ids: options?.audienceIds }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const result = await response.json(); return this.get(result.id.toString()); } catch (error) { if (error instanceof BroadcastProviderError) throw error; throw new BroadcastProviderError( `Failed to send broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async schedule(id, scheduledAt) { try { const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "PATCH", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ broadcast: { scheduled_send_at: scheduledAt.toISOString(), // TODO: Handle timezone properly scheduled_timezone: Intl.DateTimeFormat().resolvedOptions().timeZone } }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const broadcast = await response.json(); return this.transformBroadcastFromApi(broadcast); } catch (error) { throw new BroadcastProviderError( `Failed to schedule broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async cancelSchedule(id) { try { const response = await fetch(`${this.apiUrl}/api/v1/broadcasts/${id}`, { method: "PATCH", headers: { "Authorization": `Bearer ${this.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ broadcast: { scheduled_send_at: null, scheduled_timezone: null } }) }); if (!response.ok) { const error = await response.text(); throw new Error(`Broadcast API error: ${response.status} - ${error}`); } const broadcast = await response.json(); return this.transformBroadcastFromApi(broadcast); } catch (error) { throw new BroadcastProviderError( `Failed to cancel scheduled broadcast: ${error instanceof Error ? error.message : "Unknown error"}`, "PROVIDER_ERROR" /* PROVIDER_ERROR */, this.name, error ); } } async getAnalytics(_id) { throw new BroadcastProviderError( "Analytics API not yet implemented for Broadcast provider", "NOT_SUPPORTED" /* NOT_SUPPORTED */, this.name ); } getCapabilities() { return { supportsScheduling: true, supportsSegmentation: true, supportsAnalytics: false, // Not documented yet supportsABTesting: false, supportsTemplates: false, supportsPersonalization: true, supportsMultipleChannels: false, supportsChannelSegmentation: false, editableStatuses: ["draft" /* DRAFT */, "scheduled" /* SCHEDULED */], supportedContentTypes: ["html", "text"] }; } async validateConfiguration() { try { await this.list({ limit: 1 }); return true; } catch { return false; } } transformBroadcastFromApi(broadcast) { return { id: broadcast.id.toString(), name: broadcast.name, subject: broadcast.subject, preheader: broadcast.preheader, content: broadcast.body, sendStatus: this.mapBroadcastStatus(broadcast.status), trackOpens: broadcast.track_opens, trackClicks: broadcast.track_clicks, replyTo: broadcast.reply_to, recipientCount: broadcast.total_recipients, sentAt: broadcast.sent_at ? new Date(broadcast.sent_at) : void 0, scheduledAt: broadcast.scheduled_send_at ? new Date(broadcast.scheduled_send_at) : void 0, createdAt: new Date(broadcast.created_at), updatedAt: new Date(broadcast.updated_at), providerData: { broadcast }, providerId: broadcast.id.toString(), providerType: "broadcast" }; } mapBroadcastStatus(status) { const statusMap = { "draft": "draft" /* DRAFT */, "scheduled": "scheduled" /* SCHEDULED */, "queueing": "sending" /* SENDING */, "sending": "sending" /* SENDING */, "sent": "sent" /* SENT */, "failed": "failed" /* FAILED */, "partial_failure": "failed" /* FAILED */, "paused": "paused" /* PAUSED */, "aborted": "canceled" /* CANCELED */ }; return statusMap[status] || "draft" /* DRAFT */; } }; export { NewsletterProviderError, BroadcastProviderError, BaseBroadcastProvider, BroadcastApiProvider };