@mantiq/mail 0.1.3 → 0.3.0-rc.2
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 +8 -4
- 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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mantiq/mail",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.0-rc.2",
|
|
4
|
+
"description": "Transactional email — SMTP, Resend, SendGrid, Mailgun, Postmark, SES, markdown templates",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "Abdullah Khan",
|
|
@@ -40,19 +40,23 @@
|
|
|
40
40
|
"LICENSE"
|
|
41
41
|
],
|
|
42
42
|
"scripts": {
|
|
43
|
-
"build": "bun build ./src/index.ts --outdir ./dist --target bun",
|
|
43
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target bun --packages=external",
|
|
44
44
|
"test": "bun test",
|
|
45
45
|
"typecheck": "tsc --noEmit",
|
|
46
46
|
"clean": "rm -rf dist"
|
|
47
47
|
},
|
|
48
48
|
"devDependencies": {
|
|
49
49
|
"bun-types": "latest",
|
|
50
|
-
"typescript": "^5.7.0"
|
|
50
|
+
"typescript": "^5.7.0",
|
|
51
|
+
"@mantiq/core": "workspace:*"
|
|
51
52
|
},
|
|
52
53
|
"optionalDependencies": {
|
|
53
54
|
"@mantiq/queue": "^0.1.0"
|
|
54
55
|
},
|
|
55
56
|
"peerDependencies": {
|
|
56
57
|
"@mantiq/core": "^0.1.0"
|
|
58
|
+
},
|
|
59
|
+
"mantiq": {
|
|
60
|
+
"provider": "MailServiceProvider"
|
|
57
61
|
}
|
|
58
62
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { MailTransport } from './contracts/Transport.ts'
|
|
2
|
+
import type { MailConfig, MailerConfig, MailAddress } from './contracts/MailConfig.ts'
|
|
3
|
+
import { DEFAULT_CONFIG } from './contracts/MailConfig.ts'
|
|
4
|
+
import { Message } from './Message.ts'
|
|
5
|
+
import { PendingMail } from './PendingMail.ts'
|
|
6
|
+
import { MailError } from './errors/MailError.ts'
|
|
7
|
+
|
|
8
|
+
import { ArrayTransport } from './drivers/ArrayTransport.ts'
|
|
9
|
+
import { LogTransport } from './drivers/LogTransport.ts'
|
|
10
|
+
import { SmtpTransport } from './drivers/SmtpTransport.ts'
|
|
11
|
+
import { ResendTransport } from './drivers/ResendTransport.ts'
|
|
12
|
+
import { SendGridTransport } from './drivers/SendGridTransport.ts'
|
|
13
|
+
import { MailgunTransport } from './drivers/MailgunTransport.ts'
|
|
14
|
+
import { PostmarkTransport } from './drivers/PostmarkTransport.ts'
|
|
15
|
+
import { SesTransport } from './drivers/SesTransport.ts'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* MailManager — driver manager for mail transports.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* const manager = new MailManager(config)
|
|
22
|
+
* await manager.to('user@example.com').send(new WelcomeEmail(user))
|
|
23
|
+
* await manager.driver('resend').send(message)
|
|
24
|
+
*/
|
|
25
|
+
export class MailManager {
|
|
26
|
+
private config: MailConfig
|
|
27
|
+
private drivers = new Map<string, MailTransport>()
|
|
28
|
+
private customDrivers = new Map<string, () => MailTransport>()
|
|
29
|
+
|
|
30
|
+
constructor(config?: Partial<MailConfig>) {
|
|
31
|
+
this.config = { ...DEFAULT_CONFIG, ...config }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Get the default from address */
|
|
35
|
+
getFrom(): MailAddress {
|
|
36
|
+
return this.config.from
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Get or create a transport driver by name */
|
|
40
|
+
driver(name?: string): MailTransport {
|
|
41
|
+
const driverName = name ?? this.config.default
|
|
42
|
+
|
|
43
|
+
if (this.drivers.has(driverName)) {
|
|
44
|
+
return this.drivers.get(driverName)!
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const transport = this.createDriver(driverName)
|
|
48
|
+
this.drivers.set(driverName, transport)
|
|
49
|
+
return transport
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Alias for driver() */
|
|
53
|
+
mailer(name?: string): MailTransport {
|
|
54
|
+
return this.driver(name)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Start a fluent pending mail */
|
|
58
|
+
to(address: string | MailAddress | (string | MailAddress)[]): PendingMail {
|
|
59
|
+
return new PendingMail(this).to(address)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Send a mailable using the default transport */
|
|
63
|
+
async send(mailable: import('./Mailable.ts').Mailable): Promise<{ id: string }> {
|
|
64
|
+
return new PendingMail(this).send(mailable)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Register a custom driver factory */
|
|
68
|
+
extend(name: string, factory: () => MailTransport): void {
|
|
69
|
+
this.customDrivers.set(name, factory)
|
|
70
|
+
this.drivers.delete(name) // clear cached instance
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Get the default driver name */
|
|
74
|
+
getDefaultDriver(): string {
|
|
75
|
+
return this.config.default
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Private ───────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
private createDriver(name: string): MailTransport {
|
|
81
|
+
// Check custom drivers first
|
|
82
|
+
const customFactory = this.customDrivers.get(name)
|
|
83
|
+
if (customFactory) return customFactory()
|
|
84
|
+
|
|
85
|
+
const mailerConfig = this.config.mailers[name]
|
|
86
|
+
if (!mailerConfig) {
|
|
87
|
+
throw new MailError(`Mail driver "${name}" is not configured.`, { available: Object.keys(this.config.mailers) })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return this.resolveDriver(mailerConfig)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private resolveDriver(config: MailerConfig): MailTransport {
|
|
94
|
+
switch (config.driver) {
|
|
95
|
+
case 'smtp':
|
|
96
|
+
return new SmtpTransport(config)
|
|
97
|
+
case 'resend':
|
|
98
|
+
return new ResendTransport(config)
|
|
99
|
+
case 'sendgrid':
|
|
100
|
+
return new SendGridTransport(config)
|
|
101
|
+
case 'mailgun':
|
|
102
|
+
return new MailgunTransport(config)
|
|
103
|
+
case 'postmark':
|
|
104
|
+
return new PostmarkTransport(config)
|
|
105
|
+
case 'ses':
|
|
106
|
+
return new SesTransport(config)
|
|
107
|
+
case 'log':
|
|
108
|
+
return new LogTransport()
|
|
109
|
+
case 'array':
|
|
110
|
+
return new ArrayTransport()
|
|
111
|
+
default:
|
|
112
|
+
throw new MailError(`Unsupported mail driver: "${(config as any).driver}"`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { ServiceProvider, ConfigRepository } from '@mantiq/core'
|
|
2
|
+
import { MailManager } from './MailManager.ts'
|
|
3
|
+
import { MAIL_MANAGER } from './helpers/mail.ts'
|
|
4
|
+
import type { MailConfig } from './contracts/MailConfig.ts'
|
|
5
|
+
import { DEFAULT_CONFIG } from './contracts/MailConfig.ts'
|
|
6
|
+
|
|
7
|
+
export class MailServiceProvider extends ServiceProvider {
|
|
8
|
+
override register(): void {
|
|
9
|
+
const config = this.app.make(ConfigRepository).get<MailConfig>('mail', DEFAULT_CONFIG)
|
|
10
|
+
|
|
11
|
+
this.app.singleton(MailManager, () => new MailManager(config))
|
|
12
|
+
this.app.alias(MailManager, MAIL_MANAGER)
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/Mailable.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { MailAddress } from './contracts/MailConfig.ts'
|
|
2
|
+
import { Message, type Attachment } from './Message.ts'
|
|
3
|
+
import { renderMarkdown } from './markdown/MarkdownRenderer.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Base class for all mailables. Users extend this and implement build().
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* class WelcomeEmail extends Mailable {
|
|
10
|
+
* constructor(private user: { name: string }) { super() }
|
|
11
|
+
*
|
|
12
|
+
* build() {
|
|
13
|
+
* this.setSubject('Welcome!')
|
|
14
|
+
* this.markdown(`# Hi ${this.user.name}!\n\nWelcome aboard.`)
|
|
15
|
+
* }
|
|
16
|
+
* }
|
|
17
|
+
*/
|
|
18
|
+
export abstract class Mailable {
|
|
19
|
+
private _to: MailAddress[] = []
|
|
20
|
+
private _cc: MailAddress[] = []
|
|
21
|
+
private _bcc: MailAddress[] = []
|
|
22
|
+
private _replyTo: MailAddress[] = []
|
|
23
|
+
private _subject = ''
|
|
24
|
+
private _html: string | null = null
|
|
25
|
+
private _text: string | null = null
|
|
26
|
+
private _markdown: string | null = null
|
|
27
|
+
private _attachments: Attachment[] = []
|
|
28
|
+
private _headers: Record<string, string> = {}
|
|
29
|
+
private _appName = 'MantiqJS'
|
|
30
|
+
|
|
31
|
+
abstract build(): void
|
|
32
|
+
|
|
33
|
+
// ── Recipients (can be set before send) ──────────────────────────────────
|
|
34
|
+
|
|
35
|
+
to(address: string | MailAddress | (string | MailAddress)[]): this {
|
|
36
|
+
const addrs = Array.isArray(address) ? address : [address]
|
|
37
|
+
this._to.push(...addrs.map(a => typeof a === 'string' ? { address: a } : a))
|
|
38
|
+
return this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
cc(address: string | MailAddress | (string | MailAddress)[]): this {
|
|
42
|
+
const addrs = Array.isArray(address) ? address : [address]
|
|
43
|
+
this._cc.push(...addrs.map(a => typeof a === 'string' ? { address: a } : a))
|
|
44
|
+
return this
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
bcc(address: string | MailAddress | (string | MailAddress)[]): this {
|
|
48
|
+
const addrs = Array.isArray(address) ? address : [address]
|
|
49
|
+
this._bcc.push(...addrs.map(a => typeof a === 'string' ? { address: a } : a))
|
|
50
|
+
return this
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
replyTo(address: string | MailAddress): this {
|
|
54
|
+
this._replyTo.push(typeof address === 'string' ? { address } : address)
|
|
55
|
+
return this
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Content ───────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
protected setSubject(subject: string): this {
|
|
61
|
+
this._subject = subject
|
|
62
|
+
return this
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
protected html(content: string): this {
|
|
66
|
+
this._html = content
|
|
67
|
+
this._markdown = null
|
|
68
|
+
return this
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
protected text(content: string): this {
|
|
72
|
+
this._text = content
|
|
73
|
+
return this
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
protected markdown(content: string): this {
|
|
77
|
+
this._markdown = content
|
|
78
|
+
this._html = null
|
|
79
|
+
return this
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
protected attach(filename: string, content: Uint8Array | string, contentType?: string): this {
|
|
83
|
+
this._attachments.push({ filename, content, contentType })
|
|
84
|
+
return this
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
protected header(key: string, value: string): this {
|
|
88
|
+
this._headers[key] = value
|
|
89
|
+
return this
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Internal ──────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
/** Set the app name for email template header. Called by MailManager. */
|
|
95
|
+
setAppName(name: string): void {
|
|
96
|
+
this._appName = name
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Convert to a Message ready for transport. Call build() first. */
|
|
100
|
+
toMessage(from: MailAddress): Message {
|
|
101
|
+
this.build()
|
|
102
|
+
|
|
103
|
+
const msg = new Message()
|
|
104
|
+
msg.setFrom(from)
|
|
105
|
+
msg.subject = this._subject
|
|
106
|
+
|
|
107
|
+
for (const addr of this._to) msg.addTo(addr)
|
|
108
|
+
for (const addr of this._cc) msg.addCc(addr)
|
|
109
|
+
for (const addr of this._bcc) msg.addBcc(addr)
|
|
110
|
+
for (const addr of this._replyTo) msg.addReplyTo(addr)
|
|
111
|
+
|
|
112
|
+
if (this._markdown) {
|
|
113
|
+
msg.html = renderMarkdown(this._markdown, { appName: this._appName })
|
|
114
|
+
// Generate plain text by stripping tags
|
|
115
|
+
msg.text = this._text ?? stripHtml(this._markdown)
|
|
116
|
+
} else if (this._html) {
|
|
117
|
+
msg.html = this._html
|
|
118
|
+
msg.text = this._text
|
|
119
|
+
} else if (this._text) {
|
|
120
|
+
msg.text = this._text
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
msg.attachments = [...this._attachments]
|
|
124
|
+
msg.headers = { ...this._headers }
|
|
125
|
+
|
|
126
|
+
return msg
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Get recipients set on this mailable */
|
|
130
|
+
getTo(): MailAddress[] { return this._to }
|
|
131
|
+
getCc(): MailAddress[] { return this._cc }
|
|
132
|
+
getBcc(): MailAddress[] { return this._bcc }
|
|
133
|
+
getSubject(): string { return this._subject }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function stripHtml(md: string): string {
|
|
137
|
+
// Remove markdown syntax to produce plain text
|
|
138
|
+
return md
|
|
139
|
+
.replace(/\[button[^\]]*\](.*?)\[\/button\]/g, '$1')
|
|
140
|
+
.replace(/\[panel\]([\s\S]*?)\[\/panel\]/g, '$1')
|
|
141
|
+
.replace(/#{1,6}\s+/g, '')
|
|
142
|
+
.replace(/\*\*(.*?)\*\*/g, '$1')
|
|
143
|
+
.replace(/\*(.*?)\*/g, '$1')
|
|
144
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
145
|
+
.replace(/`([^`]+)`/g, '$1')
|
|
146
|
+
.replace(/>\s*/gm, '')
|
|
147
|
+
.trim()
|
|
148
|
+
}
|
package/src/Message.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { MailAddress } from './contracts/MailConfig.ts'
|
|
2
|
+
|
|
3
|
+
export interface Attachment {
|
|
4
|
+
filename: string
|
|
5
|
+
content: Uint8Array | string
|
|
6
|
+
contentType?: string | undefined
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class Message {
|
|
10
|
+
from: MailAddress = { address: '' }
|
|
11
|
+
to: MailAddress[] = []
|
|
12
|
+
cc: MailAddress[] = []
|
|
13
|
+
bcc: MailAddress[] = []
|
|
14
|
+
replyTo: MailAddress[] = []
|
|
15
|
+
subject = ''
|
|
16
|
+
html: string | null = null
|
|
17
|
+
text: string | null = null
|
|
18
|
+
attachments: Attachment[] = []
|
|
19
|
+
headers: Record<string, string> = {}
|
|
20
|
+
|
|
21
|
+
setFrom(address: string | MailAddress): this {
|
|
22
|
+
this.from = typeof address === 'string' ? { address } : address
|
|
23
|
+
return this
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
addTo(address: string | MailAddress): this {
|
|
27
|
+
this.to.push(typeof address === 'string' ? { address } : address)
|
|
28
|
+
return this
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
addCc(address: string | MailAddress): this {
|
|
32
|
+
this.cc.push(typeof address === 'string' ? { address } : address)
|
|
33
|
+
return this
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
addBcc(address: string | MailAddress): this {
|
|
37
|
+
this.bcc.push(typeof address === 'string' ? { address } : address)
|
|
38
|
+
return this
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
addReplyTo(address: string | MailAddress): this {
|
|
42
|
+
this.replyTo.push(typeof address === 'string' ? { address } : address)
|
|
43
|
+
return this
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
setSubject(subject: string): this {
|
|
47
|
+
this.subject = subject
|
|
48
|
+
return this
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setHtml(html: string): this {
|
|
52
|
+
this.html = html
|
|
53
|
+
return this
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
setText(text: string): this {
|
|
57
|
+
this.text = text
|
|
58
|
+
return this
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
addAttachment(filename: string, content: Uint8Array | string, contentType?: string): this {
|
|
62
|
+
this.attachments.push({ filename, content, contentType })
|
|
63
|
+
return this
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
setHeader(key: string, value: string): this {
|
|
67
|
+
this.headers[key] = value
|
|
68
|
+
return this
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Format address for SMTP/API: "Name <email>" or just "email" */
|
|
72
|
+
static formatAddress(addr: MailAddress): string {
|
|
73
|
+
return addr.name ? `${addr.name} <${addr.address}>` : addr.address
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Format address list */
|
|
77
|
+
static formatAddresses(addrs: MailAddress[]): string {
|
|
78
|
+
return addrs.map(Message.formatAddress).join(', ')
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { MailAddress } from './contracts/MailConfig.ts'
|
|
2
|
+
import type { MailManager } from './MailManager.ts'
|
|
3
|
+
import type { Mailable } from './Mailable.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fluent builder for sending mail.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* await mail().to('user@example.com').send(new WelcomeEmail(user))
|
|
10
|
+
* await mail().to(['a@b.com', 'c@d.com']).cc('admin@b.com').queue(new InvoiceEmail(order))
|
|
11
|
+
*/
|
|
12
|
+
export class PendingMail {
|
|
13
|
+
private _to: MailAddress[] = []
|
|
14
|
+
private _cc: MailAddress[] = []
|
|
15
|
+
private _bcc: MailAddress[] = []
|
|
16
|
+
private _mailer: string | undefined
|
|
17
|
+
|
|
18
|
+
constructor(private manager: MailManager) {}
|
|
19
|
+
|
|
20
|
+
to(address: string | MailAddress | (string | MailAddress)[]): this {
|
|
21
|
+
const addrs = Array.isArray(address) ? address : [address]
|
|
22
|
+
this._to.push(...addrs.map(a => typeof a === 'string' ? { address: a } : a))
|
|
23
|
+
return this
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
cc(address: string | MailAddress | (string | MailAddress)[]): this {
|
|
27
|
+
const addrs = Array.isArray(address) ? address : [address]
|
|
28
|
+
this._cc.push(...addrs.map(a => typeof a === 'string' ? { address: a } : a))
|
|
29
|
+
return this
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
bcc(address: string | MailAddress | (string | MailAddress)[]): this {
|
|
33
|
+
const addrs = Array.isArray(address) ? address : [address]
|
|
34
|
+
this._bcc.push(...addrs.map(a => typeof a === 'string' ? { address: a } : a))
|
|
35
|
+
return this
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Use a specific mailer instead of default */
|
|
39
|
+
via(mailer: string): this {
|
|
40
|
+
this._mailer = mailer
|
|
41
|
+
return this
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Send the mailable now */
|
|
45
|
+
async send(mailable: Mailable): Promise<{ id: string }> {
|
|
46
|
+
// Apply pending recipients to the mailable
|
|
47
|
+
if (this._to.length) mailable.to(this._to)
|
|
48
|
+
if (this._cc.length) mailable.cc(this._cc)
|
|
49
|
+
if (this._bcc.length) mailable.bcc(this._bcc)
|
|
50
|
+
|
|
51
|
+
const message = mailable.toMessage(this.manager.getFrom())
|
|
52
|
+
return this.manager.driver(this._mailer).send(message)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Queue the mailable for async sending (requires @mantiq/queue) */
|
|
56
|
+
async queue(mailable: Mailable): Promise<void> {
|
|
57
|
+
// Apply pending recipients
|
|
58
|
+
if (this._to.length) mailable.to(this._to)
|
|
59
|
+
if (this._cc.length) mailable.cc(this._cc)
|
|
60
|
+
if (this._bcc.length) mailable.bcc(this._bcc)
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const { dispatch } = await import('@mantiq/queue')
|
|
64
|
+
const { SendMailJob } = await import('./jobs/SendMailJob.ts')
|
|
65
|
+
await dispatch(new SendMailJob(mailable, this._mailer))
|
|
66
|
+
} catch {
|
|
67
|
+
// Queue not installed — send synchronously
|
|
68
|
+
await this.send(mailable)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { GeneratorCommand } from '@mantiq/cli'
|
|
2
|
+
import type { ParsedArgs } from '@mantiq/cli'
|
|
3
|
+
|
|
4
|
+
export class MakeMailCommand extends GeneratorCommand {
|
|
5
|
+
override name = 'make:mail'
|
|
6
|
+
override description = 'Create a new mailable class'
|
|
7
|
+
override usage = 'make:mail <name> [--markdown]'
|
|
8
|
+
|
|
9
|
+
override directory() { return 'app/Mail' }
|
|
10
|
+
override suffix() { return '' }
|
|
11
|
+
|
|
12
|
+
override stub(name: string, args: ParsedArgs): string {
|
|
13
|
+
const className = name
|
|
14
|
+
const useMarkdown = !!args.flags['markdown']
|
|
15
|
+
|
|
16
|
+
if (useMarkdown) {
|
|
17
|
+
return `import { Mailable } from '@mantiq/mail'
|
|
18
|
+
|
|
19
|
+
export class ${className} extends Mailable {
|
|
20
|
+
constructor(private data: Record<string, any> = {}) { super() }
|
|
21
|
+
|
|
22
|
+
build() {
|
|
23
|
+
this.setSubject('${className}')
|
|
24
|
+
this.markdown(\`
|
|
25
|
+
# Hello!
|
|
26
|
+
|
|
27
|
+
This is the **${className}** mailable.
|
|
28
|
+
|
|
29
|
+
[button url="/dashboard"]View Dashboard[/button]
|
|
30
|
+
|
|
31
|
+
[panel]
|
|
32
|
+
If you have any questions, reply to this email.
|
|
33
|
+
[/panel]
|
|
34
|
+
\`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return `import { Mailable } from '@mantiq/mail'
|
|
41
|
+
|
|
42
|
+
export class ${className} extends Mailable {
|
|
43
|
+
constructor(private data: Record<string, any> = {}) { super() }
|
|
44
|
+
|
|
45
|
+
build() {
|
|
46
|
+
this.setSubject('${className}')
|
|
47
|
+
this.html(\`
|
|
48
|
+
<h1>Hello!</h1>
|
|
49
|
+
<p>This is the <strong>${className}</strong> mailable.</p>
|
|
50
|
+
\`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
`
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface MailAddress {
|
|
2
|
+
address: string
|
|
3
|
+
name?: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export type MailerConfig =
|
|
7
|
+
| { driver: 'smtp'; host: string; port: number; username?: string; password?: string; encryption?: 'tls' | 'starttls' | 'none' }
|
|
8
|
+
| { driver: 'resend'; apiKey: string }
|
|
9
|
+
| { driver: 'sendgrid'; apiKey: string }
|
|
10
|
+
| { driver: 'mailgun'; apiKey: string; domain: string; region?: 'us' | 'eu' }
|
|
11
|
+
| { driver: 'postmark'; serverToken: string }
|
|
12
|
+
| { driver: 'ses'; region: string; accessKeyId: string; secretAccessKey: string }
|
|
13
|
+
| { driver: 'log' }
|
|
14
|
+
| { driver: 'array' }
|
|
15
|
+
|
|
16
|
+
export interface MailConfig {
|
|
17
|
+
default: string
|
|
18
|
+
from: MailAddress
|
|
19
|
+
mailers: Record<string, MailerConfig>
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_CONFIG: MailConfig = {
|
|
23
|
+
default: 'log',
|
|
24
|
+
from: { address: 'hello@example.com', name: 'MantiqJS' },
|
|
25
|
+
mailers: {
|
|
26
|
+
log: { driver: 'log' },
|
|
27
|
+
},
|
|
28
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MailTransport } from '../contracts/Transport.ts'
|
|
2
|
+
import type { Message } from '../Message.ts'
|
|
3
|
+
|
|
4
|
+
export class ArrayTransport implements MailTransport {
|
|
5
|
+
sent: Message[] = []
|
|
6
|
+
|
|
7
|
+
async send(message: Message): Promise<{ id: string }> {
|
|
8
|
+
const id = crypto.randomUUID()
|
|
9
|
+
this.sent.push(message)
|
|
10
|
+
return { id }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Clear all stored messages */
|
|
14
|
+
flush(): void {
|
|
15
|
+
this.sent = []
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { MailTransport } from '../contracts/Transport.ts'
|
|
2
|
+
import { Message } from '../Message.ts'
|
|
3
|
+
|
|
4
|
+
export class LogTransport implements MailTransport {
|
|
5
|
+
async send(message: Message): Promise<{ id: string }> {
|
|
6
|
+
const id = crypto.randomUUID()
|
|
7
|
+
|
|
8
|
+
const to = Message.formatAddresses(message.to)
|
|
9
|
+
const subject = message.subject
|
|
10
|
+
const preview = message.text
|
|
11
|
+
? message.text.substring(0, 200)
|
|
12
|
+
: message.html
|
|
13
|
+
? message.html.replace(/<[^>]*>/g, '').substring(0, 200)
|
|
14
|
+
: '(no body)'
|
|
15
|
+
|
|
16
|
+
console.log(
|
|
17
|
+
`[Mail] To: ${to} | Subject: ${subject} | Preview: ${preview}`,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
return { id }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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 MailgunConfig {
|
|
6
|
+
apiKey: string
|
|
7
|
+
domain: string
|
|
8
|
+
region?: 'us' | 'eu'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export class MailgunTransport implements MailTransport {
|
|
12
|
+
private config: MailgunConfig
|
|
13
|
+
|
|
14
|
+
constructor(config: MailgunConfig) {
|
|
15
|
+
this.config = config
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async send(message: Message): Promise<{ id: string }> {
|
|
19
|
+
const baseUrl = this.config.region === 'eu'
|
|
20
|
+
? 'https://api.eu.mailgun.net'
|
|
21
|
+
: 'https://api.mailgun.net'
|
|
22
|
+
|
|
23
|
+
const url = `${baseUrl}/v3/${this.config.domain}/messages`
|
|
24
|
+
|
|
25
|
+
const formData = new FormData()
|
|
26
|
+
|
|
27
|
+
formData.append('from', Message.formatAddress(message.from))
|
|
28
|
+
|
|
29
|
+
for (const addr of message.to) {
|
|
30
|
+
formData.append('to', Message.formatAddress(addr))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
for (const addr of message.cc) {
|
|
34
|
+
formData.append('cc', Message.formatAddress(addr))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const addr of message.bcc) {
|
|
38
|
+
formData.append('bcc', Message.formatAddress(addr))
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
formData.append('subject', message.subject)
|
|
42
|
+
|
|
43
|
+
if (message.html !== null) {
|
|
44
|
+
formData.append('html', message.html)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (message.text !== null) {
|
|
48
|
+
formData.append('text', message.text)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (message.replyTo.length > 0) {
|
|
52
|
+
formData.append('h:Reply-To', Message.formatAddresses(message.replyTo))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Custom headers
|
|
56
|
+
for (const [key, value] of Object.entries(message.headers)) {
|
|
57
|
+
formData.append(`h:${key}`, value)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Attachments
|
|
61
|
+
for (const attachment of message.attachments) {
|
|
62
|
+
const content = typeof attachment.content === 'string'
|
|
63
|
+
? new Blob([attachment.content], { type: attachment.contentType || 'application/octet-stream' })
|
|
64
|
+
: new Blob([attachment.content], { type: attachment.contentType || 'application/octet-stream' })
|
|
65
|
+
|
|
66
|
+
formData.append('attachment', content, attachment.filename)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const authString = Buffer.from(`api:${this.config.apiKey}`).toString('base64')
|
|
70
|
+
|
|
71
|
+
const response = await fetch(url, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: {
|
|
74
|
+
'Authorization': `Basic ${authString}`,
|
|
75
|
+
},
|
|
76
|
+
body: formData,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if (!response.ok) {
|
|
80
|
+
const errorBody = await response.text()
|
|
81
|
+
throw new MailError(`Mailgun API error: ${response.status} ${response.statusText}`, {
|
|
82
|
+
status: response.status,
|
|
83
|
+
body: errorBody,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = await response.json() as { id: string; message: string }
|
|
88
|
+
|
|
89
|
+
// Mailgun returns id wrapped in angle brackets like "<id@domain>"
|
|
90
|
+
const id = result.id ? result.id.replace(/[<>]/g, '') : crypto.randomUUID()
|
|
91
|
+
|
|
92
|
+
return { id }
|
|
93
|
+
}
|
|
94
|
+
}
|