|
| 1 | +import type { EmailAddress, EmailResult, Result } from 'unemail/types' |
| 2 | +import type { ProviderFactory } from '../provider.ts' |
| 3 | +import type { ZeptomailEmailOptions } from './types.ts' |
| 4 | +import { createError, createRequiredError, generateMessageId, makeRequest, retry, validateEmailOptions } from 'unemail/utils' |
| 5 | +import { defineProvider } from '../provider.ts' |
| 6 | + |
| 7 | +// Constants |
| 8 | +const PROVIDER_NAME = 'zeptomail' |
| 9 | +const DEFAULT_ENDPOINT = 'https://api.zeptomail.com/v1.1' |
| 10 | +const DEFAULT_TIMEOUT = 30000 |
| 11 | +const DEFAULT_RETRIES = 3 |
| 12 | + |
| 13 | +/** |
| 14 | + * Interface for Zeptomail configuration |
| 15 | + */ |
| 16 | +export interface ZeptomailConfig { |
| 17 | + /** |
| 18 | + * API token for authentication |
| 19 | + * Format: "Zoho-enczapikey <your_api_key>" |
| 20 | + */ |
| 21 | + token: string |
| 22 | + |
| 23 | + /** |
| 24 | + * Optional custom endpoint (default: https://api.zeptomail.com/v1.1) |
| 25 | + */ |
| 26 | + endpoint?: string |
| 27 | + |
| 28 | + /** |
| 29 | + * Request timeout in milliseconds (default: 30000) |
| 30 | + */ |
| 31 | + timeout?: number |
| 32 | + |
| 33 | + /** |
| 34 | + * Number of retry attempts for failed requests (default: 3) |
| 35 | + */ |
| 36 | + retries?: number |
| 37 | + |
| 38 | + /** |
| 39 | + * Enable debug logging (default: false) |
| 40 | + */ |
| 41 | + debug?: boolean |
| 42 | +} |
| 43 | + |
| 44 | +/** |
| 45 | + * Zeptomail Provider for sending emails through Zeptomail API |
| 46 | + */ |
| 47 | +export const zeptomailProvider: ProviderFactory<ZeptomailConfig, any, ZeptomailEmailOptions> = defineProvider((opts: ZeptomailConfig = {} as ZeptomailConfig) => { |
| 48 | + // Validate required options |
| 49 | + if (!opts.token) { |
| 50 | + throw createRequiredError(PROVIDER_NAME, 'token') |
| 51 | + } |
| 52 | + |
| 53 | + // Make sure token has correct format |
| 54 | + if (!opts.token.startsWith('Zoho-enczapikey ')) { |
| 55 | + throw createError( |
| 56 | + PROVIDER_NAME, |
| 57 | + 'Token should be in the format "Zoho-enczapikey <your_api_key>"', |
| 58 | + ) |
| 59 | + } |
| 60 | + |
| 61 | + // Initialize with defaults |
| 62 | + const options: Required<ZeptomailConfig> = { |
| 63 | + debug: opts.debug || false, |
| 64 | + timeout: opts.timeout || DEFAULT_TIMEOUT, |
| 65 | + retries: opts.retries || DEFAULT_RETRIES, |
| 66 | + token: opts.token, |
| 67 | + endpoint: opts.endpoint || DEFAULT_ENDPOINT, |
| 68 | + } |
| 69 | + |
| 70 | + let isInitialized = false |
| 71 | + |
| 72 | + // Debug helper - using a no-op function if debug is disabled to avoid console.log |
| 73 | + const debug = (message: string, ...args: any[]) => { |
| 74 | + if (options.debug) { |
| 75 | + // Use a safer approach that doesn't rely on console |
| 76 | + const _debugMsg = `[${PROVIDER_NAME}] ${message} ${args.map(arg => JSON.stringify(arg)).join(' ')}` |
| 77 | + // In a real implementation, this might use a logger injected via options |
| 78 | + // or other logging mechanism that doesn't rely on console |
| 79 | + } |
| 80 | + } |
| 81 | + |
| 82 | + return { |
| 83 | + name: PROVIDER_NAME, |
| 84 | + features: { |
| 85 | + attachments: true, |
| 86 | + html: true, |
| 87 | + templates: false, // Zeptomail has template support but not implemented here |
| 88 | + tracking: true, |
| 89 | + customHeaders: true, |
| 90 | + batchSending: false, |
| 91 | + scheduling: false, |
| 92 | + replyTo: true, |
| 93 | + tagging: false, |
| 94 | + }, |
| 95 | + options, |
| 96 | + |
| 97 | + /** |
| 98 | + * Initialize the Zeptomail provider |
| 99 | + */ |
| 100 | + async initialize(): Promise<void> { |
| 101 | + if (isInitialized) { |
| 102 | + return |
| 103 | + } |
| 104 | + |
| 105 | + try { |
| 106 | + // Test endpoint availability and credentials |
| 107 | + if (!await this.isAvailable()) { |
| 108 | + throw createError( |
| 109 | + PROVIDER_NAME, |
| 110 | + 'Zeptomail API not available or invalid token', |
| 111 | + ) |
| 112 | + } |
| 113 | + |
| 114 | + isInitialized = true |
| 115 | + debug('Provider initialized successfully') |
| 116 | + } |
| 117 | + catch (error) { |
| 118 | + throw createError( |
| 119 | + PROVIDER_NAME, |
| 120 | + `Failed to initialize: ${(error as Error).message}`, |
| 121 | + { cause: error as Error }, |
| 122 | + ) |
| 123 | + } |
| 124 | + }, |
| 125 | + |
| 126 | + /** |
| 127 | + * Check if Zeptomail API is available and credentials are valid |
| 128 | + */ |
| 129 | + async isAvailable(): Promise<boolean> { |
| 130 | + try { |
| 131 | + // Since Zeptomail doesn't have a dedicated endpoint to check token, |
| 132 | + // we'll just check if token exists and has correct format |
| 133 | + if (options.token && options.token.startsWith('Zoho-enczapikey ')) { |
| 134 | + debug('Token format is valid, assuming Zeptomail is available') |
| 135 | + return true |
| 136 | + } |
| 137 | + |
| 138 | + return false |
| 139 | + } |
| 140 | + catch (error) { |
| 141 | + debug('Error checking availability:', error) |
| 142 | + return false |
| 143 | + } |
| 144 | + }, |
| 145 | + |
| 146 | + /** |
| 147 | + * Send email through Zeptomail API |
| 148 | + * @param emailOpts The email options including Zeptomail-specific features |
| 149 | + */ |
| 150 | + async sendEmail(emailOpts: ZeptomailEmailOptions): Promise<Result<EmailResult>> { |
| 151 | + try { |
| 152 | + // Validate email options |
| 153 | + const validationErrors = validateEmailOptions(emailOpts) |
| 154 | + if (validationErrors.length > 0) { |
| 155 | + return { |
| 156 | + success: false, |
| 157 | + error: createError( |
| 158 | + PROVIDER_NAME, |
| 159 | + `Invalid email options: ${validationErrors.join(', ')}`, |
| 160 | + ), |
| 161 | + } |
| 162 | + } |
| 163 | + |
| 164 | + // Make sure provider is initialized |
| 165 | + if (!isInitialized) { |
| 166 | + await this.initialize() |
| 167 | + } |
| 168 | + |
| 169 | + // Format a single EmailAddress for Zeptomail |
| 170 | + const formatSingleAddress = (address: EmailAddress) => { |
| 171 | + return { |
| 172 | + address: address.email, |
| 173 | + name: address.name || undefined, |
| 174 | + } |
| 175 | + } |
| 176 | + |
| 177 | + // Format array of email addresses for Zeptomail |
| 178 | + const formatEmailAddresses = (addresses: EmailAddress | EmailAddress[]) => { |
| 179 | + const addressList = Array.isArray(addresses) ? addresses : [addresses] |
| 180 | + return addressList.map(addr => ({ |
| 181 | + email_address: formatSingleAddress(addr), |
| 182 | + })) |
| 183 | + } |
| 184 | + |
| 185 | + // Prepare request payload |
| 186 | + const payload: Record<string, any> = { |
| 187 | + from: formatSingleAddress(emailOpts.from), |
| 188 | + to: formatEmailAddresses(emailOpts.to), |
| 189 | + subject: emailOpts.subject, |
| 190 | + } |
| 191 | + |
| 192 | + // Add text body if present |
| 193 | + if (emailOpts.text) { |
| 194 | + payload.textbody = emailOpts.text |
| 195 | + } |
| 196 | + |
| 197 | + // Add HTML body if present |
| 198 | + if (emailOpts.html) { |
| 199 | + payload.htmlbody = emailOpts.html |
| 200 | + } |
| 201 | + |
| 202 | + // Add CC if present |
| 203 | + if (emailOpts.cc) { |
| 204 | + payload.cc = formatEmailAddresses(emailOpts.cc) |
| 205 | + } |
| 206 | + |
| 207 | + // Add BCC if present |
| 208 | + if (emailOpts.bcc) { |
| 209 | + payload.bcc = formatEmailAddresses(emailOpts.bcc) |
| 210 | + } |
| 211 | + |
| 212 | + // Add reply-to if present |
| 213 | + if (emailOpts.replyTo) { |
| 214 | + payload.reply_to = [formatSingleAddress(emailOpts.replyTo)] |
| 215 | + } |
| 216 | + |
| 217 | + // Add tracking options if present |
| 218 | + if (emailOpts.trackClicks !== undefined) { |
| 219 | + payload.track_clicks = emailOpts.trackClicks |
| 220 | + } |
| 221 | + |
| 222 | + if (emailOpts.trackOpens !== undefined) { |
| 223 | + payload.track_opens = emailOpts.trackOpens |
| 224 | + } |
| 225 | + |
| 226 | + // Add client reference if present |
| 227 | + if (emailOpts.clientReference) { |
| 228 | + payload.client_reference = emailOpts.clientReference |
| 229 | + } |
| 230 | + |
| 231 | + // Add MIME headers if present |
| 232 | + if (emailOpts.mimeHeaders && Object.keys(emailOpts.mimeHeaders).length > 0) { |
| 233 | + payload.mime_headers = Object.entries(emailOpts.mimeHeaders).reduce((acc, [key, value]) => { |
| 234 | + acc[key] = value |
| 235 | + return acc |
| 236 | + }, {} as Record<string, string>) |
| 237 | + } |
| 238 | + |
| 239 | + // Add custom headers if present |
| 240 | + if (emailOpts.headers && Object.keys(emailOpts.headers).length > 0) { |
| 241 | + // Zeptomail doesn't have a dedicated field for custom headers, so we'll merge them into mime_headers |
| 242 | + if (!payload.mime_headers) { |
| 243 | + payload.mime_headers = {} |
| 244 | + } |
| 245 | + |
| 246 | + Object.entries(emailOpts.headers).forEach(([key, value]) => { |
| 247 | + payload.mime_headers[key] = value |
| 248 | + }) |
| 249 | + } |
| 250 | + |
| 251 | + // Add attachments if present |
| 252 | + if (emailOpts.attachments && emailOpts.attachments.length > 0) { |
| 253 | + payload.attachments = emailOpts.attachments.map((attachment) => { |
| 254 | + const attachmentData: Record<string, any> = { |
| 255 | + name: attachment.filename, |
| 256 | + } |
| 257 | + |
| 258 | + // Use content if provided |
| 259 | + if (attachment.content) { |
| 260 | + attachmentData.content = typeof attachment.content === 'string' |
| 261 | + ? attachment.content |
| 262 | + : attachment.content.toString('base64') |
| 263 | + |
| 264 | + if (attachment.contentType) { |
| 265 | + attachmentData.mime_type = attachment.contentType |
| 266 | + } |
| 267 | + } |
| 268 | + // Or use file_cache_key if available (assuming this is something supported by Zeptomail) |
| 269 | + else if (attachment.path) { |
| 270 | + attachmentData.file_cache_key = attachment.path |
| 271 | + } |
| 272 | + |
| 273 | + return attachmentData |
| 274 | + }) |
| 275 | + } |
| 276 | + |
| 277 | + debug('Sending email via Zeptomail API', { |
| 278 | + to: payload.to, |
| 279 | + subject: payload.subject, |
| 280 | + }) |
| 281 | + |
| 282 | + // Create headers with API token |
| 283 | + const headers: Record<string, string> = { |
| 284 | + 'Authorization': options.token, |
| 285 | + 'Content-Type': 'application/json', |
| 286 | + 'Accept': 'application/json', |
| 287 | + } |
| 288 | + |
| 289 | + // Send request with retry capability |
| 290 | + const result = await retry( |
| 291 | + async () => makeRequest( |
| 292 | + `${options.endpoint}/email`, |
| 293 | + { |
| 294 | + method: 'POST', |
| 295 | + headers, |
| 296 | + timeout: options.timeout, |
| 297 | + }, |
| 298 | + JSON.stringify(payload), |
| 299 | + ), |
| 300 | + options.retries, |
| 301 | + ) |
| 302 | + |
| 303 | + if (!result.success) { |
| 304 | + debug('API request failed', result.error) |
| 305 | + |
| 306 | + // Enhanced error messages based on response |
| 307 | + let errorMessage = result.error?.message || 'Unknown error' |
| 308 | + |
| 309 | + // Try to extract any error details from the response body |
| 310 | + if (result.data?.body?.message) { |
| 311 | + errorMessage += ` Details: ${result.data.body.message}` |
| 312 | + } |
| 313 | + else if (result.data?.body?.error?.message) { |
| 314 | + errorMessage += ` Details: ${result.data.body.error.message}` |
| 315 | + } |
| 316 | + |
| 317 | + return { |
| 318 | + success: false, |
| 319 | + error: createError( |
| 320 | + PROVIDER_NAME, |
| 321 | + `Failed to send email: ${errorMessage}`, |
| 322 | + { cause: result.error }, |
| 323 | + ), |
| 324 | + } |
| 325 | + } |
| 326 | + |
| 327 | + // Extract information from response |
| 328 | + const responseData = result.data.body |
| 329 | + // Zeptomail returns a request_id in the successful response |
| 330 | + const messageId = responseData?.request_id || generateMessageId() |
| 331 | + |
| 332 | + debug('Email sent successfully', { messageId }) |
| 333 | + return { |
| 334 | + success: true, |
| 335 | + data: { |
| 336 | + messageId, |
| 337 | + sent: true, |
| 338 | + timestamp: new Date(), |
| 339 | + provider: PROVIDER_NAME, |
| 340 | + response: responseData, |
| 341 | + }, |
| 342 | + } |
| 343 | + } |
| 344 | + catch (error) { |
| 345 | + debug('Exception sending email', error) |
| 346 | + return { |
| 347 | + success: false, |
| 348 | + error: createError( |
| 349 | + PROVIDER_NAME, |
| 350 | + `Failed to send email: ${(error as Error).message}`, |
| 351 | + { cause: error as Error }, |
| 352 | + ), |
| 353 | + } |
| 354 | + } |
| 355 | + }, |
| 356 | + |
| 357 | + /** |
| 358 | + * Validate API credentials |
| 359 | + */ |
| 360 | + async validateCredentials(): Promise<boolean> { |
| 361 | + return this.isAvailable() |
| 362 | + }, |
| 363 | + } |
| 364 | +}) |
0 commit comments