@mantiq/mail 0.1.2 → 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/README.md +2 -2
- package/package.json +5 -5
- 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,344 @@
|
|
|
1
|
+
import type { MailTransport } from '../contracts/Transport.ts'
|
|
2
|
+
import { Message, type Attachment } from '../Message.ts'
|
|
3
|
+
import { MailError } from '../errors/MailError.ts'
|
|
4
|
+
|
|
5
|
+
export interface SmtpConfig {
|
|
6
|
+
host: string
|
|
7
|
+
port: number
|
|
8
|
+
username?: string
|
|
9
|
+
password?: string
|
|
10
|
+
encryption?: 'tls' | 'starttls' | 'none'
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SmtpTransport implements MailTransport {
|
|
14
|
+
private config: SmtpConfig
|
|
15
|
+
|
|
16
|
+
constructor(config: SmtpConfig) {
|
|
17
|
+
this.config = config
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async send(message: Message): Promise<{ id: string }> {
|
|
21
|
+
const { host, port, username, password, encryption = 'none' } = this.config
|
|
22
|
+
|
|
23
|
+
let socket: ReturnType<typeof Bun.connect> extends Promise<infer T> ? T : never
|
|
24
|
+
let responseBuffer = ''
|
|
25
|
+
let responseResolve: ((value: string) => void) | null = null
|
|
26
|
+
|
|
27
|
+
const waitForResponse = (): Promise<string> => {
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
if (responseBuffer.length > 0) {
|
|
30
|
+
// Check if we have a complete response
|
|
31
|
+
const complete = this.extractCompleteResponse(responseBuffer)
|
|
32
|
+
if (complete) {
|
|
33
|
+
responseBuffer = responseBuffer.slice(complete.length)
|
|
34
|
+
resolve(complete)
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
responseResolve = resolve
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const handleData = (data: Buffer) => {
|
|
43
|
+
responseBuffer += data.toString()
|
|
44
|
+
if (responseResolve) {
|
|
45
|
+
const complete = this.extractCompleteResponse(responseBuffer)
|
|
46
|
+
if (complete) {
|
|
47
|
+
responseBuffer = responseBuffer.slice(complete.length)
|
|
48
|
+
const resolve = responseResolve
|
|
49
|
+
responseResolve = null
|
|
50
|
+
resolve(complete)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const useTls = encryption === 'tls'
|
|
57
|
+
|
|
58
|
+
const socketOptions = {
|
|
59
|
+
hostname: host,
|
|
60
|
+
port,
|
|
61
|
+
socket: {
|
|
62
|
+
data(_socket: any, data: Buffer) {
|
|
63
|
+
handleData(data)
|
|
64
|
+
},
|
|
65
|
+
error(_socket: any, error: Error) {
|
|
66
|
+
throw new MailError(`SMTP socket error: ${error.message}`)
|
|
67
|
+
},
|
|
68
|
+
close() {
|
|
69
|
+
// Connection closed
|
|
70
|
+
},
|
|
71
|
+
open() {
|
|
72
|
+
// Connection opened
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
} as any
|
|
76
|
+
|
|
77
|
+
if (useTls) {
|
|
78
|
+
socketOptions.tls = true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
socket = await Bun.connect(socketOptions)
|
|
82
|
+
|
|
83
|
+
// Read greeting
|
|
84
|
+
const greeting = await waitForResponse()
|
|
85
|
+
this.assertCode(greeting, 220, 'greeting')
|
|
86
|
+
|
|
87
|
+
// EHLO
|
|
88
|
+
socket.write(`EHLO localhost\r\n`)
|
|
89
|
+
const ehlo = await waitForResponse()
|
|
90
|
+
this.assertCode(ehlo, 250, 'EHLO')
|
|
91
|
+
|
|
92
|
+
// STARTTLS if needed
|
|
93
|
+
if (encryption === 'starttls') {
|
|
94
|
+
socket.write(`STARTTLS\r\n`)
|
|
95
|
+
const starttls = await waitForResponse()
|
|
96
|
+
this.assertCode(starttls, 220, 'STARTTLS')
|
|
97
|
+
|
|
98
|
+
// Upgrade to TLS — Bun's socket supports upgradeToTLS (if available)
|
|
99
|
+
// For environments where upgradeToTLS isn't available, we rely on the
|
|
100
|
+
// initial TLS connection (encryption: 'tls') instead.
|
|
101
|
+
// @ts-expect-error — upgradeToTLS may or may not exist on the socket
|
|
102
|
+
if (typeof socket.upgradeToTLS === 'function') {
|
|
103
|
+
// @ts-expect-error
|
|
104
|
+
socket = await socket.upgradeToTLS()
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Re-EHLO after STARTTLS
|
|
108
|
+
socket.write(`EHLO localhost\r\n`)
|
|
109
|
+
const ehlo2 = await waitForResponse()
|
|
110
|
+
this.assertCode(ehlo2, 250, 'EHLO after STARTTLS')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// AUTH if credentials provided
|
|
114
|
+
if (username && password) {
|
|
115
|
+
// Try AUTH PLAIN first
|
|
116
|
+
const credentials = Buffer.from(`\0${username}\0${password}`).toString('base64')
|
|
117
|
+
socket.write(`AUTH PLAIN ${credentials}\r\n`)
|
|
118
|
+
const authResp = await waitForResponse()
|
|
119
|
+
|
|
120
|
+
if (!authResp.startsWith('235')) {
|
|
121
|
+
// Fall back to AUTH LOGIN
|
|
122
|
+
socket.write(`AUTH LOGIN\r\n`)
|
|
123
|
+
const loginPrompt = await waitForResponse()
|
|
124
|
+
this.assertCode(loginPrompt, 334, 'AUTH LOGIN')
|
|
125
|
+
|
|
126
|
+
socket.write(`${Buffer.from(username).toString('base64')}\r\n`)
|
|
127
|
+
const userPrompt = await waitForResponse()
|
|
128
|
+
this.assertCode(userPrompt, 334, 'AUTH LOGIN username')
|
|
129
|
+
|
|
130
|
+
socket.write(`${Buffer.from(password).toString('base64')}\r\n`)
|
|
131
|
+
const passResp = await waitForResponse()
|
|
132
|
+
this.assertCode(passResp, 235, 'AUTH LOGIN password')
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// MAIL FROM
|
|
137
|
+
socket.write(`MAIL FROM:<${message.from.address}>\r\n`)
|
|
138
|
+
const mailFrom = await waitForResponse()
|
|
139
|
+
this.assertCode(mailFrom, 250, 'MAIL FROM')
|
|
140
|
+
|
|
141
|
+
// RCPT TO — all recipients
|
|
142
|
+
const allRecipients = [...message.to, ...message.cc, ...message.bcc]
|
|
143
|
+
for (const recipient of allRecipients) {
|
|
144
|
+
socket.write(`RCPT TO:<${recipient.address}>\r\n`)
|
|
145
|
+
const rcpt = await waitForResponse()
|
|
146
|
+
this.assertCode(rcpt, 250, 'RCPT TO')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// DATA
|
|
150
|
+
socket.write(`DATA\r\n`)
|
|
151
|
+
const dataResp = await waitForResponse()
|
|
152
|
+
this.assertCode(dataResp, 354, 'DATA')
|
|
153
|
+
|
|
154
|
+
// Build RFC 2822 message
|
|
155
|
+
const messageId = `<${crypto.randomUUID()}@${host}>`
|
|
156
|
+
const rawMessage = this.buildRawMessage(message, messageId)
|
|
157
|
+
|
|
158
|
+
// Send the message body, ending with \r\n.\r\n
|
|
159
|
+
socket.write(rawMessage)
|
|
160
|
+
socket.write(`\r\n.\r\n`)
|
|
161
|
+
const sendResp = await waitForResponse()
|
|
162
|
+
this.assertCode(sendResp, 250, 'message send')
|
|
163
|
+
|
|
164
|
+
// Extract message ID from server response if available
|
|
165
|
+
const serverIdMatch = sendResp.match(/queued as ([^\s>]+)/i)
|
|
166
|
+
const id = serverIdMatch?.[1] ?? messageId.replace(/[<>]/g, '')
|
|
167
|
+
|
|
168
|
+
// QUIT
|
|
169
|
+
socket.write(`QUIT\r\n`)
|
|
170
|
+
// Don't wait for QUIT response — some servers close immediately
|
|
171
|
+
|
|
172
|
+
socket.end()
|
|
173
|
+
|
|
174
|
+
return { id }
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof MailError) throw error
|
|
177
|
+
throw new MailError(`SMTP transport error: ${(error as Error).message}`, {
|
|
178
|
+
host,
|
|
179
|
+
port,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Extract a complete SMTP response from the buffer.
|
|
186
|
+
* Multi-line responses use "XXX-" continuation and end with "XXX ".
|
|
187
|
+
*/
|
|
188
|
+
private extractCompleteResponse(buffer: string): string | null {
|
|
189
|
+
const lines = buffer.split('\r\n')
|
|
190
|
+
let result = ''
|
|
191
|
+
|
|
192
|
+
for (let i = 0; i < lines.length; i++) {
|
|
193
|
+
const line = lines[i] as string
|
|
194
|
+
if (line === '' && i === lines.length - 1) break // trailing empty from split
|
|
195
|
+
|
|
196
|
+
result += line + '\r\n'
|
|
197
|
+
|
|
198
|
+
// A line like "250 OK" (code + space) signals the final line
|
|
199
|
+
if (/^\d{3} /.test(line)) {
|
|
200
|
+
return result
|
|
201
|
+
}
|
|
202
|
+
// A line like "250-..." means continuation, keep reading
|
|
203
|
+
if (/^\d{3}-/.test(line)) {
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private assertCode(response: string, expected: number, step: string): void {
|
|
212
|
+
const code = parseInt(response.substring(0, 3), 10)
|
|
213
|
+
if (code !== expected) {
|
|
214
|
+
throw new MailError(
|
|
215
|
+
`SMTP ${step} failed: expected ${expected}, got ${code}`,
|
|
216
|
+
{ response: response.trim() },
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private buildRawMessage(message: Message, messageId: string): string {
|
|
222
|
+
const lines: string[] = []
|
|
223
|
+
const boundary = `----=_Part_${crypto.randomUUID().replace(/-/g, '')}`
|
|
224
|
+
|
|
225
|
+
// Headers
|
|
226
|
+
lines.push(`Message-ID: ${messageId}`)
|
|
227
|
+
lines.push(`Date: ${new Date().toUTCString()}`)
|
|
228
|
+
lines.push(`From: ${Message.formatAddress(message.from)}`)
|
|
229
|
+
lines.push(`To: ${Message.formatAddresses(message.to)}`)
|
|
230
|
+
|
|
231
|
+
if (message.cc.length > 0) {
|
|
232
|
+
lines.push(`Cc: ${Message.formatAddresses(message.cc)}`)
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (message.replyTo.length > 0) {
|
|
236
|
+
lines.push(`Reply-To: ${Message.formatAddresses(message.replyTo)}`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
lines.push(`Subject: ${message.subject}`)
|
|
240
|
+
lines.push(`MIME-Version: 1.0`)
|
|
241
|
+
|
|
242
|
+
// Custom headers
|
|
243
|
+
for (const [key, value] of Object.entries(message.headers)) {
|
|
244
|
+
lines.push(`${key}: ${value}`)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const hasAttachments = message.attachments.length > 0
|
|
248
|
+
const hasMultipleBodies = message.html !== null && message.text !== null
|
|
249
|
+
|
|
250
|
+
if (hasAttachments) {
|
|
251
|
+
lines.push(`Content-Type: multipart/mixed; boundary="${boundary}"`)
|
|
252
|
+
lines.push('')
|
|
253
|
+
lines.push(`--${boundary}`)
|
|
254
|
+
|
|
255
|
+
if (hasMultipleBodies) {
|
|
256
|
+
const altBoundary = `----=_Alt_${crypto.randomUUID().replace(/-/g, '')}`
|
|
257
|
+
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`)
|
|
258
|
+
lines.push('')
|
|
259
|
+
|
|
260
|
+
if (message.text !== null) {
|
|
261
|
+
lines.push(`--${altBoundary}`)
|
|
262
|
+
lines.push(`Content-Type: text/plain; charset=utf-8`)
|
|
263
|
+
lines.push(`Content-Transfer-Encoding: 7bit`)
|
|
264
|
+
lines.push('')
|
|
265
|
+
lines.push(message.text)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (message.html !== null) {
|
|
269
|
+
lines.push(`--${altBoundary}`)
|
|
270
|
+
lines.push(`Content-Type: text/html; charset=utf-8`)
|
|
271
|
+
lines.push(`Content-Transfer-Encoding: 7bit`)
|
|
272
|
+
lines.push('')
|
|
273
|
+
lines.push(message.html)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
lines.push(`--${altBoundary}--`)
|
|
277
|
+
} else if (message.html !== null) {
|
|
278
|
+
lines.push(`Content-Type: text/html; charset=utf-8`)
|
|
279
|
+
lines.push(`Content-Transfer-Encoding: 7bit`)
|
|
280
|
+
lines.push('')
|
|
281
|
+
lines.push(message.html)
|
|
282
|
+
} else if (message.text !== null) {
|
|
283
|
+
lines.push(`Content-Type: text/plain; charset=utf-8`)
|
|
284
|
+
lines.push(`Content-Transfer-Encoding: 7bit`)
|
|
285
|
+
lines.push('')
|
|
286
|
+
lines.push(message.text)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Attachments
|
|
290
|
+
for (const attachment of message.attachments) {
|
|
291
|
+
lines.push(`--${boundary}`)
|
|
292
|
+
const contentType = attachment.contentType || 'application/octet-stream'
|
|
293
|
+
lines.push(`Content-Type: ${contentType}; name="${attachment.filename}"`)
|
|
294
|
+
lines.push(`Content-Disposition: attachment; filename="${attachment.filename}"`)
|
|
295
|
+
lines.push(`Content-Transfer-Encoding: base64`)
|
|
296
|
+
lines.push('')
|
|
297
|
+
lines.push(this.encodeAttachment(attachment))
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
lines.push(`--${boundary}--`)
|
|
301
|
+
} else if (hasMultipleBodies) {
|
|
302
|
+
const altBoundary = `----=_Alt_${crypto.randomUUID().replace(/-/g, '')}`
|
|
303
|
+
lines.push(`Content-Type: multipart/alternative; boundary="${altBoundary}"`)
|
|
304
|
+
lines.push('')
|
|
305
|
+
|
|
306
|
+
if (message.text !== null) {
|
|
307
|
+
lines.push(`--${altBoundary}`)
|
|
308
|
+
lines.push(`Content-Type: text/plain; charset=utf-8`)
|
|
309
|
+
lines.push(`Content-Transfer-Encoding: 7bit`)
|
|
310
|
+
lines.push('')
|
|
311
|
+
lines.push(message.text)
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (message.html !== null) {
|
|
315
|
+
lines.push(`--${altBoundary}`)
|
|
316
|
+
lines.push(`Content-Type: text/html; charset=utf-8`)
|
|
317
|
+
lines.push(`Content-Transfer-Encoding: 7bit`)
|
|
318
|
+
lines.push('')
|
|
319
|
+
lines.push(message.html)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
lines.push(`--${altBoundary}--`)
|
|
323
|
+
} else if (message.html !== null) {
|
|
324
|
+
lines.push(`Content-Type: text/html; charset=utf-8`)
|
|
325
|
+
lines.push(`Content-Transfer-Encoding: 7bit`)
|
|
326
|
+
lines.push('')
|
|
327
|
+
lines.push(message.html)
|
|
328
|
+
} else {
|
|
329
|
+
lines.push(`Content-Type: text/plain; charset=utf-8`)
|
|
330
|
+
lines.push(`Content-Transfer-Encoding: 7bit`)
|
|
331
|
+
lines.push('')
|
|
332
|
+
lines.push(message.text || '')
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
return lines.join('\r\n')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private encodeAttachment(attachment: Attachment): string {
|
|
339
|
+
if (typeof attachment.content === 'string') {
|
|
340
|
+
return Buffer.from(attachment.content).toString('base64')
|
|
341
|
+
}
|
|
342
|
+
return Buffer.from(attachment.content).toString('base64')
|
|
343
|
+
}
|
|
344
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Application } from '@mantiq/core'
|
|
2
|
+
import type { MailManager } from '../MailManager.ts'
|
|
3
|
+
import type { PendingMail } from '../PendingMail.ts'
|
|
4
|
+
|
|
5
|
+
export const MAIL_MANAGER = Symbol('MailManager')
|
|
6
|
+
|
|
7
|
+
/** Get the mail manager instance */
|
|
8
|
+
export function mail(): MailManager
|
|
9
|
+
/** Start a pending mail to the given address */
|
|
10
|
+
export function mail(to: string): PendingMail
|
|
11
|
+
export function mail(to?: string): MailManager | PendingMail {
|
|
12
|
+
const manager = Application.getInstance().make<MailManager>(MAIL_MANAGER)
|
|
13
|
+
if (to === undefined) return manager
|
|
14
|
+
return manager.to(to)
|
|
15
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1 +1,39 @@
|
|
|
1
|
-
//
|
|
1
|
+
// ── Contracts ────────────────────────────────────────────────────────────────
|
|
2
|
+
export type { MailTransport } from './contracts/Transport.ts'
|
|
3
|
+
export type { MailConfig, MailerConfig, MailAddress } from './contracts/MailConfig.ts'
|
|
4
|
+
export { DEFAULT_CONFIG } from './contracts/MailConfig.ts'
|
|
5
|
+
|
|
6
|
+
// ── Core ─────────────────────────────────────────────────────────────────────
|
|
7
|
+
export { Message, type Attachment } from './Message.ts'
|
|
8
|
+
export { Mailable } from './Mailable.ts'
|
|
9
|
+
export { MailManager } from './MailManager.ts'
|
|
10
|
+
export { PendingMail } from './PendingMail.ts'
|
|
11
|
+
|
|
12
|
+
// ── Drivers ──────────────────────────────────────────────────────────────────
|
|
13
|
+
export { SmtpTransport } from './drivers/SmtpTransport.ts'
|
|
14
|
+
export { ResendTransport } from './drivers/ResendTransport.ts'
|
|
15
|
+
export { SendGridTransport } from './drivers/SendGridTransport.ts'
|
|
16
|
+
export { MailgunTransport } from './drivers/MailgunTransport.ts'
|
|
17
|
+
export { PostmarkTransport } from './drivers/PostmarkTransport.ts'
|
|
18
|
+
export { SesTransport } from './drivers/SesTransport.ts'
|
|
19
|
+
export { LogTransport } from './drivers/LogTransport.ts'
|
|
20
|
+
export { ArrayTransport } from './drivers/ArrayTransport.ts'
|
|
21
|
+
|
|
22
|
+
// ── Markdown ─────────────────────────────────────────────────────────────────
|
|
23
|
+
export { renderMarkdown } from './markdown/MarkdownRenderer.ts'
|
|
24
|
+
export { wrapInLayout, renderButton, renderPanel, renderTable } from './markdown/theme.ts'
|
|
25
|
+
|
|
26
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
27
|
+
export { mail, MAIL_MANAGER } from './helpers/mail.ts'
|
|
28
|
+
|
|
29
|
+
// ── Service Provider ─────────────────────────────────────────────────────────
|
|
30
|
+
export { MailServiceProvider } from './MailServiceProvider.ts'
|
|
31
|
+
|
|
32
|
+
// ── Testing ──────────────────────────────────────────────────────────────────
|
|
33
|
+
export { MailFake } from './testing/MailFake.ts'
|
|
34
|
+
|
|
35
|
+
// ── Errors ───────────────────────────────────────────────────────────────────
|
|
36
|
+
export { MailError } from './errors/MailError.ts'
|
|
37
|
+
|
|
38
|
+
// ── Commands ─────────────────────────────────────────────────────────────────
|
|
39
|
+
export { MakeMailCommand } from './commands/MakeMailCommand.ts'
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Job } from '@mantiq/queue'
|
|
2
|
+
import type { Mailable } from '../Mailable.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Queued mail job — sends a mailable via the mail manager.
|
|
6
|
+
* Dispatched by PendingMail.queue().
|
|
7
|
+
*/
|
|
8
|
+
export class SendMailJob extends Job {
|
|
9
|
+
override name = 'mail:send'
|
|
10
|
+
override tries = 3
|
|
11
|
+
|
|
12
|
+
constructor(
|
|
13
|
+
private mailable: Mailable,
|
|
14
|
+
private mailer?: string,
|
|
15
|
+
) {
|
|
16
|
+
super()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
override async handle(): Promise<void> {
|
|
20
|
+
const { mail } = await import('../helpers/mail.ts')
|
|
21
|
+
const manager = mail()
|
|
22
|
+
const message = this.mailable.toMessage(manager.getFrom())
|
|
23
|
+
await manager.driver(this.mailer).send(message)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { wrapInLayout, renderButton, renderPanel, type ThemeOptions } from './theme.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Converts markdown-like content to styled HTML email.
|
|
5
|
+
*
|
|
6
|
+
* Supports: headings, paragraphs, bold, italic, links, lists, code,
|
|
7
|
+
* blockquotes, horizontal rules, and custom components:
|
|
8
|
+
* [button url="..."]text[/button]
|
|
9
|
+
* [panel]content[/panel]
|
|
10
|
+
*/
|
|
11
|
+
export function renderMarkdown(content: string, options: ThemeOptions = {}): string {
|
|
12
|
+
const bodyHtml = markdownToHtml(content.trim())
|
|
13
|
+
return wrapInLayout(bodyHtml, options)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function markdownToHtml(md: string): string {
|
|
17
|
+
let html = ''
|
|
18
|
+
const lines = md.split('\n')
|
|
19
|
+
let i = 0
|
|
20
|
+
let inList: 'ul' | 'ol' | null = null
|
|
21
|
+
|
|
22
|
+
while (i < lines.length) {
|
|
23
|
+
const line = lines[i]!
|
|
24
|
+
const trimmed = line.trim()
|
|
25
|
+
|
|
26
|
+
// Empty line — close list if open
|
|
27
|
+
if (!trimmed) {
|
|
28
|
+
if (inList) { html += `</${inList}>` ; inList = null }
|
|
29
|
+
i++
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Custom components
|
|
34
|
+
const buttonMatch = trimmed.match(/^\[button\s+url="([^"]+)"\](.*?)\[\/button\]$/)
|
|
35
|
+
if (buttonMatch) {
|
|
36
|
+
if (inList) { html += `</${inList}>`; inList = null }
|
|
37
|
+
html += renderButton(buttonMatch[1]!, buttonMatch[2]!)
|
|
38
|
+
i++
|
|
39
|
+
continue
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Panel block (multi-line)
|
|
43
|
+
if (trimmed === '[panel]') {
|
|
44
|
+
if (inList) { html += `</${inList}>`; inList = null }
|
|
45
|
+
const panelLines: string[] = []
|
|
46
|
+
i++
|
|
47
|
+
while (i < lines.length && lines[i]!.trim() !== '[/panel]') {
|
|
48
|
+
panelLines.push(lines[i]!)
|
|
49
|
+
i++
|
|
50
|
+
}
|
|
51
|
+
i++ // skip [/panel]
|
|
52
|
+
html += renderPanel(inlineFormatting(panelLines.join('<br>')))
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Headings
|
|
57
|
+
const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/)
|
|
58
|
+
if (headingMatch) {
|
|
59
|
+
if (inList) { html += `</${inList}>`; inList = null }
|
|
60
|
+
const level = headingMatch[1]!.length
|
|
61
|
+
const text = inlineFormatting(headingMatch[2]!)
|
|
62
|
+
const sizes: Record<number, string> = { 1: '24px', 2: '20px', 3: '17px', 4: '15px', 5: '14px', 6: '13px' }
|
|
63
|
+
const mt = level <= 2 ? '28px' : '20px'
|
|
64
|
+
html += `<h${level} class="email-text" style="margin:${mt} 0 8px;font-size:${sizes[level]};font-weight:700;line-height:1.3;color:#1a1a1a;">${text}</h${level}>`
|
|
65
|
+
i++
|
|
66
|
+
continue
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Horizontal rule
|
|
70
|
+
if (/^(-{3,}|\*{3,}|_{3,})$/.test(trimmed)) {
|
|
71
|
+
if (inList) { html += `</${inList}>`; inList = null }
|
|
72
|
+
html += `<hr class="email-hr" style="border:none;border-top:1px solid #e5e7eb;margin:24px 0;">`
|
|
73
|
+
i++
|
|
74
|
+
continue
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Blockquote
|
|
78
|
+
if (trimmed.startsWith('> ')) {
|
|
79
|
+
if (inList) { html += `</${inList}>`; inList = null }
|
|
80
|
+
const quoteLines: string[] = []
|
|
81
|
+
while (i < lines.length && lines[i]!.trim().startsWith('> ')) {
|
|
82
|
+
quoteLines.push(lines[i]!.trim().slice(2))
|
|
83
|
+
i++
|
|
84
|
+
}
|
|
85
|
+
html += `<blockquote class="email-blockquote" style="margin:16px 0;padding:12px 20px;border-left:3px solid #e5e7eb;color:#6b7280;font-style:italic;">${inlineFormatting(quoteLines.join('<br>'))}</blockquote>`
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Unordered list
|
|
90
|
+
if (/^[-*+]\s+/.test(trimmed)) {
|
|
91
|
+
if (inList !== 'ul') {
|
|
92
|
+
if (inList) html += `</${inList}>`
|
|
93
|
+
html += `<ul style="margin:12px 0;padding-left:24px;color:#1a1a1a;">`
|
|
94
|
+
inList = 'ul'
|
|
95
|
+
}
|
|
96
|
+
html += `<li style="margin:4px 0;line-height:1.6;">${inlineFormatting(trimmed.replace(/^[-*+]\s+/, ''))}</li>`
|
|
97
|
+
i++
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Ordered list
|
|
102
|
+
const olMatch = trimmed.match(/^(\d+)\.\s+(.+)$/)
|
|
103
|
+
if (olMatch) {
|
|
104
|
+
if (inList !== 'ol') {
|
|
105
|
+
if (inList) html += `</${inList}>`
|
|
106
|
+
html += `<ol style="margin:12px 0;padding-left:24px;color:#1a1a1a;">`
|
|
107
|
+
inList = 'ol'
|
|
108
|
+
}
|
|
109
|
+
html += `<li style="margin:4px 0;line-height:1.6;">${inlineFormatting(olMatch[2]!)}</li>`
|
|
110
|
+
i++
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Paragraph (default)
|
|
115
|
+
if (inList) { html += `</${inList}>`; inList = null }
|
|
116
|
+
html += `<p class="email-text" style="margin:12px 0;line-height:1.65;color:#1a1a1a;">${inlineFormatting(trimmed)}</p>`
|
|
117
|
+
i++
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (inList) html += `</${inList}>`
|
|
121
|
+
|
|
122
|
+
return html
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Process inline markdown: bold, italic, code, links */
|
|
126
|
+
function inlineFormatting(text: string): string {
|
|
127
|
+
return text
|
|
128
|
+
// Bold
|
|
129
|
+
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
|
130
|
+
// Italic
|
|
131
|
+
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
|
132
|
+
// Inline code
|
|
133
|
+
.replace(/`([^`]+)`/g, '<code class="email-code" style="background:#f3f4f6;padding:2px 6px;border-radius:3px;font-size:13px;font-family:\'SF Mono\',ui-monospace,Menlo,monospace;color:#10b981;">$1</code>')
|
|
134
|
+
// Links
|
|
135
|
+
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" class="email-link" style="color:#10b981;text-decoration:underline;">$1</a>')
|
|
136
|
+
}
|