@mantiq/mail 0.1.3 → 0.2.0
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/package.json +2 -2
- package/src/MailManager.ts +115 -0
- package/src/MailServiceProvider.ts +14 -0
- package/src/Mailable.ts +148 -0
- package/src/Message.ts +80 -0
- package/src/PendingMail.ts +71 -0
- package/src/commands/MakeMailCommand.ts +55 -0
- package/src/contracts/MailConfig.ts +28 -0
- package/src/contracts/Transport.ts +5 -0
- package/src/drivers/ArrayTransport.ts +17 -0
- package/src/drivers/LogTransport.ts +22 -0
- package/src/drivers/MailgunTransport.ts +94 -0
- package/src/drivers/PostmarkTransport.ts +89 -0
- package/src/drivers/ResendTransport.ts +78 -0
- package/src/drivers/SendGridTransport.ts +102 -0
- package/src/drivers/SesTransport.ts +182 -0
- package/src/drivers/SmtpTransport.ts +344 -0
- package/src/errors/MailError.ts +7 -0
- package/src/helpers/mail.ts +15 -0
- package/src/index.ts +39 -1
- package/src/jobs/SendMailJob.ts +25 -0
- package/src/markdown/MarkdownRenderer.ts +136 -0
- package/src/markdown/theme.ts +145 -0
- package/src/testing/MailFake.ts +87 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { MailTransport } from '../contracts/Transport.ts'
|
|
2
|
+
import { Message } from '../Message.ts'
|
|
3
|
+
import { MailError } from '../errors/MailError.ts'
|
|
4
|
+
|
|
5
|
+
export interface PostmarkConfig {
|
|
6
|
+
serverToken: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class PostmarkTransport implements MailTransport {
|
|
10
|
+
private config: PostmarkConfig
|
|
11
|
+
|
|
12
|
+
constructor(config: PostmarkConfig) {
|
|
13
|
+
this.config = config
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async send(message: Message): Promise<{ id: string }> {
|
|
17
|
+
const body: Record<string, any> = {
|
|
18
|
+
From: Message.formatAddress(message.from),
|
|
19
|
+
To: Message.formatAddresses(message.to),
|
|
20
|
+
Subject: message.subject,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (message.cc.length > 0) {
|
|
24
|
+
body.Cc = Message.formatAddresses(message.cc)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (message.bcc.length > 0) {
|
|
28
|
+
body.Bcc = Message.formatAddresses(message.bcc)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (message.replyTo.length > 0) {
|
|
32
|
+
body.ReplyTo = Message.formatAddresses(message.replyTo)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (message.html !== null) {
|
|
36
|
+
body.HtmlBody = message.html
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (message.text !== null) {
|
|
40
|
+
body.TextBody = message.text
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (message.attachments.length > 0) {
|
|
44
|
+
body.Attachments = message.attachments.map((attachment) => ({
|
|
45
|
+
Name: attachment.filename,
|
|
46
|
+
Content: typeof attachment.content === 'string'
|
|
47
|
+
? Buffer.from(attachment.content).toString('base64')
|
|
48
|
+
: Buffer.from(attachment.content).toString('base64'),
|
|
49
|
+
ContentType: attachment.contentType || 'application/octet-stream',
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Custom headers
|
|
54
|
+
if (Object.keys(message.headers).length > 0) {
|
|
55
|
+
body.Headers = Object.entries(message.headers).map(([Name, Value]) => ({
|
|
56
|
+
Name,
|
|
57
|
+
Value,
|
|
58
|
+
}))
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const response = await fetch('https://api.postmarkapp.com/email', {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: {
|
|
64
|
+
'X-Postmark-Server-Token': this.config.serverToken,
|
|
65
|
+
'Content-Type': 'application/json',
|
|
66
|
+
'Accept': 'application/json',
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify(body),
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (!response.ok) {
|
|
72
|
+
const errorBody = await response.text()
|
|
73
|
+
throw new MailError(`Postmark API error: ${response.status} ${response.statusText}`, {
|
|
74
|
+
status: response.status,
|
|
75
|
+
body: errorBody,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const result = await response.json() as { MessageID: string; ErrorCode: number; Message: string }
|
|
80
|
+
|
|
81
|
+
if (result.ErrorCode !== 0) {
|
|
82
|
+
throw new MailError(`Postmark error: ${result.Message}`, {
|
|
83
|
+
errorCode: result.ErrorCode,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return { id: result.MessageID }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MailTransport } from '../contracts/Transport.ts'
|
|
2
|
+
import { Message } from '../Message.ts'
|
|
3
|
+
import { MailError } from '../errors/MailError.ts'
|
|
4
|
+
|
|
5
|
+
export interface ResendConfig {
|
|
6
|
+
apiKey: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class ResendTransport implements MailTransport {
|
|
10
|
+
private config: ResendConfig
|
|
11
|
+
|
|
12
|
+
constructor(config: ResendConfig) {
|
|
13
|
+
this.config = config
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async send(message: Message): Promise<{ id: string }> {
|
|
17
|
+
const body: Record<string, any> = {
|
|
18
|
+
from: Message.formatAddress(message.from),
|
|
19
|
+
to: message.to.map((addr) => Message.formatAddress(addr)),
|
|
20
|
+
subject: message.subject,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (message.cc.length > 0) {
|
|
24
|
+
body.cc = message.cc.map((addr) => Message.formatAddress(addr))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (message.bcc.length > 0) {
|
|
28
|
+
body.bcc = message.bcc.map((addr) => Message.formatAddress(addr))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (message.replyTo.length > 0) {
|
|
32
|
+
body.reply_to = message.replyTo.map((addr) => Message.formatAddress(addr))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (message.html !== null) {
|
|
36
|
+
body.html = message.html
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (message.text !== null) {
|
|
40
|
+
body.text = message.text
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (message.attachments.length > 0) {
|
|
44
|
+
body.attachments = message.attachments.map((attachment) => ({
|
|
45
|
+
filename: attachment.filename,
|
|
46
|
+
content: typeof attachment.content === 'string'
|
|
47
|
+
? Buffer.from(attachment.content).toString('base64')
|
|
48
|
+
: Buffer.from(attachment.content).toString('base64'),
|
|
49
|
+
type: attachment.contentType || 'application/octet-stream',
|
|
50
|
+
}))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (Object.keys(message.headers).length > 0) {
|
|
54
|
+
body.headers = message.headers
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const response = await fetch('https://api.resend.com/emails', {
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: {
|
|
60
|
+
'Authorization': `Bearer ${this.config.apiKey}`,
|
|
61
|
+
'Content-Type': 'application/json',
|
|
62
|
+
},
|
|
63
|
+
body: JSON.stringify(body),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
if (!response.ok) {
|
|
67
|
+
const errorBody = await response.text()
|
|
68
|
+
throw new MailError(`Resend API error: ${response.status} ${response.statusText}`, {
|
|
69
|
+
status: response.status,
|
|
70
|
+
body: errorBody,
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const result = await response.json() as { id: string }
|
|
75
|
+
|
|
76
|
+
return { id: result.id }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { MailTransport } from '../contracts/Transport.ts'
|
|
2
|
+
import { Message } from '../Message.ts'
|
|
3
|
+
import type { MailAddress } from '../contracts/MailConfig.ts'
|
|
4
|
+
import { MailError } from '../errors/MailError.ts'
|
|
5
|
+
|
|
6
|
+
export interface SendGridConfig {
|
|
7
|
+
apiKey: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class SendGridTransport implements MailTransport {
|
|
11
|
+
private config: SendGridConfig
|
|
12
|
+
|
|
13
|
+
constructor(config: SendGridConfig) {
|
|
14
|
+
this.config = config
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async send(message: Message): Promise<{ id: string }> {
|
|
18
|
+
const personalizations: Record<string, any> = {}
|
|
19
|
+
|
|
20
|
+
personalizations.to = message.to.map((addr) => this.formatAddr(addr))
|
|
21
|
+
|
|
22
|
+
if (message.cc.length > 0) {
|
|
23
|
+
personalizations.cc = message.cc.map((addr) => this.formatAddr(addr))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (message.bcc.length > 0) {
|
|
27
|
+
personalizations.bcc = message.bcc.map((addr) => this.formatAddr(addr))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const body: Record<string, any> = {
|
|
31
|
+
personalizations: [personalizations],
|
|
32
|
+
from: this.formatAddr(message.from),
|
|
33
|
+
subject: message.subject,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (message.replyTo.length > 0) {
|
|
37
|
+
const first = message.replyTo[0]
|
|
38
|
+
if (first) {
|
|
39
|
+
body.reply_to = this.formatAddr(first)
|
|
40
|
+
}
|
|
41
|
+
if (message.replyTo.length > 1) {
|
|
42
|
+
body.reply_to_list = message.replyTo.map((addr) => this.formatAddr(addr))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const content: { type: string; value: string }[] = []
|
|
47
|
+
if (message.text !== null) {
|
|
48
|
+
content.push({ type: 'text/plain', value: message.text })
|
|
49
|
+
}
|
|
50
|
+
if (message.html !== null) {
|
|
51
|
+
content.push({ type: 'text/html', value: message.html })
|
|
52
|
+
}
|
|
53
|
+
if (content.length > 0) {
|
|
54
|
+
body.content = content
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (message.attachments.length > 0) {
|
|
58
|
+
body.attachments = message.attachments.map((attachment) => ({
|
|
59
|
+
filename: attachment.filename,
|
|
60
|
+
content: typeof attachment.content === 'string'
|
|
61
|
+
? Buffer.from(attachment.content).toString('base64')
|
|
62
|
+
: Buffer.from(attachment.content).toString('base64'),
|
|
63
|
+
type: attachment.contentType || 'application/octet-stream',
|
|
64
|
+
disposition: 'attachment',
|
|
65
|
+
}))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (Object.keys(message.headers).length > 0) {
|
|
69
|
+
body.headers = message.headers
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const response = await fetch('https://api.sendgrid.com/v3/mail/send', {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: {
|
|
75
|
+
'Authorization': `Bearer ${this.config.apiKey}`,
|
|
76
|
+
'Content-Type': 'application/json',
|
|
77
|
+
},
|
|
78
|
+
body: JSON.stringify(body),
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const errorBody = await response.text()
|
|
83
|
+
throw new MailError(`SendGrid API error: ${response.status} ${response.statusText}`, {
|
|
84
|
+
status: response.status,
|
|
85
|
+
body: errorBody,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// SendGrid returns the message ID in the x-message-id header
|
|
90
|
+
const messageId = response.headers.get('x-message-id')
|
|
91
|
+
|
|
92
|
+
return { id: messageId || crypto.randomUUID() }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private formatAddr(addr: MailAddress): { email: string; name?: string } {
|
|
96
|
+
const result: { email: string; name?: string } = { email: addr.address }
|
|
97
|
+
if (addr.name) {
|
|
98
|
+
result.name = addr.name
|
|
99
|
+
}
|
|
100
|
+
return result
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { MailTransport } from '../contracts/Transport.ts'
|
|
2
|
+
import { Message } from '../Message.ts'
|
|
3
|
+
import { MailError } from '../errors/MailError.ts'
|
|
4
|
+
|
|
5
|
+
export interface SesConfig {
|
|
6
|
+
region: string
|
|
7
|
+
accessKeyId: string
|
|
8
|
+
secretAccessKey: string
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class SesTransport implements MailTransport {
|
|
12
|
+
private config: SesConfig
|
|
13
|
+
|
|
14
|
+
constructor(config: SesConfig) {
|
|
15
|
+
this.config = config
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async send(message: Message): Promise<{ id: string }> {
|
|
19
|
+
const { region, accessKeyId, secretAccessKey } = this.config
|
|
20
|
+
const host = `email.${region}.amazonaws.com`
|
|
21
|
+
const url = `https://${host}/v2/email/outbound-emails`
|
|
22
|
+
|
|
23
|
+
const destination: Record<string, string[]> = {
|
|
24
|
+
ToAddresses: message.to.map((addr) => Message.formatAddress(addr)),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (message.cc.length > 0) {
|
|
28
|
+
destination.CcAddresses = message.cc.map((addr) => Message.formatAddress(addr))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (message.bcc.length > 0) {
|
|
32
|
+
destination.BccAddresses = message.bcc.map((addr) => Message.formatAddress(addr))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const bodyContent: Record<string, { Data: string; Charset: string }> = {}
|
|
36
|
+
|
|
37
|
+
if (message.html !== null) {
|
|
38
|
+
bodyContent.Html = { Data: message.html, Charset: 'UTF-8' }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (message.text !== null) {
|
|
42
|
+
bodyContent.Text = { Data: message.text, Charset: 'UTF-8' }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const body: Record<string, any> = {
|
|
46
|
+
FromEmailAddress: Message.formatAddress(message.from),
|
|
47
|
+
Destination: destination,
|
|
48
|
+
Content: {
|
|
49
|
+
Simple: {
|
|
50
|
+
Subject: { Data: message.subject, Charset: 'UTF-8' },
|
|
51
|
+
Body: bodyContent,
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (message.replyTo.length > 0) {
|
|
57
|
+
body.ReplyToAddresses = message.replyTo.map((addr) => Message.formatAddress(addr))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (Object.keys(message.headers).length > 0) {
|
|
61
|
+
body.EmailTags = Object.entries(message.headers).map(([Name, Value]) => ({
|
|
62
|
+
Name,
|
|
63
|
+
Value,
|
|
64
|
+
}))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const payload = JSON.stringify(body)
|
|
68
|
+
const now = new Date()
|
|
69
|
+
|
|
70
|
+
// AWS Signature V4
|
|
71
|
+
const dateStamp = this.toDateStamp(now)
|
|
72
|
+
const amzDate = this.toAmzDate(now)
|
|
73
|
+
const service = 'ses'
|
|
74
|
+
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`
|
|
75
|
+
|
|
76
|
+
const canonicalHeaders =
|
|
77
|
+
`content-type:application/json\n` +
|
|
78
|
+
`host:${host}\n` +
|
|
79
|
+
`x-amz-date:${amzDate}\n`
|
|
80
|
+
|
|
81
|
+
const signedHeaders = 'content-type;host;x-amz-date'
|
|
82
|
+
|
|
83
|
+
const payloadHash = await this.sha256Hex(payload)
|
|
84
|
+
|
|
85
|
+
const canonicalRequest = [
|
|
86
|
+
'POST',
|
|
87
|
+
'/v2/email/outbound-emails',
|
|
88
|
+
'', // query string
|
|
89
|
+
canonicalHeaders,
|
|
90
|
+
signedHeaders,
|
|
91
|
+
payloadHash,
|
|
92
|
+
].join('\n')
|
|
93
|
+
|
|
94
|
+
const canonicalRequestHash = await this.sha256Hex(canonicalRequest)
|
|
95
|
+
|
|
96
|
+
const stringToSign = [
|
|
97
|
+
'AWS4-HMAC-SHA256',
|
|
98
|
+
amzDate,
|
|
99
|
+
credentialScope,
|
|
100
|
+
canonicalRequestHash,
|
|
101
|
+
].join('\n')
|
|
102
|
+
|
|
103
|
+
// Derive signing key
|
|
104
|
+
const kDate = await this.hmacSha256(
|
|
105
|
+
new TextEncoder().encode(`AWS4${secretAccessKey}`),
|
|
106
|
+
dateStamp,
|
|
107
|
+
)
|
|
108
|
+
const kRegion = await this.hmacSha256(kDate, region)
|
|
109
|
+
const kService = await this.hmacSha256(kRegion, service)
|
|
110
|
+
const kSigning = await this.hmacSha256(kService, 'aws4_request')
|
|
111
|
+
|
|
112
|
+
const signature = await this.hmacSha256Hex(kSigning, stringToSign)
|
|
113
|
+
|
|
114
|
+
const authorizationHeader =
|
|
115
|
+
`AWS4-HMAC-SHA256 ` +
|
|
116
|
+
`Credential=${accessKeyId}/${credentialScope}, ` +
|
|
117
|
+
`SignedHeaders=${signedHeaders}, ` +
|
|
118
|
+
`Signature=${signature}`
|
|
119
|
+
|
|
120
|
+
const response = await fetch(url, {
|
|
121
|
+
method: 'POST',
|
|
122
|
+
headers: {
|
|
123
|
+
'Content-Type': 'application/json',
|
|
124
|
+
'X-Amz-Date': amzDate,
|
|
125
|
+
'Authorization': authorizationHeader,
|
|
126
|
+
},
|
|
127
|
+
body: payload,
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
if (!response.ok) {
|
|
131
|
+
const errorBody = await response.text()
|
|
132
|
+
throw new MailError(`AWS SES API error: ${response.status} ${response.statusText}`, {
|
|
133
|
+
status: response.status,
|
|
134
|
+
body: errorBody,
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const result = await response.json() as { MessageId: string }
|
|
139
|
+
|
|
140
|
+
return { id: result.MessageId }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private toDateStamp(date: Date): string {
|
|
144
|
+
return date.toISOString().slice(0, 10).replace(/-/g, '')
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private toAmzDate(date: Date): string {
|
|
148
|
+
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '')
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private async sha256Hex(data: string): Promise<string> {
|
|
152
|
+
const encoder = new TextEncoder()
|
|
153
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(data))
|
|
154
|
+
return this.bufferToHex(hashBuffer)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
private async hmacSha256(key: Uint8Array | ArrayBuffer, data: string): Promise<ArrayBuffer> {
|
|
158
|
+
const keyBuffer = key instanceof ArrayBuffer ? key : (key as Uint8Array).buffer as ArrayBuffer
|
|
159
|
+
const cryptoKey = await crypto.subtle.importKey(
|
|
160
|
+
'raw',
|
|
161
|
+
keyBuffer,
|
|
162
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
163
|
+
false,
|
|
164
|
+
['sign'],
|
|
165
|
+
)
|
|
166
|
+
return await crypto.subtle.sign('HMAC', cryptoKey, new TextEncoder().encode(data))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private async hmacSha256Hex(key: Uint8Array | ArrayBuffer, data: string): Promise<string> {
|
|
170
|
+
const result = await this.hmacSha256(key, data)
|
|
171
|
+
return this.bufferToHex(result)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private bufferToHex(buffer: ArrayBuffer): string {
|
|
175
|
+
const bytes = new Uint8Array(buffer)
|
|
176
|
+
let hex = ''
|
|
177
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
178
|
+
hex += (bytes[i] as number).toString(16).padStart(2, '0')
|
|
179
|
+
}
|
|
180
|
+
return hex
|
|
181
|
+
}
|
|
182
|
+
}
|