@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.
Files changed (165) hide show
  1. package/README.md +45 -0
  2. package/package.json +83 -0
  3. package/src/auth/access_token.ts +122 -0
  4. package/src/auth/auth.ts +86 -0
  5. package/src/auth/index.ts +7 -0
  6. package/src/auth/middleware/authenticate.ts +64 -0
  7. package/src/auth/middleware/csrf.ts +62 -0
  8. package/src/auth/middleware/guest.ts +46 -0
  9. package/src/broadcast/broadcast_manager.ts +411 -0
  10. package/src/broadcast/client.ts +302 -0
  11. package/src/broadcast/index.ts +58 -0
  12. package/src/cache/cache_manager.ts +56 -0
  13. package/src/cache/cache_store.ts +31 -0
  14. package/src/cache/helpers.ts +74 -0
  15. package/src/cache/http_cache.ts +109 -0
  16. package/src/cache/index.ts +6 -0
  17. package/src/cache/memory_store.ts +63 -0
  18. package/src/cli/bootstrap.ts +37 -0
  19. package/src/cli/commands/generate_api.ts +74 -0
  20. package/src/cli/commands/generate_key.ts +46 -0
  21. package/src/cli/commands/generate_models.ts +48 -0
  22. package/src/cli/commands/migration_compare.ts +152 -0
  23. package/src/cli/commands/migration_fresh.ts +123 -0
  24. package/src/cli/commands/migration_generate.ts +79 -0
  25. package/src/cli/commands/migration_rollback.ts +53 -0
  26. package/src/cli/commands/migration_run.ts +44 -0
  27. package/src/cli/commands/queue_flush.ts +35 -0
  28. package/src/cli/commands/queue_retry.ts +34 -0
  29. package/src/cli/commands/queue_work.ts +40 -0
  30. package/src/cli/commands/scheduler_work.ts +45 -0
  31. package/src/cli/strav.ts +33 -0
  32. package/src/config/configuration.ts +105 -0
  33. package/src/config/loaders/base_loader.ts +69 -0
  34. package/src/config/loaders/env_loader.ts +112 -0
  35. package/src/config/loaders/typescript_loader.ts +56 -0
  36. package/src/config/types.ts +8 -0
  37. package/src/core/application.ts +4 -0
  38. package/src/core/container.ts +117 -0
  39. package/src/core/index.ts +3 -0
  40. package/src/core/inject.ts +39 -0
  41. package/src/database/database.ts +54 -0
  42. package/src/database/index.ts +30 -0
  43. package/src/database/introspector.ts +446 -0
  44. package/src/database/migration/differ.ts +308 -0
  45. package/src/database/migration/file_generator.ts +125 -0
  46. package/src/database/migration/index.ts +18 -0
  47. package/src/database/migration/runner.ts +133 -0
  48. package/src/database/migration/sql_generator.ts +378 -0
  49. package/src/database/migration/tracker.ts +76 -0
  50. package/src/database/migration/types.ts +189 -0
  51. package/src/database/query_builder.ts +474 -0
  52. package/src/encryption/encryption_manager.ts +209 -0
  53. package/src/encryption/helpers.ts +158 -0
  54. package/src/encryption/index.ts +3 -0
  55. package/src/encryption/types.ts +6 -0
  56. package/src/events/emitter.ts +101 -0
  57. package/src/events/index.ts +2 -0
  58. package/src/exceptions/errors.ts +75 -0
  59. package/src/exceptions/exception_handler.ts +126 -0
  60. package/src/exceptions/helpers.ts +25 -0
  61. package/src/exceptions/http_exception.ts +129 -0
  62. package/src/exceptions/index.ts +23 -0
  63. package/src/exceptions/strav_error.ts +11 -0
  64. package/src/generators/api_generator.ts +972 -0
  65. package/src/generators/config.ts +87 -0
  66. package/src/generators/doc_generator.ts +974 -0
  67. package/src/generators/index.ts +11 -0
  68. package/src/generators/model_generator.ts +586 -0
  69. package/src/generators/route_generator.ts +188 -0
  70. package/src/generators/test_generator.ts +1666 -0
  71. package/src/helpers/crypto.ts +4 -0
  72. package/src/helpers/env.ts +50 -0
  73. package/src/helpers/identity.ts +12 -0
  74. package/src/helpers/index.ts +4 -0
  75. package/src/helpers/strings.ts +67 -0
  76. package/src/http/context.ts +215 -0
  77. package/src/http/cookie.ts +59 -0
  78. package/src/http/cors.ts +163 -0
  79. package/src/http/index.ts +16 -0
  80. package/src/http/middleware.ts +39 -0
  81. package/src/http/rate_limit.ts +173 -0
  82. package/src/http/router.ts +556 -0
  83. package/src/http/server.ts +79 -0
  84. package/src/i18n/defaults/en/validation.json +20 -0
  85. package/src/i18n/helpers.ts +72 -0
  86. package/src/i18n/i18n_manager.ts +155 -0
  87. package/src/i18n/index.ts +4 -0
  88. package/src/i18n/middleware.ts +90 -0
  89. package/src/i18n/translator.ts +96 -0
  90. package/src/i18n/types.ts +17 -0
  91. package/src/logger/index.ts +6 -0
  92. package/src/logger/logger.ts +100 -0
  93. package/src/logger/request_logger.ts +19 -0
  94. package/src/logger/sinks/console_sink.ts +24 -0
  95. package/src/logger/sinks/file_sink.ts +24 -0
  96. package/src/logger/sinks/sink.ts +36 -0
  97. package/src/mail/css_inliner.ts +79 -0
  98. package/src/mail/helpers.ts +212 -0
  99. package/src/mail/index.ts +19 -0
  100. package/src/mail/mail_manager.ts +92 -0
  101. package/src/mail/transports/log_transport.ts +69 -0
  102. package/src/mail/transports/resend_transport.ts +59 -0
  103. package/src/mail/transports/sendgrid_transport.ts +77 -0
  104. package/src/mail/transports/smtp_transport.ts +48 -0
  105. package/src/mail/types.ts +80 -0
  106. package/src/notification/base_notification.ts +67 -0
  107. package/src/notification/channels/database_channel.ts +30 -0
  108. package/src/notification/channels/discord_channel.ts +43 -0
  109. package/src/notification/channels/email_channel.ts +37 -0
  110. package/src/notification/channels/webhook_channel.ts +45 -0
  111. package/src/notification/helpers.ts +214 -0
  112. package/src/notification/index.ts +20 -0
  113. package/src/notification/notification_manager.ts +126 -0
  114. package/src/notification/types.ts +122 -0
  115. package/src/orm/base_model.ts +351 -0
  116. package/src/orm/decorators.ts +127 -0
  117. package/src/orm/index.ts +4 -0
  118. package/src/policy/authorize.ts +44 -0
  119. package/src/policy/index.ts +3 -0
  120. package/src/policy/policy_result.ts +13 -0
  121. package/src/queue/index.ts +11 -0
  122. package/src/queue/queue.ts +338 -0
  123. package/src/queue/worker.ts +197 -0
  124. package/src/scheduler/cron.ts +140 -0
  125. package/src/scheduler/index.ts +7 -0
  126. package/src/scheduler/runner.ts +116 -0
  127. package/src/scheduler/schedule.ts +183 -0
  128. package/src/scheduler/scheduler.ts +47 -0
  129. package/src/schema/database_representation.ts +122 -0
  130. package/src/schema/define_association.ts +60 -0
  131. package/src/schema/define_schema.ts +46 -0
  132. package/src/schema/field_builder.ts +155 -0
  133. package/src/schema/field_definition.ts +66 -0
  134. package/src/schema/index.ts +21 -0
  135. package/src/schema/naming.ts +19 -0
  136. package/src/schema/postgres.ts +109 -0
  137. package/src/schema/registry.ts +157 -0
  138. package/src/schema/representation_builder.ts +479 -0
  139. package/src/schema/type_builder.ts +107 -0
  140. package/src/schema/types.ts +35 -0
  141. package/src/session/index.ts +4 -0
  142. package/src/session/middleware.ts +46 -0
  143. package/src/session/session.ts +308 -0
  144. package/src/session/session_manager.ts +81 -0
  145. package/src/storage/index.ts +13 -0
  146. package/src/storage/local_driver.ts +46 -0
  147. package/src/storage/s3_driver.ts +51 -0
  148. package/src/storage/storage.ts +43 -0
  149. package/src/storage/storage_manager.ts +59 -0
  150. package/src/storage/types.ts +42 -0
  151. package/src/storage/upload.ts +91 -0
  152. package/src/validation/index.ts +18 -0
  153. package/src/validation/rules.ts +170 -0
  154. package/src/validation/validate.ts +41 -0
  155. package/src/view/cache.ts +47 -0
  156. package/src/view/client/islands.ts +50 -0
  157. package/src/view/compiler.ts +185 -0
  158. package/src/view/engine.ts +139 -0
  159. package/src/view/escape.ts +14 -0
  160. package/src/view/index.ts +13 -0
  161. package/src/view/islands/island_builder.ts +161 -0
  162. package/src/view/islands/vue_plugin.ts +140 -0
  163. package/src/view/middleware/static.ts +35 -0
  164. package/src/view/tokenizer.ts +172 -0
  165. 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
+ }