@owlmeans/mailer 0.1.18-rc.9 → 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 +122 -15
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/mailer/SKILL.md +11 -3
- package/build/attachment.d.ts +4 -0
- package/build/attachment.d.ts.map +1 -0
- package/build/attachment.js +47 -0
- package/build/attachment.js.map +1 -0
- package/build/consts.d.ts +1 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +1 -0
- package/build/consts.js.map +1 -1
- package/build/index.d.ts +1 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/service.d.ts +3 -7
- package/build/service.d.ts.map +1 -1
- package/build/service.js +20 -5
- package/build/service.js.map +1 -1
- package/build/types.d.ts +36 -0
- package/build/types.d.ts.map +1 -1
- package/package.json +6 -5
- package/src/attachment.ts +56 -0
- package/src/consts.ts +2 -0
- package/src/index.ts +1 -0
- package/src/service.ts +24 -12
- package/src/types.ts +40 -0
- package/tests/console.spec.ts +126 -0
package/README.md
CHANGED
|
@@ -1,33 +1,140 @@
|
|
|
1
1
|
# @owlmeans/mailer
|
|
2
2
|
|
|
3
|
-
Provider-agnostic email dispatch
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"schemaVersion":
|
|
2
|
+
"schemaVersion": 3,
|
|
3
3
|
"package": "@owlmeans/mailer",
|
|
4
|
-
"version": "0.1.18
|
|
5
|
-
"generatedAt": "2026-
|
|
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
|
|
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
|
-
-
|
|
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 @@
|
|
|
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":"AAEA,MAAM,CAAC,MAAM,0BAA0B,GAAG,GAAyB,EAAE;IACnE,MAAM,UAAU,GAAG,CAAC,IAAY,EAAc,EAAE;QAC9C,MAAM,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,CAAA;QAC7C,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;QAC3C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC,EAAE;YAAE,KAAK,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,UAAU,CAAC,CAAC,CAAC,CAAA;QAEvE,OAAO,KAAK,CAAA;IACd,CAAC,CAAA;IAED,MAAM,OAAO,GAAG,CAAC,IAAY,EAAc,EAAE;QAC3C,MAAM,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAA;QACtC,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,KAAK,CAAC,IAAI,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;YACvD,MAAM,IAAI,WAAW,CAAC,uBAAuB,CAAC,CAAA;QAChD,CAAC;QACD,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,KAAK,CAAC,MAAM,GAAG,CAAC,CAAC,CAAA;QAC9C,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE;YAAE,KAAK,CAAC,CAAC,CAAC,GAAG,QAAQ,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;QAE7F,OAAO,KAAK,CAAA;IACd,CAAC,CAAA;IAED,MAAM,UAAU,GAAG,CAAC,IAAY,EAAc,EAAE;QAC9C,MAAM,KAAK,GAAG,IAAI,UAAU,CAAC,IAAI,CAAC,MAAM,CAAC,CAAA;QACzC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,MAAM,EAAE,CAAC,EAAE;YAAE,KAAK,CAAC,CAAC,CAAC,GAAG,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG,IAAI,CAAA;QAE1E,OAAO,KAAK,CAAA;IACd,CAAC,CAAA;IAED,MAAM,OAAO,GAAG,CAAC,UAA0B,EAAc,EAAE;QACzD,MAAM,EAAE,OAAO,EAAE,GAAG,UAAU,CAAA;QAC9B,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;YAChC,OAAO,OAAO,CAAA;QAChB,CAAC;QACD,QAAQ,UAAU,CAAC,QAAQ,EAAE,WAAW,EAAE,EAAE,CAAC;YAC3C,KAAK,QAAQ,EAAE,OAAO,UAAU,CAAC,OAAO,CAAC,CAAA;YACzC,KAAK,KAAK,EAAE,OAAO,OAAO,CAAC,OAAO,CAAC,CAAA;YACnC,KAAK,QAAQ,CAAC;YACd,KAAK,QAAQ,EAAE,OAAO,UAAU,CAAC,OAAO,CAAC,CAAA;YACzC,SAAS,OAAO,IAAI,WAAW,EAAE,CAAC,MAAM,CAAC,OAAO,CAAC,CAAA;QACnD,CAAC;IACH,CAAC,CAAA;IAED,MAAM,MAAM,GAAG,CAAC,UAA0B,EAAU,EAAE,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC,UAAU,CAAA;IAErF,MAAM,QAAQ,GAAG,CAAC,WAA8B,EAA2B,EAAE,CAC3E,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,EAAE,CAAC,CAAC;QACrC,QAAQ,EAAE,UAAU,CAAC,QAAQ;QAC7B,IAAI,EAAE,MAAM,CAAC,UAAU,CAAC;QACxB,GAAG,CAAC,UAAU,CAAC,WAAW,IAAI,IAAI,CAAC,CAAC,CAAC,EAAE,WAAW,EAAE,UAAU,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;KACnF,CAAC,CAAC,CAAA;IAEL,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAA;AACtC,CAAC,CAAA;AAED,MAAM,CAAC,MAAM,oBAAoB,GAAG,0BAA0B,EAAE,CAAA"}
|
package/build/consts.d.ts
CHANGED
package/build/consts.d.ts.map
CHANGED
|
@@ -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
package/build/consts.js.map
CHANGED
|
@@ -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
package/build/index.d.ts.map
CHANGED
|
@@ -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
package/build/index.js.map
CHANGED
|
@@ -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"}
|
package/build/service.d.ts
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
import type {
|
|
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) =>
|
|
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: () =>
|
|
7
|
+
export declare const makeDefaultConsoleMailerService: () => ConsoleMailerService;
|
|
12
8
|
//# sourceMappingURL=service.d.ts.map
|
package/build/service.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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;
|
package/build/service.js.map
CHANGED
|
@@ -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;
|
|
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
|
package/build/types.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
|
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
|
|
26
|
-
"@owlmeans/error": "^0.1.18
|
|
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": "
|
|
30
|
-
"@types/bun": "^1.4.
|
|
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
package/src/index.ts
CHANGED
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
|
-
|
|
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):
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
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 = ():
|
|
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
|
+
})
|