@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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Email sending for MantiqJS (coming soon).
4
4
 
5
- Part of [MantiqJS](https://github.com/abdullahkhan/mantiq) — a batteries-included TypeScript web framework for Bun.
5
+ Part of [MantiqJS](https://github.com/mantiqjs/mantiq) — a batteries-included TypeScript web framework for Bun.
6
6
 
7
7
  ## Installation
8
8
 
@@ -12,7 +12,7 @@ bun add @mantiq/mail
12
12
 
13
13
  ## Documentation
14
14
 
15
- See the [MantiqJS repository](https://github.com/abdullahkhan/mantiq) for full documentation.
15
+ See the [MantiqJS repository](https://github.com/mantiqjs/mantiq) for full documentation.
16
16
 
17
17
  ## License
18
18
 
package/package.json CHANGED
@@ -1,18 +1,18 @@
1
1
  {
2
2
  "name": "@mantiq/mail",
3
- "version": "0.1.2",
4
- "description": "Mail transports, message builder",
3
+ "version": "0.2.0",
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",
8
- "homepage": "https://github.com/abdullahkhan/mantiq/tree/main/packages/mail",
8
+ "homepage": "https://github.com/mantiqjs/mantiq/tree/main/packages/mail",
9
9
  "repository": {
10
10
  "type": "git",
11
- "url": "https://github.com/abdullahkhan/mantiq.git",
11
+ "url": "https://github.com/mantiqjs/mantiq.git",
12
12
  "directory": "packages/mail"
13
13
  },
14
14
  "bugs": {
15
- "url": "https://github.com/abdullahkhan/mantiq/issues"
15
+ "url": "https://github.com/mantiqjs/mantiq/issues"
16
16
  },
17
17
  "keywords": [
18
18
  "mantiq",
@@ -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
+ }
@@ -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
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,5 @@
1
+ import type { Message } from '../Message.ts'
2
+
3
+ export interface MailTransport {
4
+ send(message: Message): Promise<{ id: string }>
5
+ }
@@ -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
+ }