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
JavaScript
// 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
};