@owlmeans/mailer 0.1.18-rc.8 → 0.1.18

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
@@ -1,33 +1,140 @@
1
1
  # @owlmeans/mailer
2
2
 
3
- Provider-agnostic email dispatch interface for the OwlMeans framework — `MailerService` abstraction and console/dev transport.
4
-
5
- ## Overview
6
-
7
- - `MailerService` — `InitializedService` with a single `send(message)` method; implementations are swapped per environment
8
- - `MailMessage` — `{ to, subject, text?, html? }` shape accepted by all transports
9
- - `MAILER_SERVICE` constant — context alias for registering and resolving the mailer service
10
- - Default transport logs to console (suitable for development and unit tests)
11
- - Production transports provided by `@owlmeans/server-mailer-mailgun` and similar packages
3
+ Provider-agnostic email dispatch: the `MailerService` contract every transport implements, the
4
+ `MailMessage` / `MailAttachment` shapes, `mailAttachmentHelper` for decoding and describing
5
+ attachments, and the one transport of its own — a console transport for development and tests.
6
+ Domain code imports it to type the mailer it resolves under `MAILER_SERVICE` and to build messages;
7
+ tests register the console transport and read what it captured. It does not deliver real mail:
8
+ register `@owlmeans/mailer-smtp` (any SMTP relay) or `@owlmeans/server-mailer-mailgun` (Mailgun's
9
+ HTTP API) under the same alias for that.
12
10
 
13
11
  ## Installation
14
12
 
15
13
  ```bash
16
- bun add @owlmeans/mailer@^0.1.18-rc.7
14
+ bun add @owlmeans/mailer@^0.1.18
17
15
  ```
18
16
 
17
+ ## Concepts
18
+
19
+ - **Transport** — a `MailerService` (an `InitializedService` with `send(message)`), built with
20
+ `createService` from `@owlmeans/context`. Every transport registers under `MAILER_SERVICE`, so
21
+ swapping one changes no caller.
22
+ - **Message** — `MailMessage`: `to`, `subject`, `text?` / `html?`, and per-message overrides
23
+ `from?`, `replyTo?`, `headers?`, plus `attachments?`. Overrides are the smallest common contract —
24
+ a transport that cannot carry `headers` ignores them.
25
+ - **Attachment** — `MailAttachment`: `filename`, `content` as raw bytes or a string in `encoding`
26
+ (`base64`, `hex`, `latin1`/`binary`, UTF-8 by default), `contentType?`.
27
+ - **Console transport** — delivers by logging the envelope and keeps every message in `captured`.
28
+ It never throws, which makes it the right test double.
29
+
19
30
  ## Usage
20
31
 
32
+ ### Send a message
33
+
21
34
  ```typescript
22
35
  import { MAILER_SERVICE } from '@owlmeans/mailer'
23
- import type { MailerService, MailMessage } from '@owlmeans/mailer'
36
+ import type { MailerService } from '@owlmeans/mailer'
24
37
 
25
- // Resolve from context and send
26
38
  const mailer = context.service<MailerService>(MAILER_SERVICE)
27
- await mailer.send({ to: 'user@example.com', subject: 'Welcome', text: 'Hello!' })
39
+
40
+ await mailer.send({
41
+ to: 'user@example.com',
42
+ subject: 'Your report',
43
+ text: 'The monthly report is attached.',
44
+ replyTo: 'support@example.com',
45
+ attachments: [{ filename: 'report.csv', content: csvText, contentType: 'text/csv' }],
46
+ })
28
47
  ```
29
48
 
