@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.
@@ -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,7 @@
1
+ import { MantiqError } from '@mantiq/core'
2
+
3
+ export class MailError extends MantiqError {
4
+ constructor(message: string, context?: Record<string, any>) {
5
+ super(message, context)
6
+ }
7
+ }
@@ -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
- // @mantiq/mail public API exports
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
+ }