@nixxie-cms/email 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +23 -0
- package/dist/declarations/src/EmailService.d.ts +29 -0
- package/dist/declarations/src/EmailService.d.ts.map +1 -0
- package/dist/declarations/src/index.d.ts +7 -0
- package/dist/declarations/src/index.d.ts.map +1 -0
- package/dist/declarations/src/types.d.ts +193 -0
- package/dist/declarations/src/types.d.ts.map +1 -0
- package/dist/nixxie-cms-email.cjs.d.ts +2 -0
- package/dist/nixxie-cms-email.cjs.js +994 -0
- package/dist/nixxie-cms-email.esm.js +983 -0
- package/package.json +41 -0
- package/src/EmailService.ts +263 -0
- package/src/index.ts +35 -0
- package/src/queue/EmailQueue.ts +134 -0
- package/src/queue/index.ts +1 -0
- package/src/templates/HandlebarsEngine.ts +152 -0
- package/src/templates/PugEngine.ts +101 -0
- package/src/templates/TemplateManager.ts +180 -0
- package/src/templates/index.ts +4 -0
- package/src/transports/ConsoleTransport.ts +50 -0
- package/src/transports/MailgunTransport.ts +58 -0
- package/src/transports/ResendTransport.ts +83 -0
- package/src/transports/SendGridTransport.ts +54 -0
- package/src/transports/SesTransport.ts +58 -0
- package/src/transports/SmtpTransport.ts +66 -0
- package/src/transports/index.ts +30 -0
- package/src/types.ts +279 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import type pug from 'pug'
|
|
4
|
+
import type { EmailTemplatesConfig } from '../types'
|
|
5
|
+
|
|
6
|
+
type PugModule = typeof pug
|
|
7
|
+
|
|
8
|
+
let pugModule: PugModule | undefined
|
|
9
|
+
|
|
10
|
+
function getPug(): PugModule {
|
|
11
|
+
if (!pugModule) {
|
|
12
|
+
try {
|
|
13
|
+
pugModule = require('pug') as PugModule
|
|
14
|
+
} catch {
|
|
15
|
+
throw new Error('Pug is not installed. Run: npm install pug')
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return pugModule
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class PugEngine {
|
|
22
|
+
private cache = new Map<string, pug.compileTemplate>()
|
|
23
|
+
private config: EmailTemplatesConfig
|
|
24
|
+
|
|
25
|
+
constructor(config: EmailTemplatesConfig) {
|
|
26
|
+
this.config = config
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async render(templateName: string, data: Record<string, unknown>): Promise<string> {
|
|
30
|
+
const p = getPug()
|
|
31
|
+
const compiled = this.getCompiled(templateName, p)
|
|
32
|
+
const layout = this.config.layouts ? this.resolveLayout(templateName) : null
|
|
33
|
+
|
|
34
|
+
const renderData: Record<string, unknown> = { ...data }
|
|
35
|
+
if (layout) {
|
|
36
|
+
renderData['layout'] = layout
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return compiled(renderData)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private getCompiled(templateName: string, p: PugModule): pug.compileTemplate {
|
|
43
|
+
const cacheKey = `template:${templateName}`
|
|
44
|
+
if (this.config.cache !== false && this.cache.has(cacheKey)) {
|
|
45
|
+
return this.cache.get(cacheKey)!
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const filePath = this.resolveTemplatePath(templateName)
|
|
49
|
+
const engineOptions = this.config.engineOptions?.pug ?? {}
|
|
50
|
+
|
|
51
|
+
const compiled = p.compileFile(filePath, {
|
|
52
|
+
...engineOptions,
|
|
53
|
+
filename: filePath,
|
|
54
|
+
cache: false,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
if (this.config.cache !== false) {
|
|
58
|
+
this.cache.set(cacheKey, compiled)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return compiled
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private resolveTemplatePath(templateName: string): string {
|
|
65
|
+
if (!this.config.dir) {
|
|
66
|
+
throw new Error('templates.dir must be set to use template rendering')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const candidates = [
|
|
70
|
+
path.join(this.config.dir, `${templateName}.pug`),
|
|
71
|
+
path.join(this.config.dir, `${templateName}.jade`),
|
|
72
|
+
path.join(this.config.dir, templateName, 'html.pug'),
|
|
73
|
+
path.join(this.config.dir, templateName, 'index.pug'),
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
for (const candidate of candidates) {
|
|
77
|
+
if (fs.existsSync(candidate)) return candidate
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
throw new Error(`Pug template not found: ${templateName} (searched in ${this.config.dir})`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private resolveLayout(templateName: string): string | null {
|
|
84
|
+
if (!this.config.layouts) return null
|
|
85
|
+
|
|
86
|
+
if (this.config.dir) {
|
|
87
|
+
const perTemplate = path.join(this.config.dir, templateName, 'layout.pug')
|
|
88
|
+
if (fs.existsSync(perTemplate)) return perTemplate
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Default layout: templates/layouts/default.pug
|
|
92
|
+
const defaultLayout = path.join(this.config.dir, 'layouts', 'default.pug')
|
|
93
|
+
if (fs.existsSync(defaultLayout)) return defaultLayout
|
|
94
|
+
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
clearCache() {
|
|
99
|
+
this.cache.clear()
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import type { EmailTemplatesConfig } from '../types'
|
|
4
|
+
import { HandlebarsEngine } from './HandlebarsEngine'
|
|
5
|
+
import { PugEngine } from './PugEngine'
|
|
6
|
+
|
|
7
|
+
export type RenderedEmail = {
|
|
8
|
+
html: string
|
|
9
|
+
text: string | undefined
|
|
10
|
+
subject: string | undefined
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class TemplateManager {
|
|
14
|
+
private handlebars: HandlebarsEngine | undefined
|
|
15
|
+
private pug: PugEngine | undefined
|
|
16
|
+
private config: EmailTemplatesConfig
|
|
17
|
+
|
|
18
|
+
constructor(config: EmailTemplatesConfig) {
|
|
19
|
+
this.config = config
|
|
20
|
+
const engine = config.engine ?? 'handlebars'
|
|
21
|
+
if (engine === 'handlebars' || engine === 'auto') {
|
|
22
|
+
this.handlebars = new HandlebarsEngine(config)
|
|
23
|
+
}
|
|
24
|
+
if (engine === 'pug' || engine === 'auto') {
|
|
25
|
+
this.pug = new PugEngine(config)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async render(templateName: string, data: Record<string, unknown>): Promise<RenderedEmail> {
|
|
30
|
+
const engine = this.detectEngine(templateName)
|
|
31
|
+
|
|
32
|
+
switch (engine) {
|
|
33
|
+
case 'handlebars':
|
|
34
|
+
return this.renderWithHandlebars(templateName, data)
|
|
35
|
+
case 'pug':
|
|
36
|
+
return this.renderWithPug(templateName, data)
|
|
37
|
+
default:
|
|
38
|
+
throw new Error(`No template engine configured for template: ${templateName}`)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private detectEngine(templateName: string): 'handlebars' | 'pug' | null {
|
|
43
|
+
const configuredEngine = this.config.engine ?? 'handlebars'
|
|
44
|
+
|
|
45
|
+
if (configuredEngine === 'auto' || configuredEngine === 'handlebars') {
|
|
46
|
+
if (this.config.dir) {
|
|
47
|
+
const hbsCandidates = [
|
|
48
|
+
path.join(this.config.dir, `${templateName}.hbs`),
|
|
49
|
+
path.join(this.config.dir, `${templateName}.handlebars`),
|
|
50
|
+
path.join(this.config.dir, templateName, 'html.hbs'),
|
|
51
|
+
path.join(this.config.dir, templateName, 'html.handlebars'),
|
|
52
|
+
]
|
|
53
|
+
if (hbsCandidates.some(p => fs.existsSync(p))) return 'handlebars'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (configuredEngine === 'auto' || configuredEngine === 'pug') {
|
|
58
|
+
if (this.config.dir) {
|
|
59
|
+
const pugCandidates = [
|
|
60
|
+
path.join(this.config.dir, `${templateName}.pug`),
|
|
61
|
+
path.join(this.config.dir, `${templateName}.jade`),
|
|
62
|
+
path.join(this.config.dir, templateName, 'html.pug'),
|
|
63
|
+
path.join(this.config.dir, templateName, 'index.pug'),
|
|
64
|
+
]
|
|
65
|
+
if (pugCandidates.some(p => fs.existsSync(p))) return 'pug'
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (configuredEngine !== 'auto') return configuredEngine
|
|
70
|
+
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private async renderWithHandlebars(
|
|
75
|
+
templateName: string,
|
|
76
|
+
data: Record<string, unknown>
|
|
77
|
+
): Promise<RenderedEmail> {
|
|
78
|
+
if (!this.handlebars) {
|
|
79
|
+
this.handlebars = new HandlebarsEngine(this.config)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const html = await this.handlebars.render(templateName, data)
|
|
83
|
+
const text = await this.renderTextVersion(templateName, data, 'handlebars')
|
|
84
|
+
const subject = await this.renderSubject(templateName, data, 'handlebars')
|
|
85
|
+
|
|
86
|
+
return { html, text, subject }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private async renderWithPug(
|
|
90
|
+
templateName: string,
|
|
91
|
+
data: Record<string, unknown>
|
|
92
|
+
): Promise<RenderedEmail> {
|
|
93
|
+
if (!this.pug) {
|
|
94
|
+
this.pug = new PugEngine(this.config)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const html = await this.pug.render(templateName, data)
|
|
98
|
+
const text = await this.renderTextVersion(templateName, data, 'pug')
|
|
99
|
+
const subject = await this.renderSubject(templateName, data, 'pug')
|
|
100
|
+
|
|
101
|
+
return { html, text, subject }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async renderTextVersion(
|
|
105
|
+
templateName: string,
|
|
106
|
+
data: Record<string, unknown>,
|
|
107
|
+
engine: 'handlebars' | 'pug'
|
|
108
|
+
): Promise<string | undefined> {
|
|
109
|
+
if (!this.config.dir) return undefined
|
|
110
|
+
|
|
111
|
+
const textPaths = engine === 'handlebars'
|
|
112
|
+
? [
|
|
113
|
+
path.join(this.config.dir, templateName, 'text.hbs'),
|
|
114
|
+
path.join(this.config.dir, templateName, 'text.handlebars'),
|
|
115
|
+
path.join(this.config.dir, `${templateName}.text.hbs`),
|
|
116
|
+
]
|
|
117
|
+
: [
|
|
118
|
+
path.join(this.config.dir, templateName, 'text.pug'),
|
|
119
|
+
path.join(this.config.dir, `${templateName}.text.pug`),
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
const textFile = textPaths.find(p => fs.existsSync(p))
|
|
123
|
+
if (!textFile) return undefined
|
|
124
|
+
|
|
125
|
+
// Render a plain-text variant using the same engine but without layout
|
|
126
|
+
const textConfig = { ...this.config, layouts: undefined }
|
|
127
|
+
try {
|
|
128
|
+
if (engine === 'handlebars') {
|
|
129
|
+
const eng = new HandlebarsEngine(textConfig)
|
|
130
|
+
return await eng.render(path.relative(this.config.dir!, textFile).replace(/\\/g, '/').replace(/\.(hbs|handlebars)$/, ''), data)
|
|
131
|
+
} else {
|
|
132
|
+
const eng = new PugEngine(textConfig)
|
|
133
|
+
return await eng.render(path.relative(this.config.dir!, textFile).replace(/\\/g, '/').replace(/\.(pug|jade)$/, ''), data)
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Fall back to reading the file as plain template
|
|
137
|
+
return fs.readFileSync(textFile, 'utf-8')
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async renderSubject(
|
|
142
|
+
templateName: string,
|
|
143
|
+
data: Record<string, unknown>,
|
|
144
|
+
engine: 'handlebars' | 'pug'
|
|
145
|
+
): Promise<string | undefined> {
|
|
146
|
+
if (!this.config.dir) return undefined
|
|
147
|
+
|
|
148
|
+
const subjectPaths = engine === 'handlebars'
|
|
149
|
+
? [
|
|
150
|
+
path.join(this.config.dir, templateName, 'subject.hbs'),
|
|
151
|
+
path.join(this.config.dir, templateName, 'subject.handlebars'),
|
|
152
|
+
]
|
|
153
|
+
: [
|
|
154
|
+
path.join(this.config.dir, templateName, 'subject.pug'),
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
const subjectFile = subjectPaths.find(p => fs.existsSync(p))
|
|
158
|
+
if (!subjectFile) return undefined
|
|
159
|
+
|
|
160
|
+
const subjectConfig = { ...this.config, layouts: undefined }
|
|
161
|
+
try {
|
|
162
|
+
if (engine === 'handlebars') {
|
|
163
|
+
const eng = new HandlebarsEngine(subjectConfig)
|
|
164
|
+
const relative = path.relative(this.config.dir!, subjectFile).replace(/\\/g, '/').replace(/\.(hbs|handlebars)$/, '')
|
|
165
|
+
return (await eng.render(relative, data)).trim()
|
|
166
|
+
} else {
|
|
167
|
+
const eng = new PugEngine(subjectConfig)
|
|
168
|
+
const relative = path.relative(this.config.dir!, subjectFile).replace(/\\/g, '/').replace(/\.(pug|jade)$/, '')
|
|
169
|
+
return (await eng.render(relative, data)).trim()
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
return undefined
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
clearCache() {
|
|
177
|
+
this.handlebars?.clearCache()
|
|
178
|
+
this.pug?.clearCache()
|
|
179
|
+
}
|
|
180
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { EmailTransporter, TransportMessage, TransportResult } from '../types'
|
|
2
|
+
|
|
3
|
+
let messageCounter = 0
|
|
4
|
+
|
|
5
|
+
export class ConsoleTransport implements EmailTransporter {
|
|
6
|
+
async send(message: TransportMessage): Promise<TransportResult> {
|
|
7
|
+
const id = `console-${Date.now()}-${++messageCounter}`
|
|
8
|
+
|
|
9
|
+
const separator = '─'.repeat(60)
|
|
10
|
+
console.log(`\n${separator}`)
|
|
11
|
+
console.log('📧 [Nixxie Email — Development Mode]')
|
|
12
|
+
console.log(separator)
|
|
13
|
+
console.log(` From: ${message.from}`)
|
|
14
|
+
console.log(` To: ${message.to.join(', ')}`)
|
|
15
|
+
if (message.cc?.length) console.log(` CC: ${message.cc.join(', ')}`)
|
|
16
|
+
if (message.bcc?.length) console.log(` BCC: ${message.bcc.join(', ')}`)
|
|
17
|
+
if (message.replyTo?.length) console.log(` Reply-To: ${message.replyTo.join(', ')}`)
|
|
18
|
+
console.log(` Subject: ${message.subject}`)
|
|
19
|
+
console.log(` ID: ${id}`)
|
|
20
|
+
if (message.attachments?.length) {
|
|
21
|
+
console.log(` Attachments:`)
|
|
22
|
+
for (const a of message.attachments) {
|
|
23
|
+
console.log(` • ${a.filename ?? a.path ?? 'unnamed'}`)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (message.text) {
|
|
27
|
+
console.log(`\n Text body:\n ${message.text.slice(0, 300).replace(/\n/g, '\n ')}`)
|
|
28
|
+
}
|
|
29
|
+
if (message.html) {
|
|
30
|
+
const truncated = message.html.replace(/<[^>]+>/g, '').trim().slice(0, 200)
|
|
31
|
+
console.log(`\n HTML preview:\n ${truncated}…`)
|
|
32
|
+
}
|
|
33
|
+
console.log(`${separator}\n`)
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
messageId: id,
|
|
37
|
+
accepted: message.to,
|
|
38
|
+
rejected: [],
|
|
39
|
+
response: 'logged',
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async verify(): Promise<boolean> {
|
|
44
|
+
return true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async close(): Promise<void> {
|
|
48
|
+
// nothing to close
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer'
|
|
2
|
+
import type { EmailTransporter, MailgunTransportConfig, TransportMessage, TransportResult } from '../types'
|
|
3
|
+
|
|
4
|
+
export class MailgunTransport implements EmailTransporter {
|
|
5
|
+
private transporter: nodemailer.Transporter
|
|
6
|
+
|
|
7
|
+
constructor(config: MailgunTransportConfig) {
|
|
8
|
+
const host =
|
|
9
|
+
config.region === 'eu'
|
|
10
|
+
? 'smtp.eu.mailgun.org'
|
|
11
|
+
: 'smtp.mailgun.org'
|
|
12
|
+
|
|
13
|
+
this.transporter = nodemailer.createTransport({
|
|
14
|
+
host,
|
|
15
|
+
port: 587,
|
|
16
|
+
secure: false,
|
|
17
|
+
auth: {
|
|
18
|
+
user: `postmaster@${config.domain}`,
|
|
19
|
+
pass: config.apiKey,
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async send(message: TransportMessage): Promise<TransportResult> {
|
|
25
|
+
const info = await this.transporter.sendMail({
|
|
26
|
+
from: message.from,
|
|
27
|
+
to: message.to.join(', '),
|
|
28
|
+
cc: message.cc?.join(', '),
|
|
29
|
+
bcc: message.bcc?.join(', '),
|
|
30
|
+
replyTo: message.replyTo?.join(', '),
|
|
31
|
+
subject: message.subject,
|
|
32
|
+
html: message.html,
|
|
33
|
+
text: message.text,
|
|
34
|
+
attachments: message.attachments,
|
|
35
|
+
headers: message.headers,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
messageId: info.messageId,
|
|
40
|
+
accepted: (info.accepted as string[]) ?? message.to,
|
|
41
|
+
rejected: (info.rejected as string[]) ?? [],
|
|
42
|
+
response: info.response,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async verify(): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
await this.transporter.verify()
|
|
49
|
+
return true
|
|
50
|
+
} catch {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async close(): Promise<void> {
|
|
56
|
+
this.transporter.close()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { EmailTransporter, ResendTransportConfig, TransportMessage, TransportResult } from '../types'
|
|
2
|
+
|
|
3
|
+
type ResendSendResponse = {
|
|
4
|
+
id: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
type ResendErrorResponse = {
|
|
8
|
+
name: string
|
|
9
|
+
message: string
|
|
10
|
+
statusCode: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class ResendTransport implements EmailTransporter {
|
|
14
|
+
private readonly apiKey: string
|
|
15
|
+
|
|
16
|
+
constructor(config: ResendTransportConfig) {
|
|
17
|
+
this.apiKey = config.apiKey
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async send(message: TransportMessage): Promise<TransportResult> {
|
|
21
|
+
const body = {
|
|
22
|
+
from: message.from,
|
|
23
|
+
to: message.to,
|
|
24
|
+
cc: message.cc,
|
|
25
|
+
bcc: message.bcc,
|
|
26
|
+
reply_to: message.replyTo,
|
|
27
|
+
subject: message.subject,
|
|
28
|
+
html: message.html,
|
|
29
|
+
text: message.text,
|
|
30
|
+
headers: message.headers,
|
|
31
|
+
attachments: message.attachments?.map(a => ({
|
|
32
|
+
filename: a.filename,
|
|
33
|
+
// Resend expects attachment `content` as base64; encode raw string/Buffer content.
|
|
34
|
+
content:
|
|
35
|
+
a.content == null
|
|
36
|
+
? undefined
|
|
37
|
+
: Buffer.isBuffer(a.content)
|
|
38
|
+
? a.content.toString('base64')
|
|
39
|
+
: Buffer.from(a.content, 'utf-8').toString('base64'),
|
|
40
|
+
path: a.path,
|
|
41
|
+
})),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await fetch(`${this.baseUrl}/emails`, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: {
|
|
47
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
48
|
+
'Content-Type': 'application/json',
|
|
49
|
+
},
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const json = await response.json() as ResendSendResponse | ResendErrorResponse
|
|
54
|
+
|
|
55
|
+
if (!response.ok) {
|
|
56
|
+
const err = json as ResendErrorResponse
|
|
57
|
+
throw new Error(`Resend API error ${response.status}: ${err.message}`)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const data = json as ResendSendResponse
|
|
61
|
+
return {
|
|
62
|
+
messageId: data.id,
|
|
63
|
+
accepted: message.to,
|
|
64
|
+
rejected: [],
|
|
65
|
+
response: `${response.status} ${response.statusText}`,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async verify(): Promise<boolean> {
|
|
70
|
+
try {
|
|
71
|
+
const response = await fetch(`${this.baseUrl}/domains`, {
|
|
72
|
+
headers: { Authorization: `Bearer ${this.apiKey}` },
|
|
73
|
+
})
|
|
74
|
+
return response.ok
|
|
75
|
+
} catch {
|
|
76
|
+
return false
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async close(): Promise<void> {
|
|
81
|
+
// No persistent connections to close
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer'
|
|
2
|
+
import type { EmailTransporter, SendGridTransportConfig, TransportMessage, TransportResult } from '../types'
|
|
3
|
+
|
|
4
|
+
export class SendGridTransport implements EmailTransporter {
|
|
5
|
+
private transporter: nodemailer.Transporter
|
|
6
|
+
|
|
7
|
+
constructor(config: SendGridTransportConfig) {
|
|
8
|
+
// SendGrid supports nodemailer via their SMTP relay
|
|
9
|
+
this.transporter = nodemailer.createTransport({
|
|
10
|
+
host: 'smtp.sendgrid.net',
|
|
11
|
+
port: 587,
|
|
12
|
+
secure: false,
|
|
13
|
+
auth: {
|
|
14
|
+
user: 'apikey',
|
|
15
|
+
pass: config.apiKey,
|
|
16
|
+
},
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async send(message: TransportMessage): Promise<TransportResult> {
|
|
21
|
+
const info = await this.transporter.sendMail({
|
|
22
|
+
from: message.from,
|
|
23
|
+
to: message.to.join(', '),
|
|
24
|
+
cc: message.cc?.join(', '),
|
|
25
|
+
bcc: message.bcc?.join(', '),
|
|
26
|
+
replyTo: message.replyTo?.join(', '),
|
|
27
|
+
subject: message.subject,
|
|
28
|
+
html: message.html,
|
|
29
|
+
text: message.text,
|
|
30
|
+
attachments: message.attachments,
|
|
31
|
+
headers: message.headers,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
messageId: info.messageId,
|
|
36
|
+
accepted: (info.accepted as string[]) ?? message.to,
|
|
37
|
+
rejected: (info.rejected as string[]) ?? [],
|
|
38
|
+
response: info.response,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async verify(): Promise<boolean> {
|
|
43
|
+
try {
|
|
44
|
+
await this.transporter.verify()
|
|
45
|
+
return true
|
|
46
|
+
} catch {
|
|
47
|
+
return false
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async close(): Promise<void> {
|
|
52
|
+
this.transporter.close()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer'
|
|
2
|
+
import type { EmailTransporter, SesTransportConfig, TransportMessage, TransportResult } from '../types'
|
|
3
|
+
|
|
4
|
+
export class SesTransport implements EmailTransporter {
|
|
5
|
+
private transporter: nodemailer.Transporter
|
|
6
|
+
|
|
7
|
+
constructor(config: SesTransportConfig) {
|
|
8
|
+
// Uses SES SMTP interface — credentials are SES SMTP credentials (different from IAM keys)
|
|
9
|
+
// See: https://docs.aws.amazon.com/ses/latest/dg/smtp-credentials.html
|
|
10
|
+
this.transporter = nodemailer.createTransport({
|
|
11
|
+
host: `email-smtp.${config.region}.amazonaws.com`,
|
|
12
|
+
port: 587,
|
|
13
|
+
secure: false,
|
|
14
|
+
auth: config.credentials
|
|
15
|
+
? {
|
|
16
|
+
user: config.credentials.accessKeyId,
|
|
17
|
+
pass: config.credentials.secretAccessKey,
|
|
18
|
+
}
|
|
19
|
+
: undefined,
|
|
20
|
+
tls: { ciphers: 'SSLv3' },
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async send(message: TransportMessage): Promise<TransportResult> {
|
|
25
|
+
const info = await this.transporter.sendMail({
|
|
26
|
+
from: message.from,
|
|
27
|
+
to: message.to.join(', '),
|
|
28
|
+
cc: message.cc?.join(', '),
|
|
29
|
+
bcc: message.bcc?.join(', '),
|
|
30
|
+
replyTo: message.replyTo?.join(', '),
|
|
31
|
+
subject: message.subject,
|
|
32
|
+
html: message.html,
|
|
33
|
+
text: message.text,
|
|
34
|
+
attachments: message.attachments,
|
|
35
|
+
headers: message.headers,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
messageId: info.messageId,
|
|
40
|
+
accepted: (info.accepted as string[]) ?? message.to,
|
|
41
|
+
rejected: (info.rejected as string[]) ?? [],
|
|
42
|
+
response: info.response,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async verify(): Promise<boolean> {
|
|
47
|
+
try {
|
|
48
|
+
await this.transporter.verify()
|
|
49
|
+
return true
|
|
50
|
+
} catch {
|
|
51
|
+
return false
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async close(): Promise<void> {
|
|
56
|
+
this.transporter.close()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer'
|
|
2
|
+
import type { EmailTransporter, SmtpTransportConfig, TransportMessage, TransportResult } from '../types'
|
|
3
|
+
|
|
4
|
+
export class SmtpTransport implements EmailTransporter {
|
|
5
|
+
private transporter: nodemailer.Transporter
|
|
6
|
+
|
|
7
|
+
constructor(config: SmtpTransportConfig) {
|
|
8
|
+
this.transporter = nodemailer.createTransport({
|
|
9
|
+
host: config.host,
|
|
10
|
+
port: config.port ?? 587,
|
|
11
|
+
secure: config.secure ?? (config.port === 465),
|
|
12
|
+
auth: config.auth,
|
|
13
|
+
tls: config.tls,
|
|
14
|
+
connectionTimeout: config.connectionTimeout ?? 5_000,
|
|
15
|
+
greetingTimeout: config.greetingTimeout ?? 5_000,
|
|
16
|
+
socketTimeout: config.socketTimeout ?? 30_000,
|
|
17
|
+
pool: config.pool ?? false,
|
|
18
|
+
maxConnections: config.maxConnections ?? 5,
|
|
19
|
+
maxMessages: config.maxMessages ?? 100,
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async send(message: TransportMessage): Promise<TransportResult> {
|
|
24
|
+
const info = await this.transporter.sendMail({
|
|
25
|
+
from: message.from,
|
|
26
|
+
to: message.to.join(', '),
|
|
27
|
+
cc: message.cc?.join(', '),
|
|
28
|
+
bcc: message.bcc?.join(', '),
|
|
29
|
+
replyTo: message.replyTo?.join(', '),
|
|
30
|
+
subject: message.subject,
|
|
31
|
+
html: message.html,
|
|
32
|
+
text: message.text,
|
|
33
|
+
attachments: message.attachments?.map(a => ({
|
|
34
|
+
filename: a.filename,
|
|
35
|
+
content: a.content,
|
|
36
|
+
path: a.path,
|
|
37
|
+
href: a.href,
|
|
38
|
+
contentType: a.contentType,
|
|
39
|
+
encoding: a.encoding,
|
|
40
|
+
contentDisposition: a.contentDisposition,
|
|
41
|
+
cid: a.cid,
|
|
42
|
+
})),
|
|
43
|
+
headers: message.headers,
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
messageId: info.messageId,
|
|
48
|
+
accepted: (info.accepted as string[]) ?? [],
|
|
49
|
+
rejected: (info.rejected as string[]) ?? [],
|
|
50
|
+
response: info.response,
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async verify(): Promise<boolean> {
|
|
55
|
+
try {
|
|
56
|
+
await this.transporter.verify()
|
|
57
|
+
return true
|
|
58
|
+
} catch {
|
|
59
|
+
return false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async close(): Promise<void> {
|
|
64
|
+
this.transporter.close()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { EmailTransportConfig, EmailTransporter } from '../types'
|
|
2
|
+
import { ConsoleTransport } from './ConsoleTransport'
|
|
3
|
+
import { MailgunTransport } from './MailgunTransport'
|
|
4
|
+
import { ResendTransport } from './ResendTransport'
|
|
5
|
+
import { SendGridTransport } from './SendGridTransport'
|
|
6
|
+
import { SesTransport } from './SesTransport'
|
|
7
|
+
import { SmtpTransport } from './SmtpTransport'
|
|
8
|
+
|
|
9
|
+
export function createTransport(config: EmailTransportConfig): EmailTransporter {
|
|
10
|
+
switch (config.type) {
|
|
11
|
+
case 'smtp':
|
|
12
|
+
return new SmtpTransport(config)
|
|
13
|
+
case 'sendgrid':
|
|
14
|
+
return new SendGridTransport(config)
|
|
15
|
+
case 'mailgun':
|
|
16
|
+
return new MailgunTransport(config)
|
|
17
|
+
case 'ses':
|
|
18
|
+
return new SesTransport(config)
|
|
19
|
+
case 'resend':
|
|
20
|
+
return new ResendTransport(config)
|
|
21
|
+
case 'console':
|
|
22
|
+
return new ConsoleTransport()
|
|
23
|
+
default: {
|
|
24
|
+
const exhaustiveCheck: never = config
|
|
25
|
+
throw new Error(`Unknown email transport type: ${(exhaustiveCheck as any).type}`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { SmtpTransport, SendGridTransport, MailgunTransport, SesTransport, ResendTransport, ConsoleTransport }
|