30
- For production email via Mailgun, register `@owlmeans/server-mailer-mailgun` in your server context.
49
+ ### Use the console transport in development and tests
50
+
51
+ ```typescript
52
+ import { MAILER_SERVICE, makeDefaultConsoleMailerService } from '@owlmeans/mailer'
53
+ import type { MailerService } from '@owlmeans/mailer'
54
+
55
+ const mailer = makeDefaultConsoleMailerService() // registered under MAILER_SERVICE
56
+ context.registerService(mailer)
57
+ await context.configure().init()
58
+
59
+ // ...code under test sends through context.service(MAILER_SERVICE)
60
+ await context.service<MailerService>(MAILER_SERVICE).send({ to: 'user@example.com', subject: 'Code', text: '123456' })
61
+
62
+ expect(mailer.captured[0].text).toBe('123456') // read the body from captured, never from the log
63
+ ```
64
+
65
+ ### Select the transport on config
66
+
67
+ ```typescript
68
+ import { makeConsoleMailerService, MAILER_SERVICE } from '@owlmeans/mailer'
69
+ import { makeMailgunMailerService } from '@owlmeans/server-mailer-mailgun'
70
+
71
+ context.registerService(cfg.mailgun != null
72
+ ? makeMailgunMailerService(MAILER_SERVICE)
73
+ : makeConsoleMailerService(MAILER_SERVICE))
74
+ ```
75
+
76
+ ### Write a transport
77
+
78
+ ```typescript
79
+ import { createService } from '@owlmeans/context'
80
+ import { mailAttachmentHelper } from '@owlmeans/mailer'
81
+ import type { MailerService, MailMessage } from '@owlmeans/mailer'
82
+
83
+ export const makeMyMailerService = (alias = 'my-mailer'): MailerService =>
84
+ createService<MailerService>(alias, {
85
+ send: async (message: MailMessage): Promise<void> => {
86
+ const files = (message.attachments ?? []).map(file => ({
87
+ name: file.filename, bytes: mailAttachmentHelper.bytesOf(file), type: file.contentType,
88
+ }))
89
+ // reach the provider; log only mailAttachmentHelper.describe(message.attachments)
90
+ },
91
+ })
92
+ ```
93
+
94
+ ## API
95
+
96
+ | Symbol | Kind | Purpose |
97
+ |---|---|---|
98
+ | `MailerService` | interface | `InitializedService` + `send(message): Promise<void>` |
99
+ | `ConsoleMailerService` | interface | `MailerService` + `captured: MailMessage[]` |
100
+ | `MailMessage` | interface | `{ to, subject, text?, html?, from?, replyTo?, headers?, attachments? }` |
101
+ | `MailAttachment` | interface | `{ filename, content: Uint8Array \| string, encoding?, contentType? }` |
102
+ | `MailAttachmentSummary` | interface | `{ filename, size, contentType? }` — what a log may carry |
103
+ | `MailAttachmentHelper` | interface | `bytesOf`, `sizeOf`, `describe` |
104
+ | `mailAttachmentHelper` (`createMailAttachmentHelper()`) | helper | `bytesOf(a)` decodes a string `content` by `encoding`; `sizeOf(a)` byte size; `describe(list?)` summaries for logs |
105
+ | `makeConsoleMailerService(alias = CONSOLE_MAILER)` | function | console transport under the given alias |
106
+ | `makeDefaultConsoleMailerService()` | function | `makeConsoleMailerService(MAILER_SERVICE)` |
107
+ | `MAILER_SERVICE` | const | `'mailer-service'` — the alias every caller resolves |
108
+ | `CONSOLE_MAILER` | const | `'console-mailer'` — default alias of the console transport |
109
+
110
+ ## Console transport logging
111
+
112
+ - The envelope (`from` when set, `to`, `subject`, attachment summaries) is logged at `info` under
113
+ scope `mailer`, event `mail.console`.
114
+ - The body is a second record, `debug` `Mail body` (event `mail.console.body`), written only while
115
+ the `mailer` scope logs at debug — a development level or `cfg.log.debug: 'mailer'`.
116
+ - Attachment content never reaches a log; `bytesOf` throws `SyntaxError('mailer:attachment:hex')`
117
+ on malformed hex.
118
+
119
+ ## Common pitfalls
120
+
121
+ - `makeConsoleMailerService()` without an argument registers under `'console-mailer'`, not
122
+ `MAILER_SERVICE`; callers resolving `MAILER_SERVICE` will not find it. Use
123
+ `makeDefaultConsoleMailerService()` or pass the alias.
124
+ - Never register the console transport where real mail must be sent — it only logs.
125
+ - Domain services resolve the mailer by alias; they never import a concrete transport.
126
+ - Supply `text`, `html` or both; neither is required by the type.
127
+ - No transport caps attachment size — limits are the caller's.
128
+
129
+ ## Related packages
130
+
131
+ - [`@owlmeans/mailer-smtp`](../mailer-smtp) — SMTP transport and its `cfg.smtp` block
132
+ - [`@owlmeans/server-mailer-mailgun`](../server-mailer-mailgun) — Mailgun HTTP transport and its `cfg.mailgun` block
133
+ - [`@owlmeans/server-auth-otp`](../server-auth-otp) — sends login codes through `MAILER_SERVICE`
134
+ - [`@owlmeans/context`](../context) — `createService` and the service lifecycle
135
+ - [`@owlmeans/log`](../log) — the logger the console transport writes through
136
+
137
+ The `mailer` skill covers the same contract for agents.
31
138
 
32
139
  <!-- owlmeans:agent-guidance:start -->
33
140
  ## Agent guidance
@@ -37,7 +144,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
37
144
  your project's skill store (`.agents/skills/`):
38
145
 
39
146
  ```sh
40
- npx @owlmeans/agent-skills@^0.1.18-rc.12
147
+ npx @owlmeans/agent-skills@^0.1.18
41
148
  ```
42
149
 
43
150
  The embedded files are version-matched to this package release. Do not edit them
@@ -1,8 +1,8 @@
1
1
  {
2
- "schemaVersion": 2,
2
+ "schemaVersion": 3,
3
3
  "package": "@owlmeans/mailer",
4
- "version": "0.1.18-rc.8",
5
- "generatedAt": "2026-09-04T22:43:25.448Z",
4
+ "version": "0.1.18",
5
+ "generatedAt": "2026-10-28T00:31:34.757Z",
6
6
  "canonicalRepo": "https://github.com/owlmeans/common",
7
7
  "entries": [
8
8
  {
@@ -8,7 +8,7 @@ metadata:
8
8
 
9
9
  # Using `@owlmeans/mailer`
10
10
 
11
- **Install:** `"@owlmeans/mailer": "^0.1.18-rc.8"` in `dependencies`
11
+ **Install:** `"@owlmeans/mailer": "^0.1.18"` in `dependencies`
12
12
 
13
13
  Provider-agnostic email dispatch service. It defines the `MailerService` interface and ships one
14
14
  transport of its own — the console/dev one. Real delivery is a separate package:
@@ -20,7 +20,9 @@ transport of its own — the console/dev one. Real delivery is a separate packag
20
20
  | Symbol | Kind | Purpose |
21
21
  |--------|------|---------|
22
22
  | `MailerService` | interface | Extends `InitializedService` (`@owlmeans/context`) with `send(message): Promise<void>` |
23
- | `MailMessage` | interface | `{ to, subject, text?, html?, from?, replyTo?, headers? }` |
23
+ | `MailMessage` | interface | `{ to, subject, text?, html?, from?, replyTo?, headers?, attachments? }` |
24
+ | `MailAttachment` | interface | `{ filename, content: Uint8Array \| string, encoding?, contentType? }` |
25
+ | `mailAttachmentHelper` | helper | `bytesOf(a)` (decodes a string `content` by `encoding`: `base64`, `hex`, `latin1`/`binary`, default UTF-8), `sizeOf(a)`, `describe(list)` → `{ filename, size, contentType? }[]` for logs |
24
26
  | `MAILER_SERVICE` | const | Default service alias `'mailer-service'` |
25
27
  | `makeConsoleMailerService(alias?)` | fn | Dev transport: logs to console and stores messages in `.captured[]` |
26
28
  | `makeDefaultConsoleMailerService()` | fn | Alias of `makeConsoleMailerService(MAILER_SERVICE)` |
@@ -40,7 +42,8 @@ console.log(mailer.captured[0].text) // '123456'
40
42
  ```
41
43
 
42
44
  The console transport:
43
- - Logs to `console.log` (not `console.error`) — safe to use in tests without noise.
45
+ - Writes each message's envelope through `@owlmeans/log` at `info`, scope `mailer`, event `mail.console`: `from` (when set), `to`, `subject`, attachments — visible at the default level, since printing the mail IS its delivery. The text (a login code, a person's message) is personal content: it is a second record, `debug` `Mail body` (event `mail.console.body`), written only while the `mailer` scope logs at debug — a development level or `cfg.log.debug: 'mailer'`. Silenced by `configureLog({ level: 'warn' })` in a noisy test; a test reads the text from `mailer.captured`, never from the log. Never register it where real mail is sent.
46
+ - Logs attachments as `attachments: [{ filename, size, contentType? }]` — names and sizes only, never their content.
44
47
  - Accumulates all sent messages in `mailer.captured: MailMessage[]`.
45
48
  - Never throws: it has no transport to fail, which is what makes it the right double in a test.
46
49
 
@@ -84,6 +87,11 @@ context.registerService(makeMailgunMailerService(MAILER_SERVICE))
84
87
  - Register under `MAILER_SERVICE` alias when called from `OtpService` or other platform code.
85
88
  - Never import a concrete transport directly in domain services — always inject via the alias.
86
89
  - The `MailMessage.html` field is optional; supply either `text` or `html` (or both).
90
+ - `attachments` carry files: raw bytes, or a string in `encoding`. Every transport decodes them
91
+ through `mailAttachmentHelper.bytesOf`, so a file reaches the provider byte-exact whatever form it
92
+ was given in; `contentType` is sent as declared (a transport may fall back to
93
+ `application/octet-stream`). Content never reaches a log — `mailAttachmentHelper.describe` is what
94
+ a log line may carry. Size limits are the caller's: no transport caps them.
87
95
  - `from`, `replyTo` and `headers` are per-message overrides of whatever the transport was
88
96
  configured with. A transport that cannot carry headers ignores the field rather than failing —
89
97
  the contract is the smallest thing every transport can honour, so nothing here is guaranteed to
@@ -0,0 +1,4 @@
1
+ import type { MailAttachmentHelper } from './types.js';
2
+ export declare const createMailAttachmentHelper: () => MailAttachmentHelper;
3
+ export declare const mailAttachmentHelper: MailAttachmentHelper;
4
+ //# sourceMappingURL=attachment.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment.d.ts","sourceRoot":"","sources":["../src/attachment.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAkB,oBAAoB,EAAyB,MAAM,YAAY,CAAA;AAE7F,eAAO,MAAM,0BAA0B,QAAO,oBAmD7C,CAAA;AAED,eAAO,MAAM,oBAAoB,sBAA+B,CAAA"}
@@ -0,0 +1,47 @@
1
+ export const createMailAttachmentHelper = () => {
2
+ const fromBase64 = (text) => {
3
+ const binary = atob(text.replace(/\s+/g, ''));
4
+ const bytes = new Uint8Array(binary.length);
5
+ for (let i = 0; i < binary.length; i++)
6
+ bytes[i] = binary.charCodeAt(i);
7
+ return bytes;
8
+ };
9
+ const fromHex = (text) => {
10
+ const clean = text.replace(/\s+/g, '');
11
+ if (clean.length % 2 !== 0 || /[^0-9a-f]/i.test(clean)) {
12
+ throw new SyntaxError('mailer:attachment:hex');
13
+ }
14
+ const bytes = new Uint8Array(clean.length / 2);
15
+ for (let i = 0; i < bytes.length; i++)
16
+ bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
17
+ return bytes;
18
+ };
19
+ const fromLatin1 = (text) => {
20
+ const bytes = new Uint8Array(text.length);
21
+ for (let i = 0; i < text.length; i++)
22
+ bytes[i] = text.charCodeAt(i) & 0xff;
23
+ return bytes;
24
+ };
25
+ const bytesOf = (attachment) => {
26
+ const { content } = attachment;
27
+ if (typeof content !== 'string') {
28
+ return content;
29
+ }
30
+ switch (attachment.encoding?.toLowerCase()) {
31
+ case 'base64': return fromBase64(content);
32
+ case 'hex': return fromHex(content);
33
+ case 'latin1':
34
+ case 'binary': return fromLatin1(content);
35
+ default: return new TextEncoder().encode(content);
36
+ }
37
+ };
38
+ const sizeOf = (attachment) => bytesOf(attachment).byteLength;
39
+ const describe = (attachments) => (attachments ?? []).map(attachment => ({
40
+ filename: attachment.filename,
41
+ size: sizeOf(attachment),
42
+ ...(attachment.contentType != null ? { contentType: attachment.contentType } : {}),
43
+ }));
44
+ return { bytesOf, sizeOf, describe };
45
+ };
46
+ export const mailAttachmentHelper = createMailAttachmentHelper();
47
+ //# sourceMappingURL=attachment.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachment.js","sourceRoot":"","sources":["../src/attachment.ts"],"names":[],"mappings":"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"}
package/build/consts.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export declare const MAILER_SERVICE = "mailer-service";
2
+ export declare const CONSOLE_MAILER = "console-mailer";
2
3
  //# sourceMappingURL=consts.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,mBAAmB,CAAA"}
1
+ {"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,cAAc,mBAAmB,CAAA;AAE9C,eAAO,MAAM,cAAc,mBAAmB,CAAA"}
package/build/consts.js CHANGED
@@ -1,2 +1,3 @@
1
1
  export const MAILER_SERVICE = 'mailer-service';
2
+ export const CONSOLE_MAILER = 'console-mailer';
2
3
  //# sourceMappingURL=consts.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,cAAc,GAAG,gBAAgB,CAAA"}
1
+ {"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,cAAc,GAAG,gBAAgB,CAAA;AAE9C,MAAM,CAAC,MAAM,cAAc,GAAG,gBAAgB,CAAA"}
package/build/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export type * from './types.js';
2
2
  export * from './consts.js';
3
+ export * from './attachment.js';
3
4
  export * from './service.js';
4
5
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,mBAAmB,YAAY,CAAA;AAC/B,cAAc,aAAa,CAAA;AAC3B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,cAAc,CAAA"}
package/build/index.js CHANGED
@@ -1,3 +1,4 @@
1
1
  export * from './consts.js';
2
+ export * from './attachment.js';
2
3
  export * from './service.js';
3
4
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAC3B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,cAAc,CAAA"}
@@ -1,12 +1,8 @@
1
- import type { MailerService, MailMessage } from './types.js';
2
- export declare const CONSOLE_MAILER = "console-mailer";
1
+ import type { ConsoleMailerService } from './types.js';
3
2
  /** Dev/test transport: logs the message to console and stores it for inspection. */
4
- export declare const makeConsoleMailerService: (alias?: string) => MailerService & {
5
- /** Messages captured since the service was created (use in tests). */
6
- captured: MailMessage[];
7
- };
3
+ export declare const makeConsoleMailerService: (alias?: string) => ConsoleMailerService;
8
4
  /** Alias of makeConsoleMailerService registered under the default MAILER_SERVICE alias.
9
5
  * Useful as a drop-in replacement during development and integration tests.
10
6
  */
11
- export declare const makeDefaultConsoleMailerService: () => ReturnType<typeof makeConsoleMailerService>;
7
+ export declare const makeDefaultConsoleMailerService: () => ConsoleMailerService;
12
8
  //# sourceMappingURL=service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,WAAW,EAAE,MAAM,YAAY,CAAA;AAG5D,eAAO,MAAM,cAAc,mBAAmB,CAAA;AAE9C,oFAAoF;AACpF,eAAO,MAAM,wBAAwB,sBAA6B,aAAa,GAAG;IAChF,sEAAsE;IACtE,QAAQ,EAAE,WAAW,EAAE,CAAA;CAexB,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,+BAA+B,QAAO,UAAU,CAAC,OAAO,wBAAwB,CACnD,CAAA"}
1
+ {"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAA8B,MAAM,YAAY,CAAA;AAOlF,oFAAoF;AACpF,eAAO,MAAM,wBAAwB,sBAA6B,oBA2BjE,CAAA;AAED;;GAEG;AACH,eAAO,MAAM,+BAA+B,QAAO,oBACT,CAAA"}
package/build/service.js CHANGED
@@ -1,15 +1,30 @@
1
1
  import { createService } from '@owlmeans/context';
2
- import { MAILER_SERVICE } from './consts.js';
3
- export const CONSOLE_MAILER = 'console-mailer';
2
+ import { MAILER_SERVICE, CONSOLE_MAILER } from './consts.js';
3
+ import { mailAttachmentHelper } from './attachment.js';
4
+ import { logger } from '@owlmeans/log';
5
+ const log = logger('mailer');
4
6
  /** Dev/test transport: logs the message to console and stores it for inspection. */
5
7
  export const makeConsoleMailerService = (alias = CONSOLE_MAILER) => {
6
8
  const captured = [];
7
9
  const service = createService(alias, {
8
10
  send: async (message) => {
9
11
  captured.push(message);
10
- const from = message.from != null ? ` from=${message.from}` : '';
11
- console.log(`[console-mailer]${from} to=${message.to} subject="${message.subject}"`);
12
- console.log(`[console-mailer] body=${message.text ?? message.html ?? ''}`);
12
+ // The explicit dev transport: printing the mail IS its delivery, so its envelope — sender,
13
+ // recipient, subject, attachment names and sizes — is written at `info` under the scope
14
+ // `mailer`. The text (a login code, a person's message) is personal content: it is written
15
+ // only while `mailer` logs at debug (a development level, or `cfg.log.debug: 'mailer'`).
16
+ // Attachment content never reaches a log. Never register it where real mail is sent.
17
+ const attachments = mailAttachmentHelper.describe(message.attachments);
18
+ const envelope = {
19
+ ...(message.from != null ? { from: message.from } : {}),
20
+ to: message.to, subject: message.subject,
21
+ };
22
+ log.info('Mail written to the console', {
23
+ ...envelope, ...(attachments.length > 0 ? { attachments } : {}),
24
+ }, { event: 'mail.console' });
25
+ if (log.enabled('debug')) {
26
+ log.debug('Mail body', { ...envelope, body: message.text ?? message.html ?? '' }, { event: 'mail.console.body' });
27
+ }
13
28
  },
14
29
  });
15
30
  service.captured = captured;
@@ -1 +1 @@
1
- {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEjD,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAE5C,MAAM,CAAC,MAAM,cAAc,GAAG,gBAAgB,CAAA;AAE9C,oFAAoF;AACpF,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,KAAK,GAAG,cAAc,EAG7D,EAAE;IACF,MAAM,QAAQ,GAAkB,EAAE,CAAA;IAElC,MAAM,OAAO,GAAG,aAAa,CAAgB,KAAK,EAAE;QAClD,IAAI,EAAE,KAAK,EAAE,OAAoB,EAAiB,EAAE;YAClD,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;YACtB,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,SAAS,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAA;YAChE,OAAO,CAAC,GAAG,CAAC,mBAAmB,IAAI,OAAO,OAAO,CAAC,EAAE,aAAa,OAAO,CAAC,OAAO,GAAG,CAAC,CAAA;YACpF,OAAO,CAAC,GAAG,CAAC,yBAAyB,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,IAAI,IAAI,EAAE,EAAE,CAAC,CAAA;QAC5E,CAAC;KACF,CAAgD,CAAA;IAEjD,OAAO,CAAC,QAAQ,GAAG,QAAQ,CAAA;IAC3B,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA;AAED;;GAEG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,GAAgD,EAAE,CAC/F,wBAAwB,CAAC,cAAc,CAAC,CAAA"}
1
+ {"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEjD,OAAO,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,aAAa,CAAA;AAC5D,OAAO,EAAE,oBAAoB,EAAE,MAAM,iBAAiB,CAAA;AACtD,OAAO,EAAE,MAAM,EAAE,MAAM,eAAe,CAAA;AAEtC,MAAM,GAAG,GAAG,MAAM,CAAC,QAAQ,CAAC,CAAA;AAE5B,oFAAoF;AACpF,MAAM,CAAC,MAAM,wBAAwB,GAAG,CAAC,KAAK,GAAG,cAAc,EAAwB,EAAE;IACvF,MAAM,QAAQ,GAAkB,EAAE,CAAA;IAElC,MAAM,OAAO,GAAG,aAAa,CAAgB,KAAK,EAAE;QAClD,IAAI,EAAE,KAAK,EAAE,OAAoB,EAAiB,EAAE;YAClD,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;YACtB,2FAA2F;YAC3F,wFAAwF;YACxF,2FAA2F;YAC3F,yFAAyF;YACzF,qFAAqF;YACrF,MAAM,WAAW,GAAG,oBAAoB,CAAC,QAAQ,CAAC,OAAO,CAAC,WAAW,CAAC,CAAA;YACtE,MAAM,QAAQ,GAAG;gBACf,GAAG,CAAC,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;gBACvD,EAAE,EAAE,OAAO,CAAC,EAAE,EAAE,OAAO,EAAE,OAAO,CAAC,OAAO;aACzC,CAAA;YACD,GAAG,CAAC,IAAI,CAAC,6BAA6B,EAAE;gBACtC,GAAG,QAAQ,EAAE,GAAG,CAAC,WAAW,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;aAChE,EAAE,EAAE,KAAK,EAAE,cAAc,EAAE,CAAC,CAAA;YAC7B,IAAI,GAAG,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;gBACzB,GAAG,CAAC,KAAK,CAAC,WAAW,EAAE,EAAE,GAAG,QAAQ,EAAE,IAAI,EAAE,OAAO,CAAC,IAAI,IAAI,OAAO,CAAC,IAAI,IAAI,EAAE,EAAE,EAAE,EAAE,KAAK,EAAE,mBAAmB,EAAE,CAAC,CAAA;YACnH,CAAC;QACH,CAAC;KACF,CAAyB,CAAA;IAE1B,OAAO,CAAC,QAAQ,GAAG,QAAQ,CAAA;IAC3B,OAAO,OAAO,CAAA;AAChB,CAAC,CAAA;AAED;;GAEG;AACH,MAAM,CAAC,MAAM,+BAA+B,GAAG,GAAyB,EAAE,CACxE,wBAAwB,CAAC,cAAc,CAAC,CAAA"}
package/build/types.d.ts CHANGED
@@ -1,4 +1,18 @@
1
1
  import type { InitializedService } from '@owlmeans/context';
2
+ /** A file carried by a message. */
3
+ export interface MailAttachment {
4
+ /** The name the recipient sees; the transport never derives a path from it. */
5
+ filename: string;
6
+ /**
7
+ * The file itself: raw bytes, or text in `encoding` (UTF-8 when `encoding` is absent). A
8
+ * transport may hand the bytes to its provider, never to a log.
9
+ */
10
+ content: Uint8Array | string;
11
+ /** How a string `content` is encoded — `base64`, `hex`, `latin1`/`binary` or `utf8` (default). */
12
+ encoding?: string;
13
+ /** The MIME type the file is sent with; absent = the transport's own guess from the name. */
14
+ contentType?: string;
15
+ }
2
16
  export interface MailMessage {
3
17
  to: string;
4
18
  subject: string;
@@ -9,9 +23,31 @@ export interface MailMessage {
9
23
  replyTo?: string;
10
24
  /** Extra headers. A transport that cannot carry them ignores the field. */
11
25
  headers?: Record<string, string>;
26
+ /** Files sent with the message, in order. */
27
+ attachments?: MailAttachment[];
12
28
  }
13
29
  /** Provider-agnostic email dispatch service */
14
30
  export interface MailerService extends InitializedService {
15
31
  send: (message: MailMessage) => Promise<void>;
16
32
  }
33
+ /** The dev/test transport: delivers by logging, and keeps what it sent for inspection. */
34
+ export interface ConsoleMailerService extends MailerService {
35
+ /** Messages captured since the service was created (use in tests). */
36
+ captured: MailMessage[];
37
+ }
38
+ /** What every transport needs to know about an attachment without reading it into a log. */
39
+ export interface MailAttachmentHelper {
40
+ /** The attachment's bytes, decoded from a string `content` by its `encoding`. */
41
+ bytesOf: (attachment: MailAttachment) => Uint8Array;
42
+ /** The attachment's size in bytes. */
43
+ sizeOf: (attachment: MailAttachment) => number;
44
+ /** What a log may say about the attachments: their names and sizes, never their content. */
45
+ describe: (attachments?: MailAttachment[]) => MailAttachmentSummary[];
46
+ }
47
+ /** One attachment as a log line carries it. */
48
+ export interface MailAttachmentSummary {
49
+ filename: string;
50
+ size: number;
51
+ contentType?: string;
52
+ }
17
53
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAE3D,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACjC;AAED,+CAA+C;AAC/C,MAAM,WAAW,aAAc,SAAQ,kBAAkB;IACvD,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9C"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,mBAAmB,CAAA;AAE3D,mCAAmC;AACnC,MAAM,WAAW,cAAc;IAC7B,+EAA+E;IAC/E,QAAQ,EAAE,MAAM,CAAA;IAChB;;;OAGG;IACH,OAAO,EAAE,UAAU,GAAG,MAAM,CAAA;IAC5B,kGAAkG;IAClG,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6FAA6F;IAC7F,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,WAAW;IAC1B,EAAE,EAAE,MAAM,CAAA;IACV,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,6CAA6C;IAC7C,WAAW,CAAC,EAAE,cAAc,EAAE,CAAA;CAC/B;AAED,+CAA+C;AAC/C,MAAM,WAAW,aAAc,SAAQ,kBAAkB;IACvD,IAAI,EAAE,CAAC,OAAO,EAAE,WAAW,KAAK,OAAO,CAAC,IAAI,CAAC,CAAA;CAC9C;AAED,0FAA0F;AAC1F,MAAM,WAAW,oBAAqB,SAAQ,aAAa;IACzD,sEAAsE;IACtE,QAAQ,EAAE,WAAW,EAAE,CAAA;CACxB;AAED,4FAA4F;AAC5F,MAAM,WAAW,oBAAoB;IACnC,iFAAiF;IACjF,OAAO,EAAE,CAAC,UAAU,EAAE,cAAc,KAAK,UAAU,CAAA;IACnD,sCAAsC;IACtC,MAAM,EAAE,CAAC,UAAU,EAAE,cAAc,KAAK,MAAM,CAAA;IAC9C,4FAA4F;IAC5F,QAAQ,EAAE,CAAC,WAAW,CAAC,EAAE,cAAc,EAAE,KAAK,qBAAqB,EAAE,CAAA;CACtE;AAED,+CAA+C;AAC/C,MAAM,WAAW,qBAAqB;IACpC,QAAQ,EAAE,MAAM,CAAA;IAChB,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@owlmeans/mailer",
3
- "version": "0.1.18-rc.8",
3
+ "version": "0.1.18",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -22,12 +22,13 @@
22
22
  }
23
23
  },
24
24
  "dependencies": {
25
- "@owlmeans/context": "^0.1.18-rc.8",
26
- "@owlmeans/error": "^0.1.18-rc.8"
25
+ "@owlmeans/context": "^0.1.18",
26
+ "@owlmeans/error": "^0.1.18",
27
+ "@owlmeans/log": "^0.1.18"
27
28
  },
28
29
  "devDependencies": {
29
- "@owlmeans/dep-config": "workspace:*",
30
- "@types/bun": "^1.4.0",
30
+ "@owlmeans/dep-config": "^0.1.18",
31
+ "@types/bun": "^1.4.2",
31
32
  "nodemon": "^3.1.14",
32
33
  "typescript": "^7.0.2"
33
34
  },
@@ -0,0 +1,56 @@
1
+ import type { MailAttachment, MailAttachmentHelper, MailAttachmentSummary } from './types.js'
2
+
3
+ export const createMailAttachmentHelper = (): MailAttachmentHelper => {
4
+ const fromBase64 = (text: string): Uint8Array => {
5
+ const binary = atob(text.replace(/\s+/g, ''))
6
+ const bytes = new Uint8Array(binary.length)
7
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
8
+
9
+ return bytes
10
+ }
11
+
12
+ const fromHex = (text: string): Uint8Array => {
13
+ const clean = text.replace(/\s+/g, '')
14
+ if (clean.length % 2 !== 0 || /[^0-9a-f]/i.test(clean)) {
15
+ throw new SyntaxError('mailer:attachment:hex')
16
+ }
17
+ const bytes = new Uint8Array(clean.length / 2)
18
+ for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16)
19
+
20
+ return bytes
21
+ }
22
+
23
+ const fromLatin1 = (text: string): Uint8Array => {
24
+ const bytes = new Uint8Array(text.length)
25
+ for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff
26
+
27
+ return bytes
28
+ }
29
+
30
+ const bytesOf = (attachment: MailAttachment): Uint8Array => {
31
+ const { content } = attachment
32
+ if (typeof content !== 'string') {
33
+ return content
34
+ }
35
+ switch (attachment.encoding?.toLowerCase()) {
36
+ case 'base64': return fromBase64(content)
37
+ case 'hex': return fromHex(content)
38
+ case 'latin1':
39
+ case 'binary': return fromLatin1(content)
40
+ default: return new TextEncoder().encode(content)
41
+ }
42
+ }
43
+
44
+ const sizeOf = (attachment: MailAttachment): number => bytesOf(attachment).byteLength
45
+
46
+ const describe = (attachments?: MailAttachment[]): MailAttachmentSummary[] =>
47
+ (attachments ?? []).map(attachment => ({
48
+ filename: attachment.filename,
49
+ size: sizeOf(attachment),
50
+ ...(attachment.contentType != null ? { contentType: attachment.contentType } : {}),
51
+ }))
52
+
53
+ return { bytesOf, sizeOf, describe }
54
+ }
55
+
56
+ export const mailAttachmentHelper = createMailAttachmentHelper()
package/src/consts.ts CHANGED
@@ -1 +1,3 @@
1
1
  export const MAILER_SERVICE = 'mailer-service'
2
+
3
+ export const CONSOLE_MAILER = 'console-mailer'
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export type * from './types.js'
2
2
  export * from './consts.js'
3
+ export * from './attachment.js'
3
4
  export * from './service.js'
package/src/service.ts CHANGED
@@ -1,24 +1,36 @@
1
1
  import { createService } from '@owlmeans/context'
2
- import type { MailerService, MailMessage } from './types.js'
3
- import { MAILER_SERVICE } from './consts.js'
2
+ import type { ConsoleMailerService, MailerService, MailMessage } from './types.js'
3
+ import { MAILER_SERVICE, CONSOLE_MAILER } from './consts.js'
4
+ import { mailAttachmentHelper } from './attachment.js'
5
+ import { logger } from '@owlmeans/log'
4
6
 
5
- export const CONSOLE_MAILER = 'console-mailer'
7
+ const log = logger('mailer')
6
8
 
7
9
  /** Dev/test transport: logs the message to console and stores it for inspection. */
8
- export const makeConsoleMailerService = (alias = CONSOLE_MAILER): MailerService & {
9
- /** Messages captured since the service was created (use in tests). */
10
- captured: MailMessage[]
11
- } => {
10
+ export const makeConsoleMailerService = (alias = CONSOLE_MAILER): ConsoleMailerService => {
12
11
  const captured: MailMessage[] = []
13
12
 
14
13
  const service = createService<MailerService>(alias, {
15
14
  send: async (message: MailMessage): Promise<void> => {
16
15
  captured.push(message)
17
- const from = message.from != null ? ` from=${message.from}` : ''
18
- console.log(`[console-mailer]${from} to=${message.to} subject="${message.subject}"`)
19
- console.log(`[console-mailer] body=${message.text ?? message.html ?? ''}`)
16
+ // The explicit dev transport: printing the mail IS its delivery, so its envelope — sender,
17
+ // recipient, subject, attachment names and sizes — is written at `info` under the scope
18
+ // `mailer`. The text (a login code, a person's message) is personal content: it is written
19
+ // only while `mailer` logs at debug (a development level, or `cfg.log.debug: 'mailer'`).
20
+ // Attachment content never reaches a log. Never register it where real mail is sent.
21
+ const attachments = mailAttachmentHelper.describe(message.attachments)
22
+ const envelope = {
23
+ ...(message.from != null ? { from: message.from } : {}),
24
+ to: message.to, subject: message.subject,
25
+ }
26
+ log.info('Mail written to the console', {
27
+ ...envelope, ...(attachments.length > 0 ? { attachments } : {}),
28
+ }, { event: 'mail.console' })
29
+ if (log.enabled('debug')) {
30
+ log.debug('Mail body', { ...envelope, body: message.text ?? message.html ?? '' }, { event: 'mail.console.body' })
31
+ }
20
32
  },
21
- }) as MailerService & { captured: MailMessage[] }
33
+ }) as ConsoleMailerService
22
34
 
23
35
  service.captured = captured
24
36
  return service
@@ -27,5 +39,5 @@ export const makeConsoleMailerService = (alias = CONSOLE_MAILER): MailerService
27
39
  /** Alias of makeConsoleMailerService registered under the default MAILER_SERVICE alias.
28
40
  * Useful as a drop-in replacement during development and integration tests.
29
41
  */
30
- export const makeDefaultConsoleMailerService = (): ReturnType<typeof makeConsoleMailerService> =>
42
+ export const makeDefaultConsoleMailerService = (): ConsoleMailerService =>
31
43
  makeConsoleMailerService(MAILER_SERVICE)
package/src/types.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  import type { InitializedService } from '@owlmeans/context'
2
2
 
3
+ /** A file carried by a message. */
4
+ export interface MailAttachment {
5
+ /** The name the recipient sees; the transport never derives a path from it. */
6
+ filename: string
7
+ /**
8
+ * The file itself: raw bytes, or text in `encoding` (UTF-8 when `encoding` is absent). A
9
+ * transport may hand the bytes to its provider, never to a log.
10
+ */
11
+ content: Uint8Array | string
12
+ /** How a string `content` is encoded — `base64`, `hex`, `latin1`/`binary` or `utf8` (default). */
13
+ encoding?: string
14
+ /** The MIME type the file is sent with; absent = the transport's own guess from the name. */
15
+ contentType?: string
16
+ }
17
+
3
18
  export interface MailMessage {
4
19
  to: string
5
20
  subject: string
@@ -10,9 +25,34 @@ export interface MailMessage {
10
25
  replyTo?: string
11
26
  /** Extra headers. A transport that cannot carry them ignores the field. */
12
27
  headers?: Record<string, string>
28
+ /** Files sent with the message, in order. */
29
+ attachments?: MailAttachment[]
13
30
  }
14
31
 
15
32
  /** Provider-agnostic email dispatch service */
16
33
  export interface MailerService extends InitializedService {
17
34
  send: (message: MailMessage) => Promise<void>
18
35
  }
36
+
37
+ /** The dev/test transport: delivers by logging, and keeps what it sent for inspection. */
38
+ export interface ConsoleMailerService extends MailerService {
39
+ /** Messages captured since the service was created (use in tests). */
40
+ captured: MailMessage[]
41
+ }
42
+
43
+ /** What every transport needs to know about an attachment without reading it into a log. */
44
+ export interface MailAttachmentHelper {
45
+ /** The attachment's bytes, decoded from a string `content` by its `encoding`. */
46
+ bytesOf: (attachment: MailAttachment) => Uint8Array
47
+ /** The attachment's size in bytes. */
48
+ sizeOf: (attachment: MailAttachment) => number
49
+ /** What a log may say about the attachments: their names and sizes, never their content. */
50
+ describe: (attachments?: MailAttachment[]) => MailAttachmentSummary[]
51
+ }
52
+
53
+ /** One attachment as a log line carries it. */
54
+ export interface MailAttachmentSummary {
55
+ filename: string
56
+ size: number
57
+ contentType?: string
58
+ }
@@ -0,0 +1,126 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { AppType, makeBasicContext } from '@owlmeans/context'
3
+ import type { BasicConfig } from '@owlmeans/context'
4
+ import { addLogPlugin, configureLog, logConfig, memoryPlugin } from '@owlmeans/log'
5
+ import {
6
+ CONSOLE_MAILER, MAILER_SERVICE, mailAttachmentHelper, makeConsoleMailerService, makeDefaultConsoleMailerService,
7
+ } from '../src/index.js'
8
+ import type { MailerService } from '../src/index.js'
9
+
10
+ /** Registered and initialized the way an application does it, then looked up by alias. */
11
+ const booted = async (service: MailerService) => {
12
+ const ctx = makeBasicContext<BasicConfig>({ ready: false, service: 'mailer-tests', type: AppType.Backend })
13
+ ctx.registerService(service)
14
+ ctx.configure()
15
+ await ctx.init()
16
+
17
+ return ctx
18
+ }
19
+
20
+ describe('@owlmeans/mailer — console transport', () => {
21
+ test('is registered under its own alias unless told otherwise', () => {
22
+ expect(makeConsoleMailerService().alias).toBe(CONSOLE_MAILER)
23
+ expect(makeConsoleMailerService('outbox').alias).toBe('outbox')
24
+ })
25
+
26
+ test('the default variant answers the alias every consumer looks the mailer up by', async () => {
27
+ const mailer = makeDefaultConsoleMailerService()
28
+ const ctx = await booted(mailer)
29
+
30
+ expect(ctx.service<MailerService>(MAILER_SERVICE)).toBe(mailer)
31
+ })
32
+
33
+ test('captures every message as sent, in order, without touching it', async () => {
34
+ const mailer = makeConsoleMailerService()
35
+ await booted(mailer)
36
+ const first = { to: 'a@example.com', subject: 'Code', text: '123456' }
37
+ const second = {
38
+ to: 'b@example.com', subject: 'Report', html: '<b>done</b>', from: 'Ops <ops@example.com>',
39
+ replyTo: 'help@example.com', headers: { 'X-Run': '7' },
40
+ }
41
+
42
+ await mailer.send(first)
43
+ await mailer.send(second)
44
+
45
+ expect(mailer.captured).toEqual([first, second])
46
+ })
47
+
48
+ test('writes the envelope at info and the text only at debug', async () => {
49
+ const memory = memoryPlugin('mailer-body')
50
+ const remove = addLogPlugin(memory)
51
+ const level = logConfig().level
52
+ try {
53
+ const mailer = makeConsoleMailerService()
54
+ const message = { to: 'a@example.com', subject: 'Code', text: 'your code is 482913' }
55
+
56
+ configureLog({ level: 'info' })
57
+ await mailer.send(message)
58
+ expect(memory.records.map(r => [r.level, r.message, r.data])).toEqual([
59
+ ['info', 'Mail written to the console', { to: 'a@example.com', subject: 'Code' }],
60
+ ])
61
+ expect(JSON.stringify(memory.records)).not.toContain('482913')
62
+
63
+ memory.clear()
64
+ configureLog({ level: 'debug' })
65
+ await mailer.send(message)
66
+ expect(memory.records.map(r => [r.level, r.message])).toEqual([
67
+ ['info', 'Mail written to the console'], ['debug', 'Mail body'],
68
+ ])
69
+ expect((memory.records[1].data as { body: string }).body).toBe('your code is 482913')
70
+ } finally {
71
+ configureLog({ level })
72
+ remove()
73
+ }
74
+ })
75
+
76
+ test('each instance keeps its own outbox', async () => {
77
+ const one = makeConsoleMailerService('one')
78
+ const two = makeConsoleMailerService('two')
79
+ await one.send({ to: 'a@example.com', subject: 'only one' })
80
+
81
+ expect(one.captured).toHaveLength(1)
82
+ expect(two.captured).toHaveLength(0)
83
+ })
84
+ })
85
+
86
+ describe('@owlmeans/mailer — attachments', () => {
87
+ const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
88
+
89
+ test('the console transport names and sizes attachments, never logs their content', async () => {
90
+ const memory = memoryPlugin('mailer-attachments')
91
+ const remove = addLogPlugin(memory)
92
+ try {
93
+ const mailer = makeConsoleMailerService()
94
+ await mailer.send({
95
+ to: 'a@example.com', subject: 'Report', text: 'see attached',
96
+ attachments: [
97
+ { filename: 'shot.png', content: png, contentType: 'image/png' },
98
+ { filename: 'notes.txt', content: 'c2VjcmV0LWNvbnRlbnQ=', encoding: 'base64' },
99
+ ],
100
+ })
101
+
102
+ const record = memory.records.find(r => r.message === 'Mail written to the console')
103
+ expect(record).toBeDefined()
104
+ expect((record!.data as { attachments: unknown }).attachments).toEqual([
105
+ { filename: 'shot.png', size: 8, contentType: 'image/png' },
106
+ { filename: 'notes.txt', size: 14 },
107
+ ])
108
+ expect(JSON.stringify(record!.data)).not.toContain('c2VjcmV0LWNvbnRlbnQ=')
109
+ expect(JSON.stringify(record!.data)).not.toContain('secret-content')
110
+ expect(mailer.captured[0].attachments).toHaveLength(2)
111
+ } finally {
112
+ remove()
113
+ }
114
+ })
115
+
116
+ test('decodes string content by its encoding', () => {
117
+ const text = (bytes: Uint8Array) => new TextDecoder().decode(bytes)
118
+
119
+ expect(mailAttachmentHelper.bytesOf({ filename: 'a', content: png })).toBe(png)
120
+ expect(text(mailAttachmentHelper.bytesOf({ filename: 'a', content: 'aGVsbG8=', encoding: 'base64' }))).toBe('hello')
121
+ expect(text(mailAttachmentHelper.bytesOf({ filename: 'a', content: '68656c6c6f', encoding: 'hex' }))).toBe('hello')
122
+ expect(mailAttachmentHelper.sizeOf({ filename: 'a', content: 'żółw' })).toBe(7)
123
+ expect(mailAttachmentHelper.sizeOf({ filename: 'a', content: 'abc', encoding: 'latin1' })).toBe(3)
124
+ expect(() => mailAttachmentHelper.bytesOf({ filename: 'a', content: 'abc', encoding: 'hex' })).toThrow()
125
+ })
126
+ })