Skip to content

Commit dda3dbd

Browse files
feat: add new provider support zeptomail (#11)
* chore: Add new provider support zeptomail * fix(zeptomail): improve debug logging and formatting consistency in provider --------- Co-authored-by: productdevbook <hi@productdevbook.com>
1 parent fa446f4 commit dda3dbd

File tree

3 files changed

+394
-0
lines changed

3 files changed

+394
-0
lines changed

src/providers/zeptomail/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { zeptomailProvider } from './provider.ts'
2+
3+
// Export the provider directly as default export
4+
export default zeptomailProvider
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
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+
})

src/providers/zeptomail/types.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { EmailOptions } from 'unemail/types'
2+
3+
/**
4+
* Zeptomail-specific email options
5+
*/
6+
export interface ZeptomailEmailOptions extends EmailOptions {
7+
/**
8+
* Track email clicks - enables tracking for click events
9+
*/
10+
trackClicks?: boolean
11+
12+
/**
13+
* Track email opens - enables tracking for open events
14+
*/
15+
trackOpens?: boolean
16+
17+
/**
18+
* Client reference - identifier set by the user to track a particular transaction
19+
*/
20+
clientReference?: string
21+
22+
/**
23+
* MIME headers - additional headers sent in the email for reference
24+
*/
25+
mimeHeaders?: Record<string, string>
26+
}

0 commit comments

Comments
 (0)