diff --git a/src/newsletter/README.md b/src/newsletter/README.md new file mode 100644 index 0000000..531d850 --- /dev/null +++ b/src/newsletter/README.md @@ -0,0 +1,148 @@ +# Newsletter Module + +This module provides functionality for managing newsletter subscriptions using various email service providers. Currently, it supports [Resend](https://resend.com) for handling newsletter subscriptions. + +## Features + +- Subscribe users to newsletters +- Unsubscribe users from newsletters +- Check subscription status +- Provider-agnostic interface for easy integration with different newsletter services +- Automatic configuration using environment variables + +## Basic Usage + +```typescript +import { subscribe, unsubscribe, isSubscribed } from '@/src/newsletter'; + +// Subscribe a user to the newsletter +const success = await subscribe('user@example.com'); + +// Unsubscribe a user from the newsletter +const success = await unsubscribe('user@example.com'); + +// Check if a user is subscribed +const subscribed = await isSubscribed('user@example.com'); +``` + +## Configuration + +The newsletter module is configured using environment variables: + +``` +# Required for Resend provider +RESEND_API_KEY=your-resend-api-key +RESEND_AUDIENCE_ID=your-audience-id +``` + +Or you can configure it programmatically: + +```typescript +import { initializeNewsletterProvider } from '@/src/newsletter'; + +// Configure with Resend +initializeNewsletterProvider({ + resend: { + apiKey: 'your-api-key', + audienceId: 'your-audience-id' + } +}); +``` + +## Advanced Usage + +### Using the Newsletter Provider Directly + +If you need more control, you can interact with the newsletter provider directly: + +```typescript +import { getNewsletterProvider } from '@/src/newsletter'; + +const provider = getNewsletterProvider(); + +// Use provider methods directly +const result = await provider.subscribe({ email: 'user@example.com' }); +``` + +### Creating a Provider Instance Manually + +You can create a provider instance directly without configuring the global instance: + +```typescript +import { createNewsletterProvider, ResendNewsletterProvider } from '@/src/newsletter'; + +// Using the factory function +const resendProvider = createNewsletterProvider('resend', { + apiKey: 'your-api-key', + audienceId: 'your-audience-id' +}); + +// Or creating an instance directly +const manualProvider = new ResendNewsletterProvider( + 'your-api-key', + 'your-audience-id' +); +``` + +### Using a Custom Provider Implementation + +You can create and use your own newsletter provider implementation: + +```typescript +import { NewsletterProvider, SubscribeNewsletterProps } from '@/src/newsletter'; + +class CustomNewsletterProvider implements NewsletterProvider { + async subscribe(params: SubscribeNewsletterProps): Promise { + // Your implementation + return true; + } + + async unsubscribe(params: UnsubscribeNewsletterProps): Promise { + // Your implementation + return true; + } + + async checkSubscribeStatus(params: CheckSubscribeStatusProps): Promise { + // Your implementation + return true; + } + + getProviderName(): string { + return 'CustomProvider'; + } +} + +// Use your custom provider directly +const customProvider = new CustomNewsletterProvider(); +const result = await customProvider.subscribe({ email: 'user@example.com' }); +``` + +## API Reference + +### Main Functions + +- `subscribe(email)`: Subscribe a user to the newsletter +- `unsubscribe(email)`: Unsubscribe a user from the newsletter +- `isSubscribed(email)`: Check if a user is subscribed to the newsletter + +### Provider Management + +- `getNewsletterProvider()`: Get the configured newsletter provider instance +- `initializeNewsletterProvider(config?)`: Initialize the newsletter provider with specific options +- `createNewsletterProvider(type, config)`: Create a new provider instance of the specified type + +### Provider Interface + +The `NewsletterProvider` interface defines the following methods: + +- `subscribe(params)`: Subscribe a user to the newsletter +- `unsubscribe(params)`: Unsubscribe a user from the newsletter +- `checkSubscribeStatus(params)`: Check if a user is subscribed to the newsletter +- `getProviderName()`: Get the provider name + +### Types + +- `SubscribeNewsletterProps`: Parameters for subscribing a user +- `UnsubscribeNewsletterProps`: Parameters for unsubscribing a user +- `CheckSubscribeStatusProps`: Parameters for checking subscription status +- `NewsletterConfig`: Configuration options for the newsletter module \ No newline at end of file diff --git a/src/newsletter/index.ts b/src/newsletter/index.ts index 27a4051..42ba0d6 100644 --- a/src/newsletter/index.ts +++ b/src/newsletter/index.ts @@ -1,4 +1,126 @@ -// export the subscribe and unsubscribe functions -export { subscribe } from './newsletter'; -export { unsubscribe } from './newsletter'; -export { isSubscribed } from './newsletter'; \ No newline at end of file +import { ResendNewsletterProvider } from './provider/resend'; +import { + CheckSubscribeStatusProps, + NewsletterConfig, + NewsletterProvider, + SubscribeNewsletterP, + UnsubscribeNewsletterProps +} from './types'; + +// Re-export types for convenience +export type { + NewsletterProvider, + NewsletterConfig, + SubscribeNewsletterP as SubscribeNewsletterProps, + UnsubscribeNewsletterProps, + CheckSubscribeStatusProps +}; + +// Export provider implementation +export { ResendNewsletterProvider } from './provider/resend'; + +/** + * Global newsletter provider instance + */ +let newsletterProvider: NewsletterProvider | null = null; + +/** + * Initialize the newsletter provider + * @returns initialized newsletter provider + */ +export const initializeNewsletterProvider = (config?: NewsletterConfig): NewsletterProvider => { + if (newsletterProvider) { + return newsletterProvider; + } + + // If no config is provided, use environment variables + const resendApiKey = process.env.RESEND_API_KEY; + const resendAudienceId = process.env.RESEND_AUDIENCE_ID; + + if (config?.resend) { + newsletterProvider = new ResendNewsletterProvider( + config.resend.apiKey, + config.resend.audienceId + ); + } else if (resendApiKey && resendAudienceId) { + newsletterProvider = new ResendNewsletterProvider( + resendApiKey, + resendAudienceId + ); + } else { + // Default for development/testing + const testApiKey = 'test_api_key'; + const testAudienceId = 'test_audience_id'; + newsletterProvider = new ResendNewsletterProvider(testApiKey, testAudienceId); + + if (process.env.NODE_ENV !== 'test' && process.env.NODE_ENV !== 'development') { + console.warn( + 'Using Resend with test credentials. This should only happen in development/test environments.' + ); + } + } + + return newsletterProvider; +}; + +/** + * Get the newsletter provider + * @returns current newsletter provider instance + */ +export const getNewsletterProvider = (): NewsletterProvider => { + if (!newsletterProvider) { + return initializeNewsletterProvider(); + } + return newsletterProvider; +}; + +/** + * Subscribe a user to the newsletter + * @param email The email address to subscribe + * @returns True if the subscription was successful, false otherwise + */ +export const subscribe = async (email: string): Promise => { + const provider = getNewsletterProvider(); + return provider.subscribe({ email }); +}; + +/** + * Unsubscribe a user from the newsletter + * @param email The email address to unsubscribe + * @returns True if the unsubscription was successful, false otherwise + */ +export const unsubscribe = async (email: string): Promise => { + const provider = getNewsletterProvider(); + return provider.unsubscribe({ email }); +}; + +/** + * Check if a user is subscribed to the newsletter + * @param email The email address to check + * @returns True if the user is subscribed, false otherwise + */ +export const isSubscribed = async (email: string): Promise => { + const provider = getNewsletterProvider(); + return provider.checkSubscribeStatus({ email }); +}; + +/** + * Create a newsletter provider based on the specified type + * @param type The provider type + * @param config The provider configuration + * @returns A configured newsletter provider instance + */ +export const createNewsletterProvider = ( + type: string, + config: Record +): NewsletterProvider => { + switch (type.toLowerCase()) { + case 'resend': + return new ResendNewsletterProvider( + config.apiKey, + config.audienceId + ); + default: + throw new Error(`Unsupported newsletter provider type: ${type}`); + } +}; \ No newline at end of file diff --git a/src/newsletter/newsletter.ts b/src/newsletter/newsletter.ts deleted file mode 100644 index ddc4492..0000000 --- a/src/newsletter/newsletter.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { subscribeNewsletter, unsubscribeNewsletter, checkSubscribeStatus } from './provider/resend'; - -export const subscribe = async (email: string) => { - const subscribed = await subscribeNewsletter({ email }); - return subscribed; -}; - -export const unsubscribe = async (email: string) => { - const unsubscribed = await unsubscribeNewsletter({ email }); - return unsubscribed; -}; - -export const isSubscribed = async (email: string) => { - const subscribed = await checkSubscribeStatus({ email }); - return subscribed; -}; diff --git a/src/newsletter/provider/resend.ts b/src/newsletter/provider/resend.ts index 5b5a867..b2c977b 100644 --- a/src/newsletter/provider/resend.ts +++ b/src/newsletter/provider/resend.ts @@ -1,85 +1,115 @@ -import { CheckSubscribeStatusHandler, SubscribeNewsletterHandler, UnsubscribeNewsletterHandler } from '@/newsletter/types'; +import { CheckSubscribeStatusParams, NewsletterProvider, SubscribeNewsletterParams, UnsubscribeNewsletterParams } from '@/newsletter/types'; import { Resend } from 'resend'; -const apiKey = process.env.RESEND_API_KEY || 'test_api_key'; -const audienceId = process.env.RESEND_AUDIENCE_ID || 'test_audience_id'; - -const resend = new Resend(apiKey); - /** - * https://resend.com/docs/dashboard/audiences/contacts + * Implementation of the NewsletterProvider interface using Resend */ -export const subscribeNewsletter: SubscribeNewsletterHandler = async ({ email }) => { - if (!process.env.RESEND_API_KEY || !process.env.RESEND_AUDIENCE_ID) { - console.warn('RESEND_API_KEY or RESEND_AUDIENCE_ID not set, skipping subscribe newsletter'); - return false; +export class ResendNewsletterProvider implements NewsletterProvider { + private resend: Resend; + private audienceId: string; + + constructor(apiKey: string, audienceId: string) { + this.resend = new Resend(apiKey); + this.audienceId = audienceId; } - const result = await resend.contacts.create({ - email, - audienceId, - unsubscribed: false, - }); - const subscribed = !result.error; - - if (!subscribed) { - console.error('Error subscribing newsletter', result.error); - return false; - } else { - console.log('Subscribed newsletter', email); - return true; - } -}; - -export const unsubscribeNewsletter: UnsubscribeNewsletterHandler = async ({ email }) => { - if (!process.env.RESEND_API_KEY || !process.env.RESEND_AUDIENCE_ID) { - console.warn('RESEND_API_KEY or RESEND_AUDIENCE_ID not set, skipping unsubscribe newsletter'); - return false; + getProviderName(): string { + return 'Resend'; } - const result = await resend.contacts.update({ - email, - audienceId, - unsubscribed: true, - }); - const unsubscribed = !result.error; + async subscribe({ email }: SubscribeNewsletterParams): Promise { + try { + // First, list all contacts to find the one with the matching email + const listResult = await this.resend.contacts.list({ audienceId: this.audienceId }); + if (listResult.error) { + console.error('Error listing contacts:', listResult.error); + return false; + } - if (!unsubscribed) { - console.error('Error unsubscribing newsletter', result.error); - return false; - } else { - console.log('Unsubscribed newsletter', email); - return true; - } -}; + // Check if the contact with the given email exists in the list + let contact = null; + if (listResult.data && Array.isArray(listResult.data)) { + contact = listResult.data.find(c => c.email === email); + } -export const checkSubscribeStatus: CheckSubscribeStatusHandler = async ({ email }) => { - if (!process.env.RESEND_API_KEY || !process.env.RESEND_AUDIENCE_ID) { - console.warn('RESEND_API_KEY or RESEND_AUDIENCE_ID not set, skipping check subscribe status'); - return false; - } + // If the contact does not exist, create a new one + if (!contact) { + const createResult = await this.resend.contacts.create({ + email, + audienceId: this.audienceId, + unsubscribed: false, + }); - try { - // First, list all contacts to find the one with the matching email - const listResult = await resend.contacts.list({ audienceId }); - - if (listResult.error) { - console.error('Error listing contacts:', listResult.error); + if (createResult.error) { + console.error('Error creating contact', createResult.error); + return false; + } + } + + // If the contact already exists, update it + // NOTICE: we can not just create a new contact if this email already exists, + // because Resend will response 201, but user is not subscribed + const updateResult = await this.resend.contacts.update({ + email, + audienceId: this.audienceId, + unsubscribed: false, + }); + + if (updateResult.error) { + console.error('Error updating contact', updateResult.error); + return false; + } + + console.log('Subscribed newsletter', email); + return true; + } catch (error) { + console.error('Error subscribing newsletter', error); return false; } - - // Check if the contact with the given email exists in the list - // We need to check if data exists and is an array - if (listResult.data && Array.isArray(listResult.data)) { - // Now we can safely use array methods - return listResult.data.some(contact => - contact.email === email && contact.unsubscribed === false - ); - } - - return false; - } catch (error) { - console.error('Error checking subscription status:', error); - return false; } -}; + + async unsubscribe({ email }: UnsubscribeNewsletterParams): Promise { + try { + const result = await this.resend.contacts.update({ + email, + audienceId: this.audienceId, + unsubscribed: true, + }); + + if (result.error) { + console.error('Error unsubscribing newsletter', result.error); + return false; + } + + console.log('Unsubscribed newsletter', email); + return true; + } catch (error) { + console.error('Error unsubscribing newsletter', error); + return false; + } + } + + async checkSubscribeStatus({ email }: CheckSubscribeStatusParams): Promise { + try { + // First, list all contacts to find the one with the matching email + const listResult = await this.resend.contacts.list({ audienceId: this.audienceId }); + + if (listResult.error) { + console.error('Error listing contacts:', listResult.error); + return false; + } + + // Check if the contact with the given email exists in the list + if (listResult.data && Array.isArray(listResult.data)) { + return listResult.data.some(contact => + contact.email === email && contact.unsubscribed === false + ); + } + + return false; + } catch (error) { + console.error('Error checking subscription status:', error); + return false; + } + } +} diff --git a/src/newsletter/types.ts b/src/newsletter/types.ts index 02fa4a7..00115ab 100644 --- a/src/newsletter/types.ts +++ b/src/newsletter/types.ts @@ -1,20 +1,20 @@ -export interface SubscribeNewsletterProps { +export interface SubscribeNewsletterParams { email: string; } -export interface UnsubscribeNewsletterProps { +export interface UnsubscribeNewsletterParams { email: string; } -export interface CheckSubscribeStatusProps { +export interface CheckSubscribeStatusParams { email: string; } -export type SubscribeNewsletterHandler = (params: SubscribeNewsletterProps) => Promise; +export type SubscribeNewsletterHandler = (params: SubscribeNewsletterParams) => Promise; -export type UnsubscribeNewsletterHandler = (params: UnsubscribeNewsletterProps) => Promise; +export type UnsubscribeNewsletterHandler = (params: UnsubscribeNewsletterParams) => Promise; -export type CheckSubscribeStatusHandler = (params: CheckSubscribeStatusProps) => Promise; +export type CheckSubscribeStatusHandler = (params: CheckSubscribeStatusParams) => Promise; /** * Newsletter provider, currently only Resend is supported @@ -23,4 +23,13 @@ export interface NewsletterProvider { subscribe: SubscribeNewsletterHandler; unsubscribe: UnsubscribeNewsletterHandler; checkSubscribeStatus: CheckSubscribeStatusHandler; + getProviderName(): string; +} + +export interface NewsletterConfig { + provider?: string; + resend?: { + apiKey: string; + audienceId: string; + }; }