@owlmeans/mailer-smtp 0.1.18-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -0
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/mailer-smtp/SKILL.md +114 -0
- package/build/.gitkeep +0 -0
- package/build/consts.d.ts +4 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +4 -0
- package/build/consts.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/build/service.d.ts +23 -0
- package/build/service.d.ts.map +1 -0
- package/build/service.js +99 -0
- package/build/service.js.map +1 -0
- package/build/types.d.ts +39 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +43 -0
- package/src/consts.ts +4 -0
- package/src/index.ts +3 -0
- package/src/service.ts +120 -0
- package/src/types.ts +41 -0
- package/tests/context.ts +45 -0
- package/tests/message.spec.ts +94 -0
- package/tests/send.spec.ts +59 -0
- package/tsconfig.json +16 -0
package/README.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# @owlmeans/mailer-smtp
|
|
2
|
+
|
|
3
|
+
SMTP transport for the OwlMeans `MailerService` contract, built on [nodemailer](https://nodemailer.com).
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
- `makeSmtpMailerService(alias?)` — `MailerService` implementation reading `cfg.smtp`
|
|
8
|
+
- `SmtpSettings` / `SmtpConfig` — the config block; numbers and booleans also accept their string form, because config values mounted from files always arrive as text
|
|
9
|
+
- `verify()` — open a session and authenticate without sending, for health checks and tests
|
|
10
|
+
- `toTransportOptions()` / `toMailOptions()` — the pure translations into nodemailer's shapes
|
|
11
|
+
- Works with any SMTP relay; Mailgun, SES and Postmark all expose one
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @owlmeans/mailer-smtp
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```typescript
|
|
22
|
+
import { MAILER_SERVICE } from '@owlmeans/mailer'
|
|
23
|
+
import { makeSmtpMailerService } from '@owlmeans/mailer-smtp'
|
|
24
|
+
|
|
25
|
+
cfg.smtp = {
|
|
26
|
+
host: 'smtp.eu.mailgun.org',
|
|
27
|
+
port: 465, // implicit TLS; use 587 / 2525 with `secure: false` for STARTTLS
|
|
28
|
+
secure: true,
|
|
29
|
+
user: 'no-reply@example.com',
|
|
30
|
+
pass: process.env.SMTP_PASSWORD,
|
|
31
|
+
from: 'Example <no-reply@example.com>',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Register under MAILER_SERVICE so consumers — the email-OTP auth plugin above all —
|
|
35
|
+
// resolve the mailer without knowing which transport backs it.
|
|
36
|
+
context.registerService(makeSmtpMailerService(MAILER_SERVICE))
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
In a server context, point the settings at mounted files instead of literals — the
|
|
40
|
+
`fileConfigReader` middleware replaces any value starting with `/` by the file's contents:
|
|
41
|
+
|
|
42
|
+
```typescript
|
|
43
|
+
cfg.smtp = {
|
|
44
|
+
host: '/etc/app-config/smtp-host',
|
|
45
|
+
user: '/etc/app-config/smtp-user',
|
|
46
|
+
from: '/etc/app-config/smtp-from',
|
|
47
|
+
pass: '/etc/master-secret/smtp-secret',
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Tests
|
|
52
|
+
|
|
53
|
+
`tests/message.spec.ts` needs nothing. `tests/send.spec.ts` is env-gated and **delivers real
|
|
54
|
+
mail** when open — see the SMTP block in the monorepo's `.env.example`.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bun run build && bun test ./tests
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
<!-- owlmeans:agent-guidance:start -->
|
|
61
|
+
## Agent guidance
|
|
62
|
+
|
|
63
|
+
This package ships embedded agent skills under `agent-meta/`. After installing your
|
|
64
|
+
`@owlmeans/*` packages, run the OwlMeans agent-skills installer to place them into
|
|
65
|
+
your project's skill store (`.agents/skills/`):
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
npx @owlmeans/agent-skills
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The embedded files are version-matched to this package release. Do not edit them
|
|
72
|
+
directly — they are regenerated on each publish. To contribute guidance edits,
|
|
73
|
+
open a PR against the source monorepo.
|
|
74
|
+
<!-- owlmeans:agent-guidance:end -->
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schemaVersion": 2,
|
|
3
|
+
"package": "@owlmeans/mailer-smtp",
|
|
4
|
+
"version": "0.1.18-rc.0",
|
|
5
|
+
"generatedAt": "2026-08-16T22:20:50.500Z",
|
|
6
|
+
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
|
+
"entries": [
|
|
8
|
+
{
|
|
9
|
+
"kind": "skill",
|
|
10
|
+
"name": "mailer-smtp",
|
|
11
|
+
"category": "package-specific",
|
|
12
|
+
"file": "skills/mailer-smtp/SKILL.md",
|
|
13
|
+
"canonicalPath": ".agents/skills/mailer-smtp/SKILL.md"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: mailer-smtp
|
|
3
|
+
description: "How to use @owlmeans/mailer-smtp — SMTP transport (nodemailer) for the MailerService contract. Use when configuring real outbound email, an SMTP relay such as Mailgun, or the email-OTP mailer. Applies to files matching **/context.ts, **/config.ts, **/setup.ts."
|
|
4
|
+
metadata:
|
|
5
|
+
applyTo: "**/context.ts, **/config.ts, **/setup.ts"
|
|
6
|
+
---
|
|
7
|
+
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
8
|
+
|
|
9
|
+
# Using `@owlmeans/mailer-smtp`
|
|
10
|
+
|
|
11
|
+
SMTP transport implementing `@owlmeans/mailer`'s `MailerService`, built on `nodemailer` (the
|
|
12
|
+
zero-dependency de-facto standard for Node SMTP). Reads `ctx.cfg.smtp`. Works against any relay —
|
|
13
|
+
Mailgun, SES, Postmark all expose SMTP.
|
|
14
|
+
|
|
15
|
+
## Public API surface
|
|
16
|
+
|
|
17
|
+
| Symbol | Kind | Purpose |
|
|
18
|
+
|--------|------|---------|
|
|
19
|
+
| `makeSmtpMailerService(alias?)` | fn | Service factory; default alias `SMTP_MAILER` |
|
|
20
|
+
| `SmtpMailerService` | interface | `MailerService` + `verify()` + `close()` |
|
|
21
|
+
| `SmtpSettings` / `SmtpConfig` | interface | `cfg.smtp` block |
|
|
22
|
+
| `SMTP_MAILER` | const | `'smtp-mailer'` |
|
|
23
|
+
| `SMTP_DEFAULT_PORT` | const | `465` |
|
|
24
|
+
| `toTransportOptions` / `toMailOptions` | fn | Pure translations into nodemailer's shapes; test these, not the socket |
|
|
25
|
+
|
|
26
|
+
## Config shape
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
cfg.smtp = {
|
|
30
|
+
host: 'smtp.eu.mailgun.org',
|
|
31
|
+
port: 465, // default
|
|
32
|
+
secure: true, // default — implicit TLS
|
|
33
|
+
user: 'no-reply@example.com',
|
|
34
|
+
pass: process.env.SMTP_PASSWORD,
|
|
35
|
+
from: 'Example <no-reply@example.com>',
|
|
36
|
+
// optional: replyTo, headers, rejectUnauthorized, timeout
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Every numeric/boolean field also accepts its **string** form. That is not sloppiness: in a server
|
|
41
|
+
context the values usually come from mounted files, and `fileConfigReader` yields text.
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
cfg.smtp = {
|
|
45
|
+
host: '/etc/app-config/smtp-host',
|
|
46
|
+
user: '/etc/app-config/smtp-user',
|
|
47
|
+
from: '/etc/app-config/smtp-from',
|
|
48
|
+
pass: '/etc/master-secret/smtp-secret', // secret volume, never a ConfigMap
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Registration
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { MAILER_SERVICE } from '@owlmeans/mailer'
|
|
56
|
+
import { makeSmtpMailerService } from '@owlmeans/mailer-smtp'
|
|
57
|
+
|
|
58
|
+
context.registerService(makeSmtpMailerService(MAILER_SERVICE))
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Register under `MAILER_SERVICE` so platform code — `OtpService` above all — resolves the mailer
|
|
62
|
+
without knowing the transport. Select between transports on config, not on a build flag:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
context.registerService(
|
|
66
|
+
cfg.smtp?.host != null && cfg.smtp.host !== ''
|
|
67
|
+
? makeSmtpMailerService(MAILER_SERVICE)
|
|
68
|
+
: makeConsoleMailerService(MAILER_SERVICE)
|
|
69
|
+
)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Ports and TLS
|
|
73
|
+
|
|
74
|
+
| Port | Mode | `secure` |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| 465 | implicit TLS from the first byte | `true` (default) |
|
|
77
|
+
| 587, 2525 | STARTTLS upgrade | `false` |
|
|
78
|
+
| 25 | plain / STARTTLS, often blocked | `false` |
|
|
79
|
+
|
|
80
|
+
## Rules
|
|
81
|
+
|
|
82
|
+
- **The relay's SMTP credential is not the account login.** Mailgun issues a per-sending-domain
|
|
83
|
+
user (`postmaster@domain` or a custom one) under the domain's SMTP settings.
|
|
84
|
+
- The `From` domain must be verified with the relay; the local part is free-form.
|
|
85
|
+
- Deliberately **unpooled** — a pooled transport holds its socket and keeps a short-lived process
|
|
86
|
+
alive. Add pooling only with a matching lifecycle.
|
|
87
|
+
- Errors are rethrown prefixed with the service alias and the server's own reply
|
|
88
|
+
(`code`/`responseCode`/`response`). The password never reaches a message or a log.
|
|
89
|
+
- `verify()` authenticates without submitting a message — right for health checks; not proof that
|
|
90
|
+
the relay accepts *a message* from your sender.
|
|
91
|
+
- Never register this in unit tests — use `makeConsoleMailerService`. `toMailOptions` plus
|
|
92
|
+
nodemailer's own `jsonTransport` cover envelope assertions without a socket.
|
|
93
|
+
- **Never authenticate with a deliberately wrong password against a live relay.** Repeated failed
|
|
94
|
+
logins trip the provider's brute-force protection and lock the shared credential for every
|
|
95
|
+
environment using it — Mailgun then answers `535 Authentication failed` to the correct password
|
|
96
|
+
too, and the credential has to be reset in its dashboard. Provoke transport errors with an
|
|
97
|
+
unreachable socket (`host: '127.0.0.1', port: '1'`) instead.
|
|
98
|
+
- The live spec (`tests/send.spec.ts`) is gated on the SMTP block in `/.env.example` and **delivers
|
|
99
|
+
real mail** when the gate is open. Empty variables = skip, never a failure.
|
|
100
|
+
- Rollup-bundling for a container image works with the default `preferBuiltins: true` node-resolve
|
|
101
|
+
setup; nodemailer's dynamic requires do not need to be externalized.
|
|
102
|
+
|
|
103
|
+
## External references
|
|
104
|
+
|
|
105
|
+
- [nodemailer](https://www.npmjs.com/package/nodemailer) — v9.x, MIT-0, **zero dependencies**, the
|
|
106
|
+
de-facto standard (~10.8k dependents). Ships no types; `@types/nodemailer` (v8.x) supplies them.
|
|
107
|
+
CJS — import the default (`import nodemailer from 'nodemailer'`), not named members, so both Bun
|
|
108
|
+
and Rollup's commonjs interop resolve it. Verified working under Bun and inside a Rollup CJS
|
|
109
|
+
bundle (2026-08).
|
|
110
|
+
- [Mailgun — Send via SMTP](https://documentation.mailgun.com/docs/mailgun/user-manual/sending-messages/send-smtp)
|
|
111
|
+
— hosts `smtp.mailgun.org` / `smtp.eu.mailgun.org`; port 465 requires TLS, ports 25/587/2525 start
|
|
112
|
+
plain and upgrade via STARTTLS. Credentials are **per sending domain**, managed under that domain's
|
|
113
|
+
SMTP settings — not the account login. `X-Mailgun-Drop-Message: yes` submits in test mode (accepted,
|
|
114
|
+
never delivered); other `X-Mailgun-*` headers cover tagging, DKIM, tracking and required TLS.
|
package/build/.gitkeep
ADDED
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,WAAW,gBAAgB,CAAA;AAExC,oFAAoF;AACpF,eAAO,MAAM,iBAAiB,MAAM,CAAA"}
|
package/build/consts.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AAAA,MAAM,CAAC,MAAM,WAAW,GAAG,aAAa,CAAA;AAExC,oFAAoF;AACpF,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,CAAA"}
|
package/build/index.d.ts
ADDED
|
@@ -0,0 +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"}
|
package/build/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { MailMessage } from '@owlmeans/mailer';
|
|
2
|
+
import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js';
|
|
3
|
+
import type Mail from 'nodemailer/lib/mailer/index.js';
|
|
4
|
+
import type { SmtpMailerService, SmtpSettings } from './types.js';
|
|
5
|
+
/**
|
|
6
|
+
* Translate the settings block into nodemailer's transport options.
|
|
7
|
+
*
|
|
8
|
+
* Deliberately unpooled: a pooled transport holds its socket open and would keep a
|
|
9
|
+
* short-lived process alive, and the traffic this serves — login codes — is far below
|
|
10
|
+
* the volume that makes pooling worth that.
|
|
11
|
+
*/
|
|
12
|
+
export declare const toTransportOptions: (smtp: SmtpSettings) => SMTPTransport.Options;
|
|
13
|
+
/** Translate a provider-agnostic message into nodemailer's envelope, applying the config defaults. */
|
|
14
|
+
export declare const toMailOptions: (smtp: SmtpSettings, message: MailMessage) => Mail.Options;
|
|
15
|
+
/**
|
|
16
|
+
* SMTP transport for the `MailerService` contract. Reads `cfg.smtp` and keeps one
|
|
17
|
+
* nodemailer transporter per service instance.
|
|
18
|
+
*
|
|
19
|
+
* Register it under `MAILER_SERVICE` so callers — the email-OTP plugin above all —
|
|
20
|
+
* resolve it without knowing which transport is in play.
|
|
21
|
+
*/
|
|
22
|
+
export declare const makeSmtpMailerService: (alias?: string) => SmtpMailerService;
|
|
23
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAInD,OAAO,KAAK,aAAa,MAAM,wCAAwC,CAAA;AACvE,OAAO,KAAK,IAAI,MAAM,gCAAgC,CAAA;AAEtD,OAAO,KAAK,EAAc,iBAAiB,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AAgB7E;;;;;;GAMG;AACH,eAAO,MAAM,kBAAkB,SAAU,YAAY,KAAG,aAAa,CAAC,OASpE,CAAA;AAEF,sGAAsG;AACtG,eAAO,MAAM,aAAa,SAAU,YAAY,WAAW,WAAW,KAAG,IAAI,CAAC,OAa7E,CAAA;AAED;;;;;;GAMG;AACH,eAAO,MAAM,qBAAqB,sBAA0B,iBAsD3D,CAAA"}
|
package/build/service.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { createService } from '@owlmeans/context';
|
|
2
|
+
import nodemailer from 'nodemailer';
|
|
3
|
+
import { SMTP_DEFAULT_PORT, SMTP_MAILER } from './consts.js';
|
|
4
|
+
const toNumber = (value, def) => {
|
|
5
|
+
if (value == null || value === '')
|
|
6
|
+
return def;
|
|
7
|
+
const num = typeof value === 'number' ? value : Number(value);
|
|
8
|
+
return Number.isFinite(num) ? num : def;
|
|
9
|
+
};
|
|
10
|
+
const toBoolean = (value, def) => {
|
|
11
|
+
if (value == null || value === '')
|
|
12
|
+
return def;
|
|
13
|
+
if (typeof value === 'boolean')
|
|
14
|
+
return value;
|
|
15
|
+
return ['1', 'on', 'true', 'yes'].includes(value.trim().toLowerCase());
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Translate the settings block into nodemailer's transport options.
|
|
19
|
+
*
|
|
20
|
+
* Deliberately unpooled: a pooled transport holds its socket open and would keep a
|
|
21
|
+
* short-lived process alive, and the traffic this serves — login codes — is far below
|
|
22
|
+
* the volume that makes pooling worth that.
|
|
23
|
+
*/
|
|
24
|
+
export const toTransportOptions = (smtp) => ({
|
|
25
|
+
host: smtp.host,
|
|
26
|
+
port: toNumber(smtp.port, SMTP_DEFAULT_PORT),
|
|
27
|
+
secure: toBoolean(smtp.secure, true),
|
|
28
|
+
tls: { rejectUnauthorized: toBoolean(smtp.rejectUnauthorized, true) },
|
|
29
|
+
...(smtp.timeout != null ? { connectionTimeout: toNumber(smtp.timeout, 0) } : {}),
|
|
30
|
+
...(smtp.user != null && smtp.user !== ''
|
|
31
|
+
? { auth: { user: smtp.user, pass: smtp.pass ?? '' } }
|
|
32
|
+
: {}),
|
|
33
|
+
});
|
|
34
|
+
/** Translate a provider-agnostic message into nodemailer's envelope, applying the config defaults. */
|
|
35
|
+
export const toMailOptions = (smtp, message) => {
|
|
36
|
+
const headers = { ...smtp.headers, ...message.headers };
|
|
37
|
+
const replyTo = message.replyTo ?? smtp.replyTo;
|
|
38
|
+
return {
|
|
39
|
+
from: message.from ?? smtp.from,
|
|
40
|
+
to: message.to,
|
|
41
|
+
subject: message.subject,
|
|
42
|
+
...(message.text != null ? { text: message.text } : {}),
|
|
43
|
+
...(message.html != null ? { html: message.html } : {}),
|
|
44
|
+
...(replyTo != null ? { replyTo } : {}),
|
|
45
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* SMTP transport for the `MailerService` contract. Reads `cfg.smtp` and keeps one
|
|
50
|
+
* nodemailer transporter per service instance.
|
|
51
|
+
*
|
|
52
|
+
* Register it under `MAILER_SERVICE` so callers — the email-OTP plugin above all —
|
|
53
|
+
* resolve it without knowing which transport is in play.
|
|
54
|
+
*/
|
|
55
|
+
export const makeSmtpMailerService = (alias = SMTP_MAILER) => {
|
|
56
|
+
let transport = null;
|
|
57
|
+
const settings = () => {
|
|
58
|
+
const ctx = service.assertCtx(alias);
|
|
59
|
+
const smtp = ctx.cfg.smtp;
|
|
60
|
+
if (smtp?.host == null || smtp.host === '') {
|
|
61
|
+
throw new SyntaxError(`${alias}: cfg.smtp.host is not configured`);
|
|
62
|
+
}
|
|
63
|
+
return smtp;
|
|
64
|
+
};
|
|
65
|
+
const transporter = () => transport ??= nodemailer.createTransport(toTransportOptions(settings()));
|
|
66
|
+
// Credentials never reach the message: nodemailer reports the server's own reply only.
|
|
67
|
+
const fail = (action, error) => {
|
|
68
|
+
const err = error;
|
|
69
|
+
const detail = [err.code, err.responseCode, err.response ?? err.message]
|
|
70
|
+
.filter(part => part != null && part !== '').join(' ');
|
|
71
|
+
throw new Error(`${alias}: ${action}${detail !== '' ? ` — ${detail}` : ''}`);
|
|
72
|
+
};
|
|
73
|
+
const service = createService(alias, {
|
|
74
|
+
send: async (message) => {
|
|
75
|
+
const options = toMailOptions(settings(), message);
|
|
76
|
+
try {
|
|
77
|
+
await transporter().sendMail(options);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
fail(`failed to send to ${message.to}`, error);
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
verify: async () => {
|
|
84
|
+
try {
|
|
85
|
+
await transporter().verify();
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
fail('failed to verify the SMTP connection', error);
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
},
|
|
92
|
+
close: async () => {
|
|
93
|
+
transport?.close();
|
|
94
|
+
transport = null;
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
return service;
|
|
98
|
+
};
|
|
99
|
+
//# sourceMappingURL=service.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.js","sourceRoot":"","sources":["../src/service.ts"],"names":[],"mappings":"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"}
|
package/build/types.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { MailerService } from '@owlmeans/mailer';
|
|
2
|
+
import type { ServerConfig } from '@owlmeans/server-context';
|
|
3
|
+
/**
|
|
4
|
+
* SMTP transport settings, read from `cfg.smtp` at send time.
|
|
5
|
+
*
|
|
6
|
+
* Numbers and booleans accept a string form on purpose: server contexts resolve
|
|
7
|
+
* config leafs that look like paths through `fileConfigReader`, so a value mounted
|
|
8
|
+
* from a Kubernetes ConfigMap always arrives as text. The service coerces them.
|
|
9
|
+
*/
|
|
10
|
+
export interface SmtpSettings {
|
|
11
|
+
/** SMTP server, e.g. `smtp.eu.mailgun.org`. */
|
|
12
|
+
host: string;
|
|
13
|
+
/** Defaults to 465. */
|
|
14
|
+
port?: number | string;
|
|
15
|
+
/** `true` (default) = implicit TLS, for port 465. Use `false` with the STARTTLS ports. */
|
|
16
|
+
secure?: boolean | string;
|
|
17
|
+
user?: string;
|
|
18
|
+
pass?: string;
|
|
19
|
+
/** Default `From` header — `'OwlMeans <no-reply@example.com>'`. A message may override it. */
|
|
20
|
+
from: string;
|
|
21
|
+
/** Default `Reply-To` header. */
|
|
22
|
+
replyTo?: string;
|
|
23
|
+
/** Verify the server certificate. Defaults to `true`; only disable knowingly. */
|
|
24
|
+
rejectUnauthorized?: boolean | string;
|
|
25
|
+
/** Headers added to every message. Per-message headers win on conflict. */
|
|
26
|
+
headers?: Record<string, string>;
|
|
27
|
+
/** Connection timeout in milliseconds. */
|
|
28
|
+
timeout?: number | string;
|
|
29
|
+
}
|
|
30
|
+
export interface SmtpConfig extends ServerConfig {
|
|
31
|
+
smtp: SmtpSettings;
|
|
32
|
+
}
|
|
33
|
+
export interface SmtpMailerService extends MailerService {
|
|
34
|
+
/** Open a session and authenticate without submitting a message. Throws when the server rejects. */
|
|
35
|
+
verify: () => Promise<true>;
|
|
36
|
+
/** Release a pooled connection. A no-op for the default, non-pooled transport. */
|
|
37
|
+
close: () => Promise<void>;
|
|
38
|
+
}
|
|
39
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AACrD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,0BAA0B,CAAA;AAE5D;;;;;;GAMG;AACH,MAAM,WAAW,YAAY;IAC3B,+CAA+C;IAC/C,IAAI,EAAE,MAAM,CAAA;IACZ,uBAAuB;IACvB,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;IACtB,0FAA0F;IAC1F,MAAM,CAAC,EAAE,OAAO,GAAG,MAAM,CAAA;IACzB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,8FAA8F;IAC9F,IAAI,EAAE,MAAM,CAAA;IACZ,iCAAiC;IACjC,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,iFAAiF;IACjF,kBAAkB,CAAC,EAAE,OAAO,GAAG,MAAM,CAAA;IACrC,2EAA2E;IAC3E,OAAO,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAChC,0CAA0C;IAC1C,OAAO,CAAC,EAAE,MAAM,GAAG,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,UAAW,SAAQ,YAAY;IAC9C,IAAI,EAAE,YAAY,CAAA;CACnB;AAED,MAAM,WAAW,iBAAkB,SAAQ,aAAa;IACtD,oGAAoG;IACpG,MAAM,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;IAC3B,kFAAkF;IAClF,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAA;CAC3B"}
|
package/build/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":""}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@owlmeans/mailer-smtp",
|
|
3
|
+
"version": "0.1.18-rc.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"build": "tsc -b",
|
|
8
|
+
"dev": "sleep 4 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
9
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
10
|
+
"test": "bun test ./tests"
|
|
11
|
+
},
|
|
12
|
+
"main": "build/index.js",
|
|
13
|
+
"module": "build/index.js",
|
|
14
|
+
"types": "build/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"import": "./build/index.js",
|
|
18
|
+
"require": "./build/index.js",
|
|
19
|
+
"default": "./build/index.js",
|
|
20
|
+
"module": "./build/index.js",
|
|
21
|
+
"types": "./build/index.d.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@owlmeans/context": "^0.1.18-rc.0",
|
|
26
|
+
"@owlmeans/mailer": "^0.1.18-rc.0",
|
|
27
|
+
"@owlmeans/server-context": "^0.1.18-rc.0",
|
|
28
|
+
"nodemailer": "^9.0.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@owlmeans/dep-config": "workspace:*",
|
|
32
|
+
"@owlmeans/test": "^0.1.18-rc.0",
|
|
33
|
+
"@owlmeans/test-integration": "^0.1.18-rc.0",
|
|
34
|
+
"@types/bun": "^1.3.14",
|
|
35
|
+
"@types/node": "^26.1.0",
|
|
36
|
+
"@types/nodemailer": "^8.0.1",
|
|
37
|
+
"nodemon": "^3.1.14",
|
|
38
|
+
"typescript": "^7.0.2"
|
|
39
|
+
},
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/consts.ts
ADDED
package/src/index.ts
ADDED
package/src/service.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createService } from '@owlmeans/context'
|
|
2
|
+
import type { MailMessage } from '@owlmeans/mailer'
|
|
3
|
+
import type { ServerContext } from '@owlmeans/server-context'
|
|
4
|
+
import nodemailer from 'nodemailer'
|
|
5
|
+
import type { Transporter } from 'nodemailer'
|
|
6
|
+
import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js'
|
|
7
|
+
import type Mail from 'nodemailer/lib/mailer/index.js'
|
|
8
|
+
import { SMTP_DEFAULT_PORT, SMTP_MAILER } from './consts.js'
|
|
9
|
+
import type { SmtpConfig, SmtpMailerService, SmtpSettings } from './types.js'
|
|
10
|
+
|
|
11
|
+
const toNumber = (value: number | string | undefined, def: number): number => {
|
|
12
|
+
if (value == null || value === '') return def
|
|
13
|
+
const num = typeof value === 'number' ? value : Number(value)
|
|
14
|
+
|
|
15
|
+
return Number.isFinite(num) ? num : def
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const toBoolean = (value: boolean | string | undefined, def: boolean): boolean => {
|
|
19
|
+
if (value == null || value === '') return def
|
|
20
|
+
if (typeof value === 'boolean') return value
|
|
21
|
+
|
|
22
|
+
return ['1', 'on', 'true', 'yes'].includes(value.trim().toLowerCase())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Translate the settings block into nodemailer's transport options.
|
|
27
|
+
*
|
|
28
|
+
* Deliberately unpooled: a pooled transport holds its socket open and would keep a
|
|
29
|
+
* short-lived process alive, and the traffic this serves — login codes — is far below
|
|
30
|
+
* the volume that makes pooling worth that.
|
|
31
|
+
*/
|
|
32
|
+
export const toTransportOptions = (smtp: SmtpSettings): SMTPTransport.Options => ({
|
|
33
|
+
host: smtp.host,
|
|
34
|
+
port: toNumber(smtp.port, SMTP_DEFAULT_PORT),
|
|
35
|
+
secure: toBoolean(smtp.secure, true),
|
|
36
|
+
tls: { rejectUnauthorized: toBoolean(smtp.rejectUnauthorized, true) },
|
|
37
|
+
...(smtp.timeout != null ? { connectionTimeout: toNumber(smtp.timeout, 0) } : {}),
|
|
38
|
+
...(smtp.user != null && smtp.user !== ''
|
|
39
|
+
? { auth: { user: smtp.user, pass: smtp.pass ?? '' } }
|
|
40
|
+
: {}),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
/** Translate a provider-agnostic message into nodemailer's envelope, applying the config defaults. */
|
|
44
|
+
export const toMailOptions = (smtp: SmtpSettings, message: MailMessage): Mail.Options => {
|
|
45
|
+
const headers = { ...smtp.headers, ...message.headers }
|
|
46
|
+
const replyTo = message.replyTo ?? smtp.replyTo
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
from: message.from ?? smtp.from,
|
|
50
|
+
to: message.to,
|
|
51
|
+
subject: message.subject,
|
|
52
|
+
...(message.text != null ? { text: message.text } : {}),
|
|
53
|
+
...(message.html != null ? { html: message.html } : {}),
|
|
54
|
+
...(replyTo != null ? { replyTo } : {}),
|
|
55
|
+
...(Object.keys(headers).length > 0 ? { headers } : {}),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* SMTP transport for the `MailerService` contract. Reads `cfg.smtp` and keeps one
|
|
61
|
+
* nodemailer transporter per service instance.
|
|
62
|
+
*
|
|
63
|
+
* Register it under `MAILER_SERVICE` so callers — the email-OTP plugin above all —
|
|
64
|
+
* resolve it without knowing which transport is in play.
|
|
65
|
+
*/
|
|
66
|
+
export const makeSmtpMailerService = (alias = SMTP_MAILER): SmtpMailerService => {
|
|
67
|
+
let transport: Transporter<SMTPTransport.SentMessageInfo> | null = null
|
|
68
|
+
|
|
69
|
+
const settings = (): SmtpSettings => {
|
|
70
|
+
const ctx = service.assertCtx<SmtpConfig, ServerContext<SmtpConfig>>(alias)
|
|
71
|
+
const smtp = ctx.cfg.smtp
|
|
72
|
+
|
|
73
|
+
if (smtp?.host == null || smtp.host === '') {
|
|
74
|
+
throw new SyntaxError(`${alias}: cfg.smtp.host is not configured`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return smtp
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const transporter = (): Transporter<SMTPTransport.SentMessageInfo> =>
|
|
81
|
+
transport ??= nodemailer.createTransport(toTransportOptions(settings()))
|
|
82
|
+
|
|
83
|
+
// Credentials never reach the message: nodemailer reports the server's own reply only.
|
|
84
|
+
const fail = (action: string, error: unknown): never => {
|
|
85
|
+
const err = error as { code?: string, responseCode?: number, response?: string, message?: string }
|
|
86
|
+
const detail = [err.code, err.responseCode, err.response ?? err.message]
|
|
87
|
+
.filter(part => part != null && part !== '').join(' ')
|
|
88
|
+
|
|
89
|
+
throw new Error(`${alias}: ${action}${detail !== '' ? ` — ${detail}` : ''}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const service = createService<SmtpMailerService>(alias, {
|
|
93
|
+
send: async (message: MailMessage): Promise<void> => {
|
|
94
|
+
const options = toMailOptions(settings(), message)
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
await transporter().sendMail(options)
|
|
98
|
+
} catch (error) {
|
|
99
|
+
fail(`failed to send to ${message.to}`, error)
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
verify: async (): Promise<true> => {
|
|
104
|
+
try {
|
|
105
|
+
await transporter().verify()
|
|
106
|
+
} catch (error) {
|
|
107
|
+
fail('failed to verify the SMTP connection', error)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return true
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
close: async (): Promise<void> => {
|
|
114
|
+
transport?.close()
|
|
115
|
+
transport = null
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
return service
|
|
120
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { MailerService } from '@owlmeans/mailer'
|
|
2
|
+
import type { ServerConfig } from '@owlmeans/server-context'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* SMTP transport settings, read from `cfg.smtp` at send time.
|
|
6
|
+
*
|
|
7
|
+
* Numbers and booleans accept a string form on purpose: server contexts resolve
|
|
8
|
+
* config leafs that look like paths through `fileConfigReader`, so a value mounted
|
|
9
|
+
* from a Kubernetes ConfigMap always arrives as text. The service coerces them.
|
|
10
|
+
*/
|
|
11
|
+
export interface SmtpSettings {
|
|
12
|
+
/** SMTP server, e.g. `smtp.eu.mailgun.org`. */
|
|
13
|
+
host: string
|
|
14
|
+
/** Defaults to 465. */
|
|
15
|
+
port?: number | string
|
|
16
|
+
/** `true` (default) = implicit TLS, for port 465. Use `false` with the STARTTLS ports. */
|
|
17
|
+
secure?: boolean | string
|
|
18
|
+
user?: string
|
|
19
|
+
pass?: string
|
|
20
|
+
/** Default `From` header — `'OwlMeans <no-reply@example.com>'`. A message may override it. */
|
|
21
|
+
from: string
|
|
22
|
+
/** Default `Reply-To` header. */
|
|
23
|
+
replyTo?: string
|
|
24
|
+
/** Verify the server certificate. Defaults to `true`; only disable knowingly. */
|
|
25
|
+
rejectUnauthorized?: boolean | string
|
|
26
|
+
/** Headers added to every message. Per-message headers win on conflict. */
|
|
27
|
+
headers?: Record<string, string>
|
|
28
|
+
/** Connection timeout in milliseconds. */
|
|
29
|
+
timeout?: number | string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SmtpConfig extends ServerConfig {
|
|
33
|
+
smtp: SmtpSettings
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface SmtpMailerService extends MailerService {
|
|
37
|
+
/** Open a session and authenticate without submitting a message. Throws when the server rejects. */
|
|
38
|
+
verify: () => Promise<true>
|
|
39
|
+
/** Release a pooled connection. A no-op for the default, non-pooled transport. */
|
|
40
|
+
close: () => Promise<void>
|
|
41
|
+
}
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { smtpGate } from '@owlmeans/test-integration'
|
|
2
|
+
import type { IntegrationGate, SmtpEnv } from '@owlmeans/test-integration'
|
|
3
|
+
import { config, makeServerContext } from '@owlmeans/server-context'
|
|
4
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
5
|
+
import { MAILER_SERVICE } from '@owlmeans/mailer'
|
|
6
|
+
import { makeSmtpMailerService } from '@owlmeans/mailer-smtp'
|
|
7
|
+
import type { SmtpConfig, SmtpMailerService, SmtpSettings } from '@owlmeans/mailer-smtp'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Live relay gate. Every variable empty means the delivery specs self-skip with a
|
|
11
|
+
* printed reason — never a failure. When it is open these specs send REAL mail, so
|
|
12
|
+
* `SMTP_TEST_TO` is required rather than optional.
|
|
13
|
+
*/
|
|
14
|
+
export const gate: IntegrationGate<SmtpEnv> = smtpGate()
|
|
15
|
+
|
|
16
|
+
/** Never logged, never asserted on — only handed to the transport. */
|
|
17
|
+
export const settings = (): SmtpSettings => ({
|
|
18
|
+
host: gate.env.SMTP_HOST as string,
|
|
19
|
+
port: gate.env.SMTP_PORT ?? '465',
|
|
20
|
+
secure: gate.env.SMTP_SECURE ?? 'true',
|
|
21
|
+
user: gate.env.SMTP_USER as string,
|
|
22
|
+
pass: gate.env.SMTP_PASSWORD as string,
|
|
23
|
+
from: gate.env.SMTP_FROM as string,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export const recipient = (): string => gate.env.SMTP_TEST_TO as string
|
|
27
|
+
|
|
28
|
+
export interface Booted {
|
|
29
|
+
context: ServerContext<ServerConfig>
|
|
30
|
+
mailer: SmtpMailerService
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Real context, real service registration — the same path a server app takes. */
|
|
34
|
+
export const boot = async (overrides: Partial<SmtpSettings> = {}): Promise<Booted> => {
|
|
35
|
+
const cfg = config('smtp-test') as SmtpConfig
|
|
36
|
+
cfg.smtp = { ...settings(), ...overrides }
|
|
37
|
+
|
|
38
|
+
const context = makeServerContext(cfg) as unknown as ServerContext<ServerConfig>
|
|
39
|
+
context.registerService(makeSmtpMailerService(MAILER_SERVICE) as never)
|
|
40
|
+
|
|
41
|
+
context.configure()
|
|
42
|
+
await context.init()
|
|
43
|
+
|
|
44
|
+
return { context, mailer: context.service<SmtpMailerService>(MAILER_SERVICE) }
|
|
45
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import nodemailer from 'nodemailer'
|
|
3
|
+
import { SMTP_DEFAULT_PORT, toMailOptions, toTransportOptions } from '@owlmeans/mailer-smtp'
|
|
4
|
+
import type { SmtpSettings } from '@owlmeans/mailer-smtp'
|
|
5
|
+
|
|
6
|
+
const base: SmtpSettings = {
|
|
7
|
+
host: 'smtp.example.org',
|
|
8
|
+
user: 'no-reply@example.org',
|
|
9
|
+
pass: 'secret',
|
|
10
|
+
from: 'Example <no-reply@example.org>',
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* No gate and no network: these assert the translation into nodemailer's own shapes,
|
|
15
|
+
* and let nodemailer itself build the envelope through its bundled `jsonTransport`.
|
|
16
|
+
*/
|
|
17
|
+
describe('@owlmeans/mailer-smtp — transport options', () => {
|
|
18
|
+
test('defaults to implicit TLS on 465 with certificate verification', () => {
|
|
19
|
+
const options = toTransportOptions(base)
|
|
20
|
+
|
|
21
|
+
expect(options.port).toBe(SMTP_DEFAULT_PORT)
|
|
22
|
+
expect(options.secure).toBe(true)
|
|
23
|
+
expect(options.tls).toEqual({ rejectUnauthorized: true })
|
|
24
|
+
expect(options.auth).toEqual({ user: base.user, pass: base.pass })
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('coerces the string forms a ConfigMap file delivers', () => {
|
|
28
|
+
const options = toTransportOptions({
|
|
29
|
+
...base, port: '2525', secure: 'false', rejectUnauthorized: '0', timeout: '5000',
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
expect(options.port).toBe(2525)
|
|
33
|
+
expect(options.secure).toBe(false)
|
|
34
|
+
expect(options.tls).toEqual({ rejectUnauthorized: false })
|
|
35
|
+
expect(options.connectionTimeout).toBe(5000)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
test('falls back to the default port when the value is unusable', () => {
|
|
39
|
+
expect(toTransportOptions({ ...base, port: '' }).port).toBe(SMTP_DEFAULT_PORT)
|
|
40
|
+
expect(toTransportOptions({ ...base, port: 'nonsense' }).port).toBe(SMTP_DEFAULT_PORT)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('omits auth for an unauthenticated relay', () => {
|
|
44
|
+
expect(toTransportOptions({ ...base, user: '' }).auth).toBeUndefined()
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('@owlmeans/mailer-smtp — message mapping', () => {
|
|
49
|
+
const message = { to: 'user@example.com', subject: 'Your login code', text: '106341' }
|
|
50
|
+
|
|
51
|
+
test('applies the configured sender and reply-to', () => {
|
|
52
|
+
const options = toMailOptions({ ...base, replyTo: 'support@example.org' }, message)
|
|
53
|
+
|
|
54
|
+
expect(options.from).toBe(base.from)
|
|
55
|
+
expect(options.replyTo).toBe('support@example.org')
|
|
56
|
+
expect(options.headers).toBeUndefined()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
test('lets a message override the sender and reply-to', () => {
|
|
60
|
+
const options = toMailOptions(
|
|
61
|
+
{ ...base, replyTo: 'support@example.org' },
|
|
62
|
+
{ ...message, from: 'Other <other@example.org>', replyTo: 'nobody@example.org' }
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
expect(options.from).toBe('Other <other@example.org>')
|
|
66
|
+
expect(options.replyTo).toBe('nobody@example.org')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test('merges headers with the message winning', () => {
|
|
70
|
+
const options = toMailOptions(
|
|
71
|
+
{ ...base, headers: { 'X-Origin': 'config', 'X-Kept': 'yes' } },
|
|
72
|
+
{ ...message, headers: { 'X-Origin': 'message' } }
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
expect(options.headers).toEqual({ 'X-Origin': 'message', 'X-Kept': 'yes' })
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('nodemailer builds the envelope we described', async () => {
|
|
79
|
+
const transport = nodemailer.createTransport({ jsonTransport: true })
|
|
80
|
+
const info = await transport.sendMail(toMailOptions(
|
|
81
|
+
{ ...base, headers: { 'X-OwlMeans-Test': 'mapping' } },
|
|
82
|
+
{ ...message, html: '<p>106341</p>' }
|
|
83
|
+
))
|
|
84
|
+
|
|
85
|
+
const built = JSON.parse(info.message as unknown as string)
|
|
86
|
+
|
|
87
|
+
expect(built.from.address).toBe('no-reply@example.org')
|
|
88
|
+
expect(built.to[0].address).toBe('user@example.com')
|
|
89
|
+
expect(built.subject).toBe('Your login code')
|
|
90
|
+
expect(built.text).toBe('106341')
|
|
91
|
+
expect(built.html).toBe('<p>106341</p>')
|
|
92
|
+
expect(built.headers['X-OwlMeans-Test']).toBe('mapping')
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { randomNamespace } from '@owlmeans/test-integration'
|
|
3
|
+
import { boot, gate, recipient } from './context.js'
|
|
4
|
+
import type { SmtpMailerService } from '@owlmeans/mailer-smtp'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Live delivery against the configured relay (Mailgun EU in our deployments).
|
|
8
|
+
*
|
|
9
|
+
* These send real mail on every run — that is the point: the only thing that proves
|
|
10
|
+
* the relay accepts our sender, credentials and TLS mode is the relay accepting it.
|
|
11
|
+
*/
|
|
12
|
+
describe('@owlmeans/mailer-smtp — live delivery', () => {
|
|
13
|
+
if (gate.skip) {
|
|
14
|
+
test.skip(gate.reason ?? 'smtp gate closed', () => { })
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const opened: SmtpMailerService[] = []
|
|
19
|
+
|
|
20
|
+
afterAll(async () => {
|
|
21
|
+
while (opened.length > 0) {
|
|
22
|
+
await opened.pop()?.close().catch(() => undefined)
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('authenticates against the relay', async () => {
|
|
27
|
+
const { mailer } = await boot()
|
|
28
|
+
opened.push(mailer)
|
|
29
|
+
|
|
30
|
+
expect(await mailer.verify()).toBe(true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('delivers a message', async () => {
|
|
34
|
+
const { mailer } = await boot()
|
|
35
|
+
opened.push(mailer)
|
|
36
|
+
const stamp = randomNamespace('omt-smtp')
|
|
37
|
+
|
|
38
|
+
await mailer.send({
|
|
39
|
+
to: recipient(),
|
|
40
|
+
subject: `OwlMeans SMTP integration ${stamp}`,
|
|
41
|
+
text: `Delivery check ${stamp}. Sent by @owlmeans/mailer-smtp.`,
|
|
42
|
+
html: `<p>Delivery check <strong>${stamp}</strong>.</p>`,
|
|
43
|
+
headers: { 'X-OwlMeans-Test': stamp },
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Failure is provoked with an unreachable socket, never with a wrong password: repeated bad
|
|
49
|
+
* logins trip the relay's brute-force protection and lock the shared credential for everyone.
|
|
50
|
+
* Never authenticate incorrectly on purpose against a live relay.
|
|
51
|
+
*/
|
|
52
|
+
test('reports a transport failure instead of swallowing it', async () => {
|
|
53
|
+
const { mailer } = await boot({ host: '127.0.0.1', port: '1', secure: 'false', timeout: '1000' })
|
|
54
|
+
opened.push(mailer)
|
|
55
|
+
|
|
56
|
+
// The alias prefixes the message so a failure in a server log names the transport.
|
|
57
|
+
expect(mailer.verify()).rejects.toThrow(/mailer-service/)
|
|
58
|
+
})
|
|
59
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.node.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"exclude": [
|
|
11
|
+
"./dist/**/*",
|
|
12
|
+
"./build/**/*",
|
|
13
|
+
"./tests/**/*",
|
|
14
|
+
"./*.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|