@kilnjs/mailer
v0.1.3
Published
Outbound mail for KilnJS: one transport, configured where the module is declared, and recorded rather than sent in a test run
Readme
@kilnjs/mailer
Outbound mail for KilnJS, as a module core is given rather than one it contains. SMTP, over nodemailer.
One transport, because SMTP reaches every relay worth reaching and which one is a host rather than a decision. What is a decision is whether mail leaves the process at all, and a test run says no.
Install
yarn add @kilnjs/mailerimport { mailer } from "@kilnjs/mailer";
export default {
modules: [mailer()],
} satisfies AppConfig;Declaring it reads the configuration, so a missing host or sender stops the boot rather than the first message.
import { Mail } from "@kilnjs/mailer";
await Mail.send({
to: user.email,
subject: "You have been invited",
html: render(invite),
});What a message says, and who should get one, stays in the application. This is the part that talks to a relay.
Settings
| | | |
| --- | --- | --- |
| from | MAIL_FROM | required. A message with no sender is refused by every relay worth using |
| host | SMTP_HOST | required |
| port | SMTP_PORT | 587 |
| user | SMTP_USER | omitted means no authentication, which is what a local relay wants |
| password | SMTP_PASSWORD | |
| secure | SMTP_SECURE | false. TLS from the first byte, which is port 465; 587 upgrades with STARTTLS on its own |
| timeout | SMTP_TIMEOUT_MS | 10000. How long the relay has to connect, to greet and to answer on the socket. nodemailer's own defaults for those are two minutes, thirty seconds and ten minutes |
| driver | MAIL_DRIVER | smtp, and memory in a test run |
| pool | SMTP_POOL | false. Connections held open between messages |
| maxConnections | SMTP_MAX_CONNECTIONS | 5, with a pool |
| maxMessages | SMTP_MAX_MESSAGES | 100 per connection, with a pool |
| rejectUnauthorized | SMTP_REJECT_UNAUTHORIZED | true. Whether the relay's certificate has to verify |
| name | SMTP_CLIENT_NAME | what this server calls itself in EHLO |
| replyTo | | on every message that does not name one |
| headers | | under whatever a message declares |
Passed to mailer() or read from the environment, field at a time, so the
sender can be declared and the password left where secrets live.
driver: "memory" is a deployment that captures its mail rather than
sending it: every message goes to listSentMail() and no relay is reached.
That is what a test run already does, and MAIL_DRIVER is how a staging box
gets it without a code change.
pool is for a relay that charges for connections or rate limits them;
without one each message opens and closes its own, which is what a local relay
and most hosted ones want. rejectUnauthorized: false is for an internal
relay with its own certificate authority and nothing else, which is why it is
a line somebody has to write.
A test run sends nothing
Every message is recorded instead, and listSentMail() is what a suite asserts
against:
await Mail.send({ to: "[email protected]", subject: "Welcome", text: "..." });
expect(listSentMail()[0]?.subject).toBe("Welcome");No host is needed for that, and none is reached. A suite cannot write to a
real address however it is configured, which is the same guarantee
@kilnjs/storage gives for files.
A reply has to thread
An answer to a message a contact sent needs the three headers their client
threads on, or it lands as a new thread and their reply to it cannot be matched
back by Message-ID:
await Mail.send({
to: contact.email,
subject: `Re: ${conversation.subject}`,
text: body,
messageId: `<${reply.id}@${ENV.INBOUND_DOMAIN}>`,
inReplyTo: incoming.messageId,
references: [...conversation.messageIds, incoming.messageId],
});headers carries anything else a sender needs. All of it reaches the relay and
is recorded by listSentMail(), so a suite asserts on threading without
driving nodemailer itself. Before this the type carried to, subject, text
and html and little else, so an application that had to thread bypassed this
module altogether.
readMailerSettings() is exported for the same reason: it is the one that
reads the environment, so something reusing the relay settings does not have to
rebuild them from options it does not have.
Awaiting it
Mail.send is awaited, so a handler that cannot continue without the mail
having gone can say so. A message nobody is waiting on belongs on a queue:
put it on one with @kilnjs/jobs and let the worker send it.
Mail.verify() asks the relay whether it answers and accepts the
credentials, for a health check. The connection pool is closed on shutdown.
A refusal carries the relay's own code. The error raised when a relay will
not take a message has responseCode in its context, with relayCode
(EENVELOPE, ESOCKET, ETIMEDOUT) and relayCommand beside it, so a sender
can tell a 450 worth trying again from a 550 that never will be. Without them a
bounce and a blip were the same error.
Development
yarn verify # ts:check, lint, build, exports:check, testThe tests reach no relay: what they cover is how the settings are read and that a test run records rather than sends.
What a transport refuses lives on the contract rather than in the SMTP transport, because a test run is served by the memory one: a rule only the real transport applied would be a rule no suite ever checks, and the first thing to apply it would be the relay in production.
