Skip to content

Commit 380b39e

Browse files
chore: wip
1 parent 275000d commit 380b39e

File tree

3 files changed

+159
-25
lines changed

3 files changed

+159
-25
lines changed

config/email.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ export default {
2323
scan: true, // scans for spam and viruses
2424
},
2525

26-
// Add driver configuration
2726
default: env.MAIL_DRIVER || 'ses',
2827

2928
drivers: {
@@ -41,11 +40,19 @@ export default {
4140
retryTimeout: env.SENDGRID_RETRY_TIMEOUT ? Number.parseInt(env.SENDGRID_RETRY_TIMEOUT) : 1000,
4241
},
4342

43+
mailgun: {
44+
apiKey: env.MAILGUN_API_KEY,
45+
domain: env.MAILGUN_DOMAIN,
46+
endpoint: env.MAILGUN_ENDPOINT || 'api.mailgun.net',
47+
maxRetries: env.MAILGUN_MAX_RETRIES ? Number.parseInt(env.MAILGUN_MAX_RETRIES) : 3,
48+
retryTimeout: env.MAILGUN_RETRY_TIMEOUT ? Number.parseInt(env.MAILGUN_RETRY_TIMEOUT) : 1000,
49+
},
50+
4451
mailtrap: {
4552
token: env.MAILTRAP_TOKEN,
4653
inboxId: env.MAILTRAP_INBOX_ID,
4754
maxRetries: env.MAILTRAP_MAX_RETRIES ? Number.parseInt(env.MAILTRAP_MAX_RETRIES) : 3,
48-
retryTimeout: env.MAILTRAP_RETRY_TIMEOUT ? Number.parseInt(env.MAILTRAP) : 1000,
55+
retryTimeout: env.MAILTRAP_RETRY_TIMEOUT ? Number.parseInt(env.MAILTRAP_RETRY_TIMEOUT) : 1000,
4956
},
5057
},
51-
} satisfies EmailConfig
58+
} satisfies EmailConfig
Lines changed: 138 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,138 @@
1-
// import { MailgunEmailProvider } from '@novu/mailgun'
2-
// import type { EmailOptions } from '@stacksjs/types'
3-
// import type { ResultAsync } from '@stacksjs/error-handling'
4-
// import { notification } from '@stacksjs/config'
5-
// import { send as sendEmail } from '../send'
6-
//
7-
// const env = notification.email
8-
// const service = notification.email?.drivers.mailgun
9-
//
10-
// const provider = new MailgunEmailProvider({
11-
// apiKey: service?.key || '',
12-
// domain: service?.domain || '',
13-
// username: service?.username || '',
14-
// from: env?.from.address || '',
15-
// })
16-
//
17-
// async function send(options: EmailOptions, css?: string): Promise<ResultAsync<any, Error>> {
18-
// return sendEmail(options, provider, 'Mailgun', css)
19-
// }
20-
//
21-
// export { send as Send, send }
1+
import type { EmailAddress, EmailMessage, EmailResult, RenderOptions } from '@stacksjs/types'
2+
import { Buffer } from 'node:buffer'
3+
import { log } from '@stacksjs/logging'
4+
import { template } from '../template'
5+
import { config } from '@stacksjs/config'
6+
import { BaseEmailDriver } from './base'
7+
8+
export class MailgunDriver extends BaseEmailDriver {
9+
public name = 'mailgun'
10+
private apiKey: string
11+
private domain: string
12+
private endpoint: string
13+
14+
constructor() {
15+
super()
16+
this.apiKey = config.email.drivers?.mailgun?.apiKey ?? ''
17+
this.domain = config.email.drivers?.mailgun?.domain ?? ''
18+
this.endpoint = config.email.drivers?.mailgun?.endpoint ?? 'api.mailgun.net'
19+
}
20+
21+
public async send(message: EmailMessage, options?: RenderOptions): Promise<EmailResult> {
22+
const logContext = {
23+
provider: this.name,
24+
to: message.to,
25+
subject: message.subject,
26+
domain: this.domain,
27+
}
28+
29+
log.info('Sending email via Mailgun...', logContext)
30+
31+
try {
32+
this.validateMessage(message)
33+
const templ = await template(message.template, options)
34+
35+
const formData = new FormData()
36+
formData.append('from', this.formatMailgunAddress(message.from))
37+
38+
// Handle multiple recipients
39+
this.formatMailgunAddresses(message.to).forEach(to => formData.append('to', to))
40+
41+
if (message.cc)
42+
this.formatMailgunAddresses(message.cc).forEach(cc => formData.append('cc', cc))
43+
44+
if (message.bcc)
45+
this.formatMailgunAddresses(message.bcc).forEach(bcc => formData.append('bcc', bcc))
46+
47+
formData.append('subject', message.subject)
48+
formData.append('html', templ.html)
49+
50+
if (message.text)
51+
formData.append('text', message.text)
52+
53+
// Handle attachments
54+
if (message.attachments) {
55+
message.attachments.forEach((attachment) => {
56+
const content = typeof attachment.content === 'string'
57+
? attachment.content
58+
: this.arrayBufferToBase64(attachment.content)
59+
60+
formData.append('attachment', new Blob([content], { type: attachment.contentType }), attachment.filename)
61+
})
62+
}
63+
64+
const response = await this.sendWithRetry(formData)
65+
return this.handleSuccess(message, response.id)
66+
}
67+
catch (error) {
68+
return this.handleError(error, message)
69+
}
70+
}
71+
72+
private formatMailgunAddress(address: EmailAddress): string {
73+
return address.name ? `${address.name} <${address.address}>` : address.address
74+
}
75+
76+
private formatMailgunAddresses(addresses: string | string[] | EmailAddress[] | undefined): string[] {
77+
if (!addresses)
78+
return []
79+
80+
if (typeof addresses === 'string')
81+
return [addresses]
82+
83+
return addresses.map((addr) => {
84+
if (typeof addr === 'string')
85+
return addr
86+
return addr.name ? `${addr.name} <${addr.address}>` : addr.address
87+
})
88+
}
89+
90+
private arrayBufferToBase64(buffer: Uint8Array): string {
91+
let binary = ''
92+
const bytes = new Uint8Array(buffer)
93+
const len = bytes.byteLength
94+
95+
for (let i = 0; i < len; i++) {
96+
binary += String.fromCharCode(bytes[i])
97+
}
98+
99+
return typeof btoa === 'function'
100+
? btoa(binary)
101+
: Buffer.from(binary).toString('base64')
102+
}
103+
104+
private async sendWithRetry(formData: FormData, attempt = 1): Promise<any> {
105+
const url = `https://${this.endpoint}/v3/${this.domain}/messages`
106+
const auth = Buffer.from(`api:${this.apiKey}`).toString('base64')
107+
108+
try {
109+
const response = await fetch(url, {
110+
method: 'POST',
111+
headers: {
112+
Authorization: `Basic ${auth}`,
113+
},
114+
body: formData,
115+
})
116+
117+
if (!response.ok) {
118+
const errorData = await response.json()
119+
throw new Error(`Mailgun API error: ${response.status} - ${JSON.stringify(errorData)}`)
120+
}
121+
122+
const data = await response.json()
123+
log.info(`[${this.name}] Email sent successfully`, { attempt, messageId: data.id })
124+
return data
125+
}
126+
catch (error) {
127+
if (attempt < (config.email.drivers?.mailgun?.maxRetries ?? 3)) {
128+
const retryTimeout = config.email.drivers?.mailgun?.retryTimeout ?? 1000
129+
log.warn(`[${this.name}] Email send failed, retrying (${attempt}/${config.email.drivers?.mailgun?.maxRetries ?? 3})`)
130+
await new Promise(resolve => setTimeout(resolve, retryTimeout))
131+
return this.sendWithRetry(formData, attempt + 1)
132+
}
133+
throw error
134+
}
135+
}
136+
}
137+
138+
export default MailgunDriver

storage/framework/core/types/src/email.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { I18n } from 'vue-email'
22

3+
34
export interface EmailOptions {
45
from: {
56
name: string
@@ -15,7 +16,7 @@ export interface EmailOptions {
1516
scan?: boolean
1617
}
1718

18-
default: 'ses' | 'sendgrid' | 'mailtrap'
19+
default: 'ses' | 'sendgrid' | 'mailgun' | 'mailtrap'
1920

2021
drivers: {
2122
ses?: {
@@ -24,12 +25,21 @@ export interface EmailOptions {
2425
accessKeyId?: string
2526
secretAccessKey?: string
2627
}
28+
maxRetries?: number
29+
retryTimeout?: number
2730
}
2831
sendgrid?: {
2932
apiKey?: string
3033
maxRetries?: number
3134
retryTimeout?: number
3235
}
36+
mailgun?: {
37+
apiKey?: string
38+
domain?: string
39+
endpoint?: string
40+
maxRetries?: number
41+
retryTimeout?: number
42+
}
3343
mailtrap?: {
3444
token?: string
3545
inboxId?: string | number

0 commit comments

Comments
 (0)