refactor: implement newsletter module with Resend provider integration

- Introduced a comprehensive newsletter module for managing subscriptions, including functions to subscribe, unsubscribe, and check subscription status.
- Created a `ResendNewsletterProvider` class to handle interactions with the Resend API, improving modularity and code organization.
- Re-exported relevant types for better accessibility and convenience.
- Added a README file detailing usage, configuration, and advanced features of the newsletter module.
- Implemented automatic configuration using environment variables and provided a programmatic configuration option.
This commit is contained in:
javayhu 2025-03-19 01:45:40 +08:00
parent 9e1c648a7c
commit 9c8c54799f
5 changed files with 390 additions and 97 deletions

148
src/newsletter/README.md Normal file
View File

@ -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<boolean> {
// Your implementation
return true;
}
async unsubscribe(params: UnsubscribeNewsletterProps): Promise<boolean> {
// Your implementation
return true;
}
async checkSubscribeStatus(params: CheckSubscribeStatusProps): Promise<boolean> {
// 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

View File

@ -1,4 +1,126 @@
// export the subscribe and unsubscribe functions
export { subscribe } from './newsletter';
export { unsubscribe } from './newsletter';
export { isSubscribed } from './newsletter';
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<string, any>
): NewsletterProvider => {
switch (type.toLowerCase()) {
case 'resend':
return new ResendNewsletterProvider(
config.apiKey,
config.audienceId
);
default:
throw new Error(`Unsupported newsletter provider type: ${type}`);
}
};

View File

@ -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;
};

View File

@ -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<boolean> {
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<boolean> {
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<boolean> {
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;
}
}
}

View File

@ -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<boolean>;
export type SubscribeNewsletterHandler = (params: SubscribeNewsletterParams) => Promise<boolean>;
export type UnsubscribeNewsletterHandler = (params: UnsubscribeNewsletterProps) => Promise<boolean>;
export type UnsubscribeNewsletterHandler = (params: UnsubscribeNewsletterParams) => Promise<boolean>;
export type CheckSubscribeStatusHandler = (params: CheckSubscribeStatusProps) => Promise<boolean>;
export type CheckSubscribeStatusHandler = (params: CheckSubscribeStatusParams) => Promise<boolean>;
/**
* 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;
};
}