@gravito/signal 1.0.0-alpha.6 → 1.0.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/src/Mailable.ts CHANGED
@@ -13,6 +13,7 @@ export abstract class Mailable implements Queueable {
13
13
  protected renderer?: Renderer
14
14
  private rendererResolver?: () => Promise<Renderer>
15
15
  protected renderData: Record<string, unknown> = {}
16
+ protected config?: MailConfig
16
17
 
17
18
  // ===== Fluent API (Envelope Construction) =====
18
19
 
@@ -46,7 +47,7 @@ export abstract class Mailable implements Queueable {
46
47
  return this
47
48
  }
48
49
 
49
- priority(level: 'high' | 'normal' | 'low'): this {
50
+ emailPriority(level: 'high' | 'normal' | 'low'): this {
50
51
  this.envelope.priority = level
51
52
  return this
52
53
  }
@@ -82,10 +83,17 @@ export abstract class Mailable implements Queueable {
82
83
  * Set the content using a React component.
83
84
  * Dynamically imports ReactRenderer to avoid hard dependency errors if React is not installed.
84
85
  */
85
- react<P extends object>(component: ComponentType, props?: P): this {
86
+ react<P extends object>(
87
+ component: ComponentType,
88
+ props?: P,
89
+ deps?: {
90
+ createElement?: (...args: any[]) => any
91
+ renderToStaticMarkup?: (element: any) => string
92
+ }
93
+ ): this {
86
94
  this.rendererResolver = async () => {
87
95
  const { ReactRenderer } = await import('./renderers/ReactRenderer')
88
- return new ReactRenderer(component, props)
96
+ return new ReactRenderer(component, props, deps)
89
97
  }
90
98
  return this
91
99
  }
@@ -94,10 +102,18 @@ export abstract class Mailable implements Queueable {
94
102
  * Set the content using a Vue component.
95
103
  * Dynamically imports VueRenderer to avoid hard dependency errors if Vue is not installed.
96
104
  */
97
- vue<P extends object>(component: ComponentType, props?: P): this {
105
+ vue<P extends object>(
106
+ component: ComponentType,
107
+ props?: P,
108
+ deps?: {
109
+ createSSRApp?: (...args: any[]) => any
110
+ h?: (...args: any[]) => any
111
+ renderToString?: (app: any) => Promise<string>
112
+ }
113
+ ): this {
98
114
  this.rendererResolver = async () => {
99
115
  const { VueRenderer } = await import('./renderers/VueRenderer')
100
- return new VueRenderer(component, props as any)
116
+ return new VueRenderer(component, props as any, deps)
101
117
  }
102
118
  return this
103
119
  }
@@ -114,6 +130,7 @@ export abstract class Mailable implements Queueable {
114
130
  queueName?: string
115
131
  connectionName?: string
116
132
  delaySeconds?: number
133
+ priority?: number | string
117
134
 
118
135
  onQueue(queue: string): this {
119
136
  this.queueName = queue
@@ -130,13 +147,29 @@ export abstract class Mailable implements Queueable {
130
147
  return this
131
148
  }
132
149
 
150
+ withPriority(priority: string | number): this {
151
+ this.priority = priority
152
+ return this
153
+ }
154
+
133
155
  /**
134
156
  * Queue the mailable for sending.
135
157
  */
136
158
  async queue(): Promise<void> {
137
- // Avoid circular dependency by dynamically importing OrbitSignal
138
- const { OrbitSignal } = await import('./OrbitSignal')
139
- return OrbitSignal.getInstance().queue(this)
159
+ // We should ideally use the container to get the mail service
160
+ // But since Mailable might be used outside a core context, we'll try a safe approach.
161
+ try {
162
+ // biome-ignore lint/suspicious/noTsIgnore: Global access to app() helper from core
163
+ // @ts-ignore
164
+ const { app } = await import('@gravito/core')
165
+ const mail = app().container.make<any>('mail')
166
+ if (mail) {
167
+ return mail.queue(this)
168
+ }
169
+ } catch (_e) {
170
+ // Fallback if core is not available
171
+ console.warn('[Mailable] Could not auto-resolve mail service for queuing.')
172
+ }
140
173
  }
141
174
 
142
175
  // ===== I18n Support =====
@@ -178,6 +211,7 @@ export abstract class Mailable implements Queueable {
178
211
  */
179
212
  async buildEnvelope(configPromise: MailConfig | Promise<MailConfig>): Promise<Envelope> {
180
213
  const config = await Promise.resolve(configPromise)
214
+ this.config = config
181
215
 
182
216
  // Inject translator from config if available
183
217
  if (config.translator) {
@@ -188,8 +222,8 @@ export abstract class Mailable implements Queueable {
188
222
 
189
223
  // Ensure Renderer is initialized if using TemplateRenderer with config path
190
224
  if (this.renderer instanceof TemplateRenderer && config.viewsDir) {
191
- // Here we could re-initialize TemplateRenderer if we had a setter for viewsDir
192
- // For now, it defaults to process.cwd()/src/emails which is standard
225
+ // Re-initialize or update TemplateRenderer with the correct directory
226
+ this.renderer = new TemplateRenderer((this.renderer as any).template, config.viewsDir)
193
227
  }
194
228
 
195
229
  const envelope: Envelope = {
@@ -1,4 +1,4 @@
1
- import type { GravitoOrbit, PlanetCore } from 'gravito-core'
1
+ import type { GravitoOrbit, PlanetCore } from '@gravito/core'
2
2
  import { DevMailbox } from './dev/DevMailbox'
3
3
  import { DevServer } from './dev/DevServer'
4
4
  import type { Mailable } from './Mailable'
@@ -7,41 +7,12 @@ import { MemoryTransport } from './transports/MemoryTransport'
7
7
  import type { MailConfig, Message } from './types'
8
8
 
9
9
  export class OrbitSignal implements GravitoOrbit {
10
- private static instance?: OrbitSignal
11
10
  private config: MailConfig
12
11
  private devMailbox?: DevMailbox
12
+ private core?: PlanetCore
13
13
 
14
14
  constructor(config: MailConfig = {}) {
15
15
  this.config = config
16
- OrbitSignal.instance = this
17
- }
18
-
19
- /**
20
- * Get the singleton instance of OrbitSignal
21
- *
22
- * @returns The singleton instance of OrbitSignal.
23
- * @throws {Error} If OrbitSignal has not been initialized.
24
- */
25
- static getInstance(): OrbitSignal {
26
- if (!OrbitSignal.instance) {
27
- throw new Error('OrbitSignal has not been initialized. Call OrbitSignal.configure() first.')
28
- }
29
- return OrbitSignal.instance
30
- }
31
-
32
- /**
33
- * Configure the OrbitSignal instance
34
- *
35
- * @param config - The mail configuration object.
36
- * @returns A new instance of OrbitSignal.
37
- */
38
- static configure(config: MailConfig): OrbitSignal {
39
- // Basic validation
40
- if (!config.transport && !config.devMode) {
41
- console.warn('[OrbitSignal] No transport provided, falling back to LogTransport')
42
- config.transport = new LogTransport()
43
- }
44
- return new OrbitSignal(config)
45
16
  }
46
17
 
47
18
  /**
@@ -50,29 +21,33 @@ export class OrbitSignal implements GravitoOrbit {
50
21
  * @param core - The PlanetCore instance.
51
22
  */
52
23
  install(core: PlanetCore): void {
53
- core.logger.info('[OrbitSignal] Initializing Mail Service (Exposed as: mail)')
24
+ this.core = core
25
+ core.logger.info('[OrbitSignal] Initializing Mail Service')
54
26
 
55
- // Ensure transport exists (fallback to Log if not set)
27
+ // 1. Ensure transport exists (fallback to Log if not set)
56
28
  if (!this.config.transport && !this.config.devMode) {
57
29
  this.config.transport = new LogTransport()
58
30
  }
59
31
 
60
- // In Dev Mode, override transport and setup Dev Server
32
+ // 2. In Dev Mode, override transport and setup Dev Server
61
33
  if (this.config.devMode) {
62
34
  this.devMailbox = new DevMailbox()
63
35
  this.config.transport = new MemoryTransport(this.devMailbox)
64
36
  core.logger.info('[OrbitSignal] Dev Mode Enabled: Emails will be intercepted to Dev Mailbox')
65
37
 
66
- const devServer = new DevServer(this.devMailbox, this.config.devUiPrefix || '/__mail')
38
+ const devServer = new DevServer(this.devMailbox, this.config.devUiPrefix || '/__mail', {
39
+ allowInProduction: this.config.devUiAllowInProduction,
40
+ gate: this.config.devUiGate,
41
+ })
67
42
  devServer.register(core)
68
43
  }
69
44
 
70
- // Inject mail service into context
45
+ // 3. Register in container
46
+ core.container.singleton('mail', () => this)
47
+
48
+ // 4. Inject mail service into context for easy access in routes
71
49
  core.adapter.use('*', async (c, next) => {
72
- c.set('mail', {
73
- send: (mailable: Mailable) => this.send(mailable),
74
- queue: (mailable: Mailable) => this.queue(mailable),
75
- })
50
+ c.set('mail', this)
76
51
  await next()
77
52
  return undefined
78
53
  })
@@ -116,38 +91,27 @@ export class OrbitSignal implements GravitoOrbit {
116
91
 
117
92
  // 4. Send via transport
118
93
  if (!this.config.transport) {
119
- throw new Error(
120
- '[OrbitSignal] No transport configured. Did you call configure() or register the orbit?'
121
- )
94
+ throw new Error('[OrbitSignal] No transport configured. Did you call register the orbit?')
122
95
  }
123
96
  await this.config.transport.send(message)
124
97
  }
125
98
 
126
99
  /**
127
100
  * Queue a mailable instance
128
- *
129
- * Push a mailable into the queue for execution.
130
- * Requires OrbitStream to be installed and available in the context.
131
- *
132
- * @param mailable - The mailable object to queue.
133
- * @returns A promise that resolves when the job is pushed to the queue or sent immediately if no queue service is found.
134
101
  */
135
102
  async queue(mailable: Mailable): Promise<void> {
136
- // Try to get queue service from context.
137
- // If not available, send immediately (backward compatible).
138
- const queue = (
139
- this as unknown as { queueService?: { push: (job: unknown) => Promise<unknown> } }
140
- ).queueService
141
-
142
- if (queue) {
143
- // Push via Queue system.
144
- await queue.push(mailable)
145
- } else {
146
- // Fallback: send immediately (backward compatible).
147
- console.warn(
148
- '[OrbitSignal] Queue service not available, sending immediately. Install OrbitStream to enable queuing.'
149
- )
150
- await this.send(mailable)
103
+ try {
104
+ // 嘗試從容器獲取隊列服務 (OrbitStream)
105
+ const queue = this.core?.container.make<any>('queue')
106
+ if (queue) {
107
+ await queue.push(mailable)
108
+ return
109
+ }
110
+ } catch (_e) {
111
+ // 找不到隊列服務時,會拋出錯誤,我們捕捉並降級
151
112
  }
113
+
114
+ // Fallback: 直接發送
115
+ await this.send(mailable)
152
116
  }
153
117
  }
@@ -1,7 +1,7 @@
1
1
  export {}
2
2
 
3
3
  // Module augmentation for GravitoVariables (new abstraction)
4
- declare module 'gravito-core' {
4
+ declare module '@gravito/core' {
5
5
  interface GravitoVariables {
6
6
  /** Mail service for sending emails */
7
7
  mail?: {
@@ -1,104 +1,163 @@
1
- import type { PlanetCore } from 'gravito-core'
1
+ import type { GravitoContext, PlanetCore } from '@gravito/core'
2
2
  import type { DevMailbox } from './DevMailbox'
3
3
  import { getMailboxHtml } from './ui/mailbox'
4
4
  import { getPreviewHtml } from './ui/preview'
5
5
 
6
+ export type DevServerOptions = {
7
+ allowInProduction?: boolean
8
+ gate?: (c: GravitoContext) => boolean | Promise<boolean>
9
+ }
10
+
6
11
  export class DevServer {
7
12
  constructor(
8
13
  private mailbox: DevMailbox,
9
- private base = '/__mail'
14
+ private base = '/__mail',
15
+ private options?: DevServerOptions
10
16
  ) {}
11
17
 
18
+ private async canAccess(ctx: GravitoContext): Promise<boolean> {
19
+ const isProduction = process.env.NODE_ENV === 'production'
20
+ if (isProduction && !this.options?.allowInProduction && !this.options?.gate) {
21
+ return false
22
+ }
23
+ if (this.options?.gate) {
24
+ return await this.options.gate(ctx)
25
+ }
26
+ return true
27
+ }
28
+
12
29
  register(core: PlanetCore): void {
13
30
  const router = core.router
31
+ const isProduction = process.env.NODE_ENV === 'production'
32
+
33
+ if (isProduction && !this.options?.allowInProduction && !this.options?.gate) {
34
+ core.logger.warn(
35
+ '[OrbitSignal] Dev Mailbox disabled in production. Configure a gate to allow access.'
36
+ )
37
+ return
38
+ }
14
39
 
15
40
  // Remove trailing slash
16
41
  const prefix = this.base.replace(/\/$/, '')
17
42
 
43
+ const wrap = (handler: (ctx: GravitoContext) => Response | Promise<Response>) => {
44
+ return async (ctx: GravitoContext) => {
45
+ const allowed = await this.canAccess(ctx)
46
+ if (!allowed) {
47
+ return ctx.text('Unauthorized', 403)
48
+ }
49
+ return await handler(ctx)
50
+ }
51
+ }
52
+
18
53
  // 1. Mailbox List
19
- router.get(prefix, (ctx) => {
20
- const entries = this.mailbox.list()
21
- return ctx.html(getMailboxHtml(entries, prefix))
22
- })
54
+ router.get(
55
+ prefix,
56
+ wrap((ctx) => {
57
+ const entries = this.mailbox.list()
58
+ return ctx.html(getMailboxHtml(entries, prefix))
59
+ })
60
+ )
23
61
 
24
62
  // 2. Single Email Preview
25
- router.get(`${prefix}/:id`, (ctx) => {
26
- const id = ctx.req.param('id')
27
- if (!id) {
28
- return ctx.text('Bad Request', 400)
29
- }
30
-
31
- const entry = this.mailbox.get(id)
32
- if (!entry) {
33
- return ctx.text('Email not found', 404)
34
- }
35
- return ctx.html(getPreviewHtml(entry, prefix))
36
- })
63
+ router.get(
64
+ `${prefix}/:id`,
65
+ wrap((ctx) => {
66
+ const id = ctx.req.param('id')
67
+ if (!id) {
68
+ return ctx.text('Bad Request', 400)
69
+ }
70
+
71
+ const entry = this.mailbox.get(id)
72
+ if (!entry) {
73
+ return ctx.text('Email not found', 404)
74
+ }
75
+ return ctx.html(getPreviewHtml(entry, prefix))
76
+ })
77
+ )
37
78
 
38
79
  // 3. Iframe Content: HTML
39
- router.get(`${prefix}/:id/html`, (ctx) => {
40
- const id = ctx.req.param('id')
41
- if (!id) {
42
- return ctx.text('Bad Request', 400)
43
- }
44
-
45
- const entry = this.mailbox.get(id)
46
- if (!entry) {
47
- return ctx.text('Not found', 404)
48
- }
49
- return ctx.html(entry.html)
50
- })
80
+ router.get(
81
+ `${prefix}/:id/html`,
82
+ wrap((ctx) => {
83
+ const id = ctx.req.param('id')
84
+ if (!id) {
85
+ return ctx.text('Bad Request', 400)
86
+ }
87
+
88
+ const entry = this.mailbox.get(id)
89
+ if (!entry) {
90
+ return ctx.text('Not found', 404)
91
+ }
92
+ return ctx.html(entry.html)
93
+ })
94
+ )
51
95
 
52
96
  // 4. Iframe Content: Text
53
- router.get(`${prefix}/:id/text`, (ctx) => {
54
- const id = ctx.req.param('id')
55
- if (!id) {
56
- return ctx.text('Bad Request', 400)
57
- }
58
-
59
- const entry = this.mailbox.get(id)
60
- if (!entry) {
61
- return ctx.text('Not found', 404)
62
- }
63
-
64
- ctx.header('Content-Type', 'text/plain; charset=utf-8')
65
- return ctx.text(entry.text || 'No text content', 200)
66
- })
97
+ router.get(
98
+ `${prefix}/:id/text`,
99
+ wrap((ctx) => {
100
+ const id = ctx.req.param('id')
101
+ if (!id) {
102
+ return ctx.text('Bad Request', 400)
103
+ }
104
+
105
+ const entry = this.mailbox.get(id)
106
+ if (!entry) {
107
+ return ctx.text('Not found', 404)
108
+ }
109
+
110
+ ctx.header('Content-Type', 'text/plain; charset=utf-8')
111
+ return ctx.text(entry.text || 'No text content', 200)
112
+ })
113
+ )
67
114
 
68
115
  // 5. Raw JSON
69
- router.get(`${prefix}/:id/raw`, (ctx) => {
70
- const id = ctx.req.param('id')
71
- if (!id) {
72
- return ctx.json({ error: 'Bad Request' }, 400)
73
- }
74
-
75
- const entry = this.mailbox.get(id)
76
- if (!entry) {
77
- return ctx.json({ error: 'Not found' }, 404)
78
- }
79
- return ctx.json(entry)
80
- })
116
+ router.get(
117
+ `${prefix}/:id/raw`,
118
+ wrap((ctx) => {
119
+ const id = ctx.req.param('id')
120
+ if (!id) {
121
+ return ctx.json({ error: 'Bad Request' }, 400)
122
+ }
123
+
124
+ const entry = this.mailbox.get(id)
125
+ if (!entry) {
126
+ return ctx.json({ error: 'Not found' }, 404)
127
+ }
128
+ return ctx.json(entry)
129
+ })
130
+ )
81
131
 
82
132
  // 6. API: Delete Single
83
- router.get(`${prefix}/:id/delete`, (ctx) => {
84
- return ctx.text('Method not allowed', 405)
85
- })
86
-
87
- router.delete(`${prefix}/:id`, (ctx) => {
88
- const id = ctx.req.param('id')
89
- if (!id) {
90
- return ctx.json({ success: false, error: 'Bad Request' }, 400)
91
- }
92
-
93
- const success = this.mailbox.delete(id)
94
- return ctx.json({ success })
95
- })
133
+ router.get(
134
+ `${prefix}/:id/delete`,
135
+ wrap((ctx) => {
136
+ return ctx.text('Method not allowed', 405)
137
+ })
138
+ )
139
+
140
+ router.delete(
141
+ `${prefix}/:id`,
142
+ wrap((ctx) => {
143
+ const id = ctx.req.param('id')
144
+ if (!id) {
145
+ return ctx.json({ success: false, error: 'Bad Request' }, 400)
146
+ }
147
+
148
+ const success = this.mailbox.delete(id)
149
+ return ctx.json({ success })
150
+ })
151
+ )
96
152
 
97
153
  // 7. API: Clear All
98
- router.delete(prefix, (ctx) => {
99
- this.mailbox.clear()
100
- return ctx.json({ success: true })
101
- })
154
+ router.delete(
155
+ prefix,
156
+ wrap((ctx) => {
157
+ this.mailbox.clear()
158
+ return ctx.json({ success: true })
159
+ })
160
+ )
102
161
 
103
162
  core.logger.info(`[OrbitSignal] Dev Mailbox available at ${prefix}`)
104
163
  }
@@ -3,13 +3,18 @@ import type { Renderer, RenderResult } from './Renderer'
3
3
  export class ReactRenderer<P extends object = object> implements Renderer {
4
4
  constructor(
5
5
  private component: any, // Use any to avoid hard React dependency in types
6
- private props?: P
6
+ private props?: P,
7
+ private deps: {
8
+ createElement?: (...args: any[]) => any
9
+ renderToStaticMarkup?: (element: any) => string
10
+ } = {}
7
11
  ) {}
8
12
 
9
13
  async render(data: Record<string, unknown>): Promise<RenderResult> {
10
14
  // Dynamic imports to avoid hard dependencies on react/react-dom
11
- const { createElement } = await import('react')
12
- const { renderToStaticMarkup } = await import('react-dom/server')
15
+ const createElement = this.deps.createElement ?? (await import('react')).createElement
16
+ const renderToStaticMarkup =
17
+ this.deps.renderToStaticMarkup ?? (await import('react-dom/server')).renderToStaticMarkup
13
18
 
14
19
  const mergedProps = { ...this.props, ...data } as P
15
20
 
@@ -3,13 +3,20 @@ import type { Renderer, RenderResult } from './Renderer'
3
3
  export class VueRenderer<P extends object = object> implements Renderer {
4
4
  constructor(
5
5
  private component: any, // Use any to avoid hard Vue dependency in types
6
- private props?: P
6
+ private props?: P,
7
+ private deps: {
8
+ createSSRApp?: (...args: any[]) => any
9
+ h?: (...args: any[]) => any
10
+ renderToString?: (app: any) => Promise<string>
11
+ } = {}
7
12
  ) {}
8
13
 
9
14
  async render(data: Record<string, unknown>): Promise<RenderResult> {
10
15
  // Dynamic imports to avoid hard dependencies on vue/@vue/server-renderer
11
- const { createSSRApp, h } = await import('vue')
12
- const { renderToString } = await import('@vue/server-renderer')
16
+ const createSSRApp = this.deps.createSSRApp ?? (await import('vue')).createSSRApp
17
+ const h = this.deps.h ?? (await import('vue')).h
18
+ const renderToString =
19
+ this.deps.renderToString ?? (await import('@vue/server-renderer')).renderToString
13
20
 
14
21
  const mergedProps = { ...this.props, ...data }
15
22
 
package/src/types.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { GravitoContext } from '@gravito/core'
1
2
  import type { Transport } from './transports/Transport'
2
3
  export type { Transport }
3
4
 
@@ -62,6 +63,16 @@ export interface MailConfig {
62
63
  */
63
64
  devUiPrefix?: string | undefined
64
65
 
66
+ /**
67
+ * Allow Dev UI in production. Default: false.
68
+ */
69
+ devUiAllowInProduction?: boolean | undefined
70
+
71
+ /**
72
+ * Gate access to Dev UI (required in production unless allowInProduction is true).
73
+ */
74
+ devUiGate?: ((ctx: GravitoContext) => boolean | Promise<boolean>) | undefined
75
+
65
76
  /**
66
77
  * Translation function for i18n support
67
78
  */
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it, mock } from 'bun:test'
2
+ import { DevMailbox } from '../src/dev/DevMailbox'
3
+ import { DevServer } from '../src/dev/DevServer'
4
+
5
+ const createCore = () => {
6
+ const routes = new Map<string, (ctx: any) => any>()
7
+ const deletes = new Map<string, (ctx: any) => any>()
8
+ const core = {
9
+ router: {
10
+ get: (path: string, handler: (ctx: any) => any) => routes.set(path, handler),
11
+ delete: (path: string, handler: (ctx: any) => any) => deletes.set(path, handler),
12
+ },
13
+ logger: { info: mock(() => {}) },
14
+ routes,
15
+ deletes,
16
+ }
17
+ return core
18
+ }
19
+
20
+ describe('DevServer', () => {
21
+ it('registers mailbox routes and serves entries', async () => {
22
+ const mailbox = new DevMailbox()
23
+ const entry = mailbox.add({
24
+ from: { address: 'from@example.com' },
25
+ to: [{ address: 'to@example.com' }],
26
+ subject: 'Hello',
27
+ html: '<p>Hi</p>',
28
+ priority: 'normal',
29
+ })
30
+
31
+ const core = createCore()
32
+ const server = new DevServer(mailbox, '/__mail/', { allowInProduction: true })
33
+ server.register(core as any)
34
+
35
+ const list = await core.routes.get('/__mail')?.({
36
+ html: (body: string) => body,
37
+ })
38
+ expect(list).toContain('Hello')
39
+
40
+ const preview = await core.routes.get('/__mail/:id')?.({
41
+ req: { param: () => entry.id },
42
+ html: (body: string) => body,
43
+ text: () => '',
44
+ })
45
+ expect(preview).toContain('Email Preview')
46
+
47
+ const raw = await core.routes.get('/__mail/:id/raw')?.({
48
+ req: { param: () => entry.id },
49
+ json: (body: unknown) => body,
50
+ })
51
+ expect((raw as any).id).toBe(entry.id)
52
+
53
+ const deleted = await core.deletes.get('/__mail/:id')?.({
54
+ req: { param: () => entry.id },
55
+ json: (body: unknown) => body,
56
+ })
57
+ expect((deleted as any).success).toBe(true)
58
+
59
+ const cleared = await core.deletes.get('/__mail')?.({
60
+ json: (body: unknown) => body,
61
+ })
62
+ expect((cleared as any).success).toBe(true)
63
+ })
64
+ })
@@ -0,0 +1,21 @@
1
+ import { describe, expect, it, mock } from 'bun:test'
2
+ import { LogTransport } from '../src/transports/LogTransport'
3
+
4
+ describe('LogTransport', () => {
5
+ it('logs formatted output', async () => {
6
+ const originalLog = console.log
7
+ console.log = mock(() => {})
8
+
9
+ const transport = new LogTransport()
10
+ await transport.send({
11
+ from: { address: 'from@example.com' },
12
+ to: [{ address: 'to@example.com' }],
13
+ subject: 'Hello',
14
+ html: '<p>Hi</p>',
15
+ priority: 'normal',
16
+ })
17
+
18
+ expect((console.log as any).mock.calls.length).toBeGreaterThan(0)
19
+ console.log = originalLog
20
+ })
21
+ })