@stravigor/core 0.1.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 +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +50 -0
- package/src/view/compiler.ts +185 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import juice from 'juice'
|
|
2
|
+
|
|
3
|
+
export interface InlinerOptions {
|
|
4
|
+
/** Enable CSS inlining (default: true). */
|
|
5
|
+
enabled: boolean
|
|
6
|
+
/** Enable Tailwind CSS compilation before inlining (default: false). */
|
|
7
|
+
tailwind: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Process rendered HTML for email delivery:
|
|
12
|
+
* 1. Optionally compile Tailwind classes to CSS
|
|
13
|
+
* 2. Inline all <style> blocks into style="" attributes via juice
|
|
14
|
+
*/
|
|
15
|
+
export async function inlineCss(html: string, options: InlinerOptions): Promise<string> {
|
|
16
|
+
if (!options.enabled) return html
|
|
17
|
+
|
|
18
|
+
let processed = html
|
|
19
|
+
|
|
20
|
+
if (options.tailwind) {
|
|
21
|
+
processed = await compileTailwind(processed)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return juice(processed, {
|
|
25
|
+
removeStyleTags: true,
|
|
26
|
+
preserveMediaQueries: true,
|
|
27
|
+
preserveFontFaces: true,
|
|
28
|
+
preserveKeyFrames: true,
|
|
29
|
+
applyWidthAttributes: true,
|
|
30
|
+
applyHeightAttributes: true,
|
|
31
|
+
applyAttributesTableElements: true,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract Tailwind utility classes from HTML and compile them to CSS,
|
|
37
|
+
* then inject a <style> block for juice to inline.
|
|
38
|
+
*
|
|
39
|
+
* Uses dynamic import — silently skips if tailwindcss is not installed.
|
|
40
|
+
*/
|
|
41
|
+
async function compileTailwind(html: string): Promise<string> {
|
|
42
|
+
try {
|
|
43
|
+
// @ts-ignore: Tailwind is optional
|
|
44
|
+
const { compile } = await import('tailwindcss')
|
|
45
|
+
|
|
46
|
+
const compiler = await compile('@tailwind utilities;')
|
|
47
|
+
const classes = extractClasses(html)
|
|
48
|
+
|
|
49
|
+
if (classes.length === 0) return html
|
|
50
|
+
|
|
51
|
+
const css = compiler.build(classes)
|
|
52
|
+
if (!css) return html
|
|
53
|
+
|
|
54
|
+
const insertPoint = html.indexOf('</head>')
|
|
55
|
+
if (insertPoint !== -1) {
|
|
56
|
+
return html.slice(0, insertPoint) + `<style>${css}</style>` + html.slice(insertPoint)
|
|
57
|
+
}
|
|
58
|
+
return `<style>${css}</style>` + html
|
|
59
|
+
} catch {
|
|
60
|
+
// tailwindcss not installed or API mismatch — skip silently
|
|
61
|
+
return html
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Extract class names from HTML class="..." attributes. */
|
|
66
|
+
function extractClasses(html: string): string[] {
|
|
67
|
+
const classRegex = /class\s*=\s*["']([^"']*)["']/gi
|
|
68
|
+
const classes = new Set<string>()
|
|
69
|
+
|
|
70
|
+
let match
|
|
71
|
+
while ((match = classRegex.exec(html)) !== null) {
|
|
72
|
+
for (const cls of match[1]!.split(/\s+/)) {
|
|
73
|
+
const trimmed = cls.trim()
|
|
74
|
+
if (trimmed) classes.add(trimmed)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return [...classes]
|
|
79
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import MailManager from './mail_manager.ts'
|
|
2
|
+
import ViewEngine from '../view/engine.ts'
|
|
3
|
+
import { inlineCss } from './css_inliner.ts'
|
|
4
|
+
import Queue from '../queue/queue.ts'
|
|
5
|
+
import type { MailMessage, MailResult, MailAttachment } from './types.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fluent email builder. Returned by `mail.to()`.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* await mail.to('user@example.com')
|
|
12
|
+
* .subject('Welcome!')
|
|
13
|
+
* .template('welcome', { name: 'Alice' })
|
|
14
|
+
* .send()
|
|
15
|
+
*
|
|
16
|
+
* await mail.to(user.email)
|
|
17
|
+
* .from('support@app.com')
|
|
18
|
+
* .subject('Reset Password')
|
|
19
|
+
* .template('reset', { token })
|
|
20
|
+
* .queue()
|
|
21
|
+
*/
|
|
22
|
+
export class PendingMail {
|
|
23
|
+
private _from?: string
|
|
24
|
+
private _to: string | string[]
|
|
25
|
+
private _cc?: string | string[]
|
|
26
|
+
private _bcc?: string | string[]
|
|
27
|
+
private _replyTo?: string
|
|
28
|
+
private _subject = ''
|
|
29
|
+
private _html?: string
|
|
30
|
+
private _text?: string
|
|
31
|
+
private _attachments: MailAttachment[] = []
|
|
32
|
+
private _template?: string
|
|
33
|
+
private _templateData?: Record<string, unknown>
|
|
34
|
+
|
|
35
|
+
constructor(to: string | string[]) {
|
|
36
|
+
this._to = to
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
from(address: string): this {
|
|
40
|
+
this._from = address
|
|
41
|
+
return this
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
cc(address: string | string[]): this {
|
|
45
|
+
this._cc = address
|
|
46
|
+
return this
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
bcc(address: string | string[]): this {
|
|
50
|
+
this._bcc = address
|
|
51
|
+
return this
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
replyTo(address: string): this {
|
|
55
|
+
this._replyTo = address
|
|
56
|
+
return this
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
subject(value: string): this {
|
|
60
|
+
this._subject = value
|
|
61
|
+
return this
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Set raw HTML content (bypasses template rendering). */
|
|
65
|
+
html(value: string): this {
|
|
66
|
+
this._html = value
|
|
67
|
+
return this
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Set plain text content. */
|
|
71
|
+
text(value: string): this {
|
|
72
|
+
this._text = value
|
|
73
|
+
return this
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Use a .strav template. Name is relative to the mail template prefix. */
|
|
77
|
+
template(name: string, data: Record<string, unknown> = {}): this {
|
|
78
|
+
this._template = name
|
|
79
|
+
this._templateData = data
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
attach(attachment: MailAttachment): this {
|
|
84
|
+
this._attachments.push(attachment)
|
|
85
|
+
return this
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build the MailMessage, rendering template + inlining CSS if needed. */
|
|
89
|
+
async build(): Promise<MailMessage> {
|
|
90
|
+
const config = MailManager.config
|
|
91
|
+
let html = this._html
|
|
92
|
+
const text = this._text
|
|
93
|
+
|
|
94
|
+
if (this._template) {
|
|
95
|
+
const templateName = `${config.templatePrefix}.${this._template}`
|
|
96
|
+
html = await ViewEngine.instance.render(templateName, this._templateData ?? {})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (html) {
|
|
100
|
+
html = await inlineCss(html, {
|
|
101
|
+
enabled: config.inlineCss,
|
|
102
|
+
tailwind: config.tailwind,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
from: this._from ?? config.from,
|
|
108
|
+
to: this._to,
|
|
109
|
+
cc: this._cc,
|
|
110
|
+
bcc: this._bcc,
|
|
111
|
+
replyTo: this._replyTo,
|
|
112
|
+
subject: this._subject,
|
|
113
|
+
html,
|
|
114
|
+
text,
|
|
115
|
+
attachments: this._attachments.length > 0 ? this._attachments : undefined,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Send the email immediately via the configured transport. */
|
|
120
|
+
async send(): Promise<MailResult> {
|
|
121
|
+
const message = await this.build()
|
|
122
|
+
return MailManager.transport.send(message)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Push the email onto the job queue for async sending. */
|
|
126
|
+
async queue(options?: { queue?: string; delay?: number }): Promise<number> {
|
|
127
|
+
const message = await this.build()
|
|
128
|
+
return Queue.push('strav:send-mail', message, options)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Mail helper object — the primary API for sending emails.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* import { mail } from '@stravigor/core/mail'
|
|
137
|
+
*
|
|
138
|
+
* // Fluent builder
|
|
139
|
+
* await mail.to('user@example.com').subject('Hello').template('welcome', { name }).send()
|
|
140
|
+
*
|
|
141
|
+
* // Convenience send
|
|
142
|
+
* await mail.send({ to: 'user@example.com', subject: 'Hello', template: 'welcome', data: { name } })
|
|
143
|
+
*
|
|
144
|
+
* // Raw HTML send
|
|
145
|
+
* await mail.raw({ to: 'user@example.com', subject: 'Hello', html: '<h1>Hi</h1>' })
|
|
146
|
+
*/
|
|
147
|
+
export const mail = {
|
|
148
|
+
/** Start building an email to the given recipient(s). Returns a fluent PendingMail. */
|
|
149
|
+
to(address: string | string[]): PendingMail {
|
|
150
|
+
return new PendingMail(address)
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/** Send an email using a template. Convenience wrapper for the fluent API. */
|
|
154
|
+
async send(options: {
|
|
155
|
+
to: string | string[]
|
|
156
|
+
from?: string
|
|
157
|
+
cc?: string | string[]
|
|
158
|
+
bcc?: string | string[]
|
|
159
|
+
replyTo?: string
|
|
160
|
+
subject: string
|
|
161
|
+
template: string
|
|
162
|
+
data?: Record<string, unknown>
|
|
163
|
+
attachments?: MailAttachment[]
|
|
164
|
+
}): Promise<MailResult> {
|
|
165
|
+
const pending = new PendingMail(options.to)
|
|
166
|
+
.subject(options.subject)
|
|
167
|
+
.template(options.template, options.data)
|
|
168
|
+
if (options.from) pending.from(options.from)
|
|
169
|
+
if (options.cc) pending.cc(options.cc)
|
|
170
|
+
if (options.bcc) pending.bcc(options.bcc)
|
|
171
|
+
if (options.replyTo) pending.replyTo(options.replyTo)
|
|
172
|
+
if (options.attachments) {
|
|
173
|
+
for (const a of options.attachments) pending.attach(a)
|
|
174
|
+
}
|
|
175
|
+
return pending.send()
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/** Send a raw email without template rendering. */
|
|
179
|
+
async raw(options: {
|
|
180
|
+
to: string | string[]
|
|
181
|
+
from?: string
|
|
182
|
+
cc?: string | string[]
|
|
183
|
+
bcc?: string | string[]
|
|
184
|
+
replyTo?: string
|
|
185
|
+
subject: string
|
|
186
|
+
html?: string
|
|
187
|
+
text?: string
|
|
188
|
+
attachments?: MailAttachment[]
|
|
189
|
+
}): Promise<MailResult> {
|
|
190
|
+
const pending = new PendingMail(options.to).subject(options.subject)
|
|
191
|
+
if (options.from) pending.from(options.from)
|
|
192
|
+
if (options.html) pending.html(options.html)
|
|
193
|
+
if (options.text) pending.text(options.text)
|
|
194
|
+
if (options.cc) pending.cc(options.cc)
|
|
195
|
+
if (options.bcc) pending.bcc(options.bcc)
|
|
196
|
+
if (options.replyTo) pending.replyTo(options.replyTo)
|
|
197
|
+
if (options.attachments) {
|
|
198
|
+
for (const a of options.attachments) pending.attach(a)
|
|
199
|
+
}
|
|
200
|
+
return pending.send()
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Register the built-in queue handler for async mail sending.
|
|
205
|
+
* Call this in your app bootstrap after Queue is configured.
|
|
206
|
+
*/
|
|
207
|
+
registerQueueHandler(): void {
|
|
208
|
+
Queue.handle<MailMessage>('strav:send-mail', async (message) => {
|
|
209
|
+
await MailManager.transport.send(message)
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { default, default as MailManager } from './mail_manager.ts'
|
|
2
|
+
export { mail, PendingMail } from './helpers.ts'
|
|
3
|
+
export { SmtpTransport } from './transports/smtp_transport.ts'
|
|
4
|
+
export { ResendTransport } from './transports/resend_transport.ts'
|
|
5
|
+
export { SendGridTransport } from './transports/sendgrid_transport.ts'
|
|
6
|
+
export { LogTransport } from './transports/log_transport.ts'
|
|
7
|
+
export { inlineCss } from './css_inliner.ts'
|
|
8
|
+
export type {
|
|
9
|
+
MailTransport,
|
|
10
|
+
MailMessage,
|
|
11
|
+
MailResult,
|
|
12
|
+
MailAttachment,
|
|
13
|
+
MailConfig,
|
|
14
|
+
SmtpConfig,
|
|
15
|
+
ResendConfig,
|
|
16
|
+
SendGridConfig,
|
|
17
|
+
LogConfig,
|
|
18
|
+
} from './types.ts'
|
|
19
|
+
export type { InlinerOptions } from './css_inliner.ts'
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { inject } from '../core/inject.ts'
|
|
2
|
+
import { ConfigurationError } from '../exceptions/errors.ts'
|
|
3
|
+
import Configuration from '../config/configuration.ts'
|
|
4
|
+
import { SmtpTransport } from './transports/smtp_transport.ts'
|
|
5
|
+
import { ResendTransport } from './transports/resend_transport.ts'
|
|
6
|
+
import { SendGridTransport } from './transports/sendgrid_transport.ts'
|
|
7
|
+
import { LogTransport } from './transports/log_transport.ts'
|
|
8
|
+
import type { MailTransport, MailConfig } from './types.ts'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Central mail configuration hub.
|
|
12
|
+
*
|
|
13
|
+
* Resolved once via the DI container — reads the mail config
|
|
14
|
+
* and initializes the appropriate transport driver.
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* app.singleton(MailManager)
|
|
18
|
+
* app.resolve(MailManager)
|
|
19
|
+
*
|
|
20
|
+
* // Plug in a custom transport
|
|
21
|
+
* MailManager.useTransport(new MyCustomTransport())
|
|
22
|
+
*/
|
|
23
|
+
@inject
|
|
24
|
+
export default class MailManager {
|
|
25
|
+
private static _transport: MailTransport
|
|
26
|
+
private static _config: MailConfig
|
|
27
|
+
|
|
28
|
+
constructor(config: Configuration) {
|
|
29
|
+
const driverName = config.get('mail.default', 'log') as string
|
|
30
|
+
|
|
31
|
+
MailManager._config = {
|
|
32
|
+
default: driverName,
|
|
33
|
+
from: config.get('mail.from', 'noreply@localhost') as string,
|
|
34
|
+
templatePrefix: config.get('mail.templatePrefix', 'emails') as string,
|
|
35
|
+
inlineCss: config.get('mail.inlineCss', true) as boolean,
|
|
36
|
+
tailwind: config.get('mail.tailwind', false) as boolean,
|
|
37
|
+
smtp: {
|
|
38
|
+
host: '127.0.0.1',
|
|
39
|
+
port: 587,
|
|
40
|
+
secure: false,
|
|
41
|
+
...(config.get('mail.smtp', {}) as object),
|
|
42
|
+
},
|
|
43
|
+
resend: {
|
|
44
|
+
apiKey: '',
|
|
45
|
+
...(config.get('mail.resend', {}) as object),
|
|
46
|
+
},
|
|
47
|
+
sendgrid: {
|
|
48
|
+
apiKey: '',
|
|
49
|
+
...(config.get('mail.sendgrid', {}) as object),
|
|
50
|
+
},
|
|
51
|
+
log: {
|
|
52
|
+
output: 'console',
|
|
53
|
+
...(config.get('mail.log', {}) as object),
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
MailManager._transport = MailManager.createTransport(driverName)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private static createTransport(driver: string): MailTransport {
|
|
61
|
+
switch (driver) {
|
|
62
|
+
case 'smtp':
|
|
63
|
+
return new SmtpTransport(MailManager._config.smtp)
|
|
64
|
+
case 'resend':
|
|
65
|
+
return new ResendTransport(MailManager._config.resend)
|
|
66
|
+
case 'sendgrid':
|
|
67
|
+
return new SendGridTransport(MailManager._config.sendgrid)
|
|
68
|
+
case 'log':
|
|
69
|
+
return new LogTransport(MailManager._config.log)
|
|
70
|
+
default:
|
|
71
|
+
throw new ConfigurationError(
|
|
72
|
+
`Unknown mail transport: ${driver}. Use MailManager.useTransport() for custom transports.`
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
static get transport(): MailTransport {
|
|
78
|
+
if (!MailManager._transport) {
|
|
79
|
+
throw new ConfigurationError('MailManager not configured. Resolve it through the container first.')
|
|
80
|
+
}
|
|
81
|
+
return MailManager._transport
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static get config(): MailConfig {
|
|
85
|
+
return MailManager._config
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Swap the transport at runtime (e.g., for testing or a custom provider). */
|
|
89
|
+
static useTransport(transport: MailTransport): void {
|
|
90
|
+
MailManager._transport = transport
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { MailTransport, MailMessage, MailResult, LogConfig } from '../types.ts'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Development/testing transport — logs email details to console or a file.
|
|
5
|
+
* No external dependencies required.
|
|
6
|
+
*/
|
|
7
|
+
export class LogTransport implements MailTransport {
|
|
8
|
+
private output: 'console' | string
|
|
9
|
+
|
|
10
|
+
constructor(config: LogConfig) {
|
|
11
|
+
this.output = config.output ?? 'console'
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async send(message: MailMessage): Promise<MailResult> {
|
|
15
|
+
const timestamp = new Date().toISOString()
|
|
16
|
+
const separator = '─'.repeat(60)
|
|
17
|
+
const toList = Array.isArray(message.to) ? message.to.join(', ') : message.to
|
|
18
|
+
|
|
19
|
+
const lines = [
|
|
20
|
+
separator,
|
|
21
|
+
`[Mail] ${timestamp}`,
|
|
22
|
+
`From: ${message.from}`,
|
|
23
|
+
`To: ${toList}`,
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
if (message.cc) {
|
|
27
|
+
const cc = Array.isArray(message.cc) ? message.cc.join(', ') : message.cc
|
|
28
|
+
lines.push(`CC: ${cc}`)
|
|
29
|
+
}
|
|
30
|
+
if (message.bcc) {
|
|
31
|
+
const bcc = Array.isArray(message.bcc) ? message.bcc.join(', ') : message.bcc
|
|
32
|
+
lines.push(`BCC: ${bcc}`)
|
|
33
|
+
}
|
|
34
|
+
if (message.replyTo) lines.push(`Reply-To: ${message.replyTo}`)
|
|
35
|
+
|
|
36
|
+
lines.push(`Subject: ${message.subject}`)
|
|
37
|
+
|
|
38
|
+
if (message.text) {
|
|
39
|
+
lines.push('', '--- Text ---', message.text)
|
|
40
|
+
}
|
|
41
|
+
if (message.html) {
|
|
42
|
+
lines.push('', '--- HTML ---', message.html)
|
|
43
|
+
}
|
|
44
|
+
if (message.attachments?.length) {
|
|
45
|
+
lines.push('', `--- Attachments (${message.attachments.length}) ---`)
|
|
46
|
+
for (const a of message.attachments) {
|
|
47
|
+
lines.push(` ${a.filename} (${a.contentType ?? 'unknown'})`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
lines.push(separator, '')
|
|
52
|
+
|
|
53
|
+
const output = lines.join('\n')
|
|
54
|
+
|
|
55
|
+
if (this.output === 'console') {
|
|
56
|
+
console.log(output)
|
|
57
|
+
} else {
|
|
58
|
+
const file = Bun.file(this.output)
|
|
59
|
+
const existing = (await file.exists()) ? await file.text() : ''
|
|
60
|
+
await Bun.write(this.output, existing + output + '\n')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const messageId = `log-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
|
64
|
+
return {
|
|
65
|
+
messageId,
|
|
66
|
+
accepted: Array.isArray(message.to) ? message.to : [message.to],
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ExternalServiceError } from '../../exceptions/errors.ts'
|
|
2
|
+
import type { MailTransport, MailMessage, MailResult, ResendConfig } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resend HTTP API transport.
|
|
6
|
+
* Uses fetch — no SDK dependency required.
|
|
7
|
+
*
|
|
8
|
+
* @see https://resend.com/docs/api-reference/emails/send-email
|
|
9
|
+
*/
|
|
10
|
+
export class ResendTransport implements MailTransport {
|
|
11
|
+
private apiKey: string
|
|
12
|
+
private baseUrl: string
|
|
13
|
+
|
|
14
|
+
constructor(config: ResendConfig) {
|
|
15
|
+
this.apiKey = config.apiKey
|
|
16
|
+
this.baseUrl = config.baseUrl ?? 'https://api.resend.com'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async send(message: MailMessage): Promise<MailResult> {
|
|
20
|
+
const body: Record<string, unknown> = {
|
|
21
|
+
from: message.from,
|
|
22
|
+
to: Array.isArray(message.to) ? message.to : [message.to],
|
|
23
|
+
subject: message.subject,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (message.cc) body.cc = Array.isArray(message.cc) ? message.cc : [message.cc]
|
|
27
|
+
if (message.bcc) body.bcc = Array.isArray(message.bcc) ? message.bcc : [message.bcc]
|
|
28
|
+
if (message.replyTo) body.reply_to = message.replyTo
|
|
29
|
+
if (message.html) body.html = message.html
|
|
30
|
+
if (message.text) body.text = message.text
|
|
31
|
+
|
|
32
|
+
if (message.attachments?.length) {
|
|
33
|
+
body.attachments = message.attachments.map((a) => ({
|
|
34
|
+
filename: a.filename,
|
|
35
|
+
content: typeof a.content === 'string'
|
|
36
|
+
? a.content
|
|
37
|
+
: Buffer.from(a.content).toString('base64'),
|
|
38
|
+
content_type: a.contentType,
|
|
39
|
+
}))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const response = await fetch(`${this.baseUrl}/emails`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: {
|
|
45
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
46
|
+
'Content-Type': 'application/json',
|
|
47
|
+
},
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
if (!response.ok) {
|
|
52
|
+
const error = await response.text()
|
|
53
|
+
throw new ExternalServiceError('Resend', response.status, error)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const data = (await response.json()) as { id: string }
|
|
57
|
+
return { messageId: data.id }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ExternalServiceError } from '../../exceptions/errors.ts'
|
|
2
|
+
import type { MailTransport, MailMessage, MailResult, SendGridConfig } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SendGrid v3 Mail Send API transport.
|
|
6
|
+
* Uses fetch — no SDK dependency required.
|
|
7
|
+
*
|
|
8
|
+
* @see https://docs.sendgrid.com/api-reference/mail-send/mail-send
|
|
9
|
+
*/
|
|
10
|
+
export class SendGridTransport implements MailTransport {
|
|
11
|
+
private apiKey: string
|
|
12
|
+
private baseUrl: string
|
|
13
|
+
|
|
14
|
+
constructor(config: SendGridConfig) {
|
|
15
|
+
this.apiKey = config.apiKey
|
|
16
|
+
this.baseUrl = config.baseUrl ?? 'https://api.sendgrid.com/v3'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async send(message: MailMessage): Promise<MailResult> {
|
|
20
|
+
const toArray = Array.isArray(message.to) ? message.to : [message.to]
|
|
21
|
+
|
|
22
|
+
const personalizations: Record<string, unknown>[] = [{
|
|
23
|
+
to: toArray.map((email) => ({ email })),
|
|
24
|
+
}]
|
|
25
|
+
|
|
26
|
+
if (message.cc) {
|
|
27
|
+
const ccArray = Array.isArray(message.cc) ? message.cc : [message.cc]
|
|
28
|
+
personalizations[0]!.cc = ccArray.map((email) => ({ email }))
|
|
29
|
+
}
|
|
30
|
+
if (message.bcc) {
|
|
31
|
+
const bccArray = Array.isArray(message.bcc) ? message.bcc : [message.bcc]
|
|
32
|
+
personalizations[0]!.bcc = bccArray.map((email) => ({ email }))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const content: { type: string; value: string }[] = []
|
|
36
|
+
if (message.text) content.push({ type: 'text/plain', value: message.text })
|
|
37
|
+
if (message.html) content.push({ type: 'text/html', value: message.html })
|
|
38
|
+
|
|
39
|
+
const body: Record<string, unknown> = {
|
|
40
|
+
personalizations,
|
|
41
|
+
from: { email: message.from },
|
|
42
|
+
subject: message.subject,
|
|
43
|
+
content,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (message.replyTo) body.reply_to = { email: message.replyTo }
|
|
47
|
+
|
|
48
|
+
if (message.attachments?.length) {
|
|
49
|
+
body.attachments = message.attachments.map((a) => ({
|
|
50
|
+
filename: a.filename,
|
|
51
|
+
content: typeof a.content === 'string'
|
|
52
|
+
? Buffer.from(a.content).toString('base64')
|
|
53
|
+
: Buffer.from(a.content).toString('base64'),
|
|
54
|
+
type: a.contentType,
|
|
55
|
+
disposition: a.cid ? 'inline' : 'attachment',
|
|
56
|
+
content_id: a.cid,
|
|
57
|
+
}))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const response = await fetch(`${this.baseUrl}/mail/send`, {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: {
|
|
63
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
64
|
+
'Content-Type': 'application/json',
|
|
65
|
+
},
|
|
66
|
+
body: JSON.stringify(body),
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
const error = await response.text()
|
|
71
|
+
throw new ExternalServiceError('SendGrid', response.status, error)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const messageId = response.headers.get('X-Message-Id') ?? undefined
|
|
75
|
+
return { messageId, accepted: toArray }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import nodemailer from 'nodemailer'
|
|
2
|
+
import type { MailTransport, MailMessage, MailResult, SmtpConfig } from '../types.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SMTP transport via nodemailer.
|
|
6
|
+
* Supports all standard SMTP features including TLS, auth, and attachments.
|
|
7
|
+
*/
|
|
8
|
+
export class SmtpTransport implements MailTransport {
|
|
9
|
+
private transporter: nodemailer.Transporter
|
|
10
|
+
|
|
11
|
+
constructor(config: SmtpConfig) {
|
|
12
|
+
this.transporter = nodemailer.createTransport({
|
|
13
|
+
host: config.host,
|
|
14
|
+
port: config.port,
|
|
15
|
+
secure: config.secure,
|
|
16
|
+
auth: config.auth?.user ? { user: config.auth.user, pass: config.auth.pass } : undefined,
|
|
17
|
+
})
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async send(message: MailMessage): Promise<MailResult> {
|
|
21
|
+
const info = await this.transporter.sendMail({
|
|
22
|
+
from: message.from,
|
|
23
|
+
to: Array.isArray(message.to) ? message.to.join(', ') : message.to,
|
|
24
|
+
cc: message.cc
|
|
25
|
+
? Array.isArray(message.cc) ? message.cc.join(', ') : message.cc
|
|
26
|
+
: undefined,
|
|
27
|
+
bcc: message.bcc
|
|
28
|
+
? Array.isArray(message.bcc) ? message.bcc.join(', ') : message.bcc
|
|
29
|
+
: undefined,
|
|
30
|
+
replyTo: message.replyTo,
|
|
31
|
+
subject: message.subject,
|
|
32
|
+
html: message.html,
|
|
33
|
+
text: message.text,
|
|
34
|
+
attachments: message.attachments?.map((a) => ({
|
|
35
|
+
filename: a.filename,
|
|
36
|
+
content: a.content,
|
|
37
|
+
contentType: a.contentType,
|
|
38
|
+
cid: a.cid,
|
|
39
|
+
})),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
messageId: info.messageId,
|
|
44
|
+
accepted: Array.isArray(info.accepted) ? info.accepted.map(String) : [],
|
|
45
|
+
rejected: Array.isArray(info.rejected) ? info.rejected.map(String) : [],
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|