@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.
@@ -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
+ }