A real deployment could not send mail through an IP-authorized relay. Every attempt surfaced as MAIL_PROVIDER_UNAVAILABLE while the relay was healthy and swaks sent from the same machine. The cause was the greeting, and it is generic rather than tied to any provider. An SMTP client identifies itself in EHLO, and with nothing configured Nodemailer derives that from the machine hostname — substituting the address literal [127.0.0.1] whenever the hostname is not fully qualified, which is the normal state of a container. The relay closed the connection on it with a 421 before any message was offered, which is indistinguishable from the relay being down. EKKLESIA_SMTP_HELO_NAME now lets the deployment say what to greet as. It is validated as a fully qualified domain name — no scheme, port, brackets, whitespace, or control characters, and at least one dot, since a single unqualified label is exactly the input Nodemailer already discards. It is not a credential: it travels in the clear on every connection, so Doctor prints it where an operator can compare it against what the relay expects, and Doctor's probe performs the same greeting the runtime does. Absent preserves Nodemailer's behaviour exactly. It is deliberately not defaulted to the sender's domain: a machine whose hostname is already a valid FQDN may be greeting correctly, and some relays check that against reverse DNS, so replacing it silently would trade one deployment's bug for another's. The regression guard is a raw SMTP server that refuses any greeting but the expected one, because smtp-server exposes no EHLO hook and its earliest hook is never reached by a send-nothing probe. Co-Authored-By: Claude Opus 5 <[email protected]>
532 lines
21 KiB
TypeScript
532 lines
21 KiB
TypeScript
import type { AddressInfo, Socket } from "node:net";
|
|
import { createServer, type Server } from "node:net";
|
|
import { SMTPServer } from "smtp-server";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { createMailProviderConfig, type MailProviderConfig } from "../src/mail/config.js";
|
|
import { MailError, validateMailMessage } from "../src/mail/message.js";
|
|
import type { MailProvider } from "../src/mail/provider.js";
|
|
import { createSmtpMailProvider } from "../src/mail/smtp-provider.js";
|
|
|
|
/**
|
|
* The SMTP provider against a **real SMTP server** speaking the real protocol.
|
|
*
|
|
* The unit suite proves the contract with no provider at all; this proves the things only a server
|
|
* can. Every claim here is a property of the agreement between Nodemailer and an SMTP server rather
|
|
* than of code in this repository: that the host's sender really is the one on the wire, that a
|
|
* refused recipient is distinguishable from an outage, that a silent server eventually gives up
|
|
* rather than hanging forever, and that a STARTTLS transport refuses to continue in the clear.
|
|
*
|
|
* `smtp-server` is used rather than a mock because it is the reference implementation from the same
|
|
* project as the client, it runs in-process, and it needs no container — so this adds no CI
|
|
* infrastructure and sends no real email. **Nothing here reaches the internet**, and no live
|
|
* credential exists anywhere in this suite.
|
|
*/
|
|
interface CapturedMessage {
|
|
readonly from: string;
|
|
readonly to: readonly string[];
|
|
readonly data: string;
|
|
/** The argument this client actually sent in `EHLO`, lowercased by the server. */
|
|
readonly greetedAs: string;
|
|
}
|
|
|
|
interface Harness {
|
|
readonly port: number;
|
|
readonly captured: CapturedMessage[];
|
|
readonly close: () => Promise<void>;
|
|
}
|
|
|
|
const openHarnesses: Array<() => Promise<void>> = [];
|
|
const openProviders: MailProvider[] = [];
|
|
|
|
afterEach(async () => {
|
|
for (const provider of openProviders.splice(0)) {
|
|
await provider.close().catch(() => undefined);
|
|
}
|
|
for (const close of openHarnesses.splice(0)) {
|
|
await close().catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
interface HarnessOptions {
|
|
/** When set, the server demands AUTH and accepts only this exact pair. */
|
|
readonly auth?: { readonly username: string; readonly password: string };
|
|
/** Rejects exactly these recipients at RCPT TO, as a real relay does for an unknown mailbox. */
|
|
readonly rejectRecipients?: readonly string[];
|
|
/** Advertises STARTTLS. Off by default, so a `starttls` client must refuse to continue. */
|
|
readonly offerStartTls?: boolean;
|
|
/**
|
|
* Refuses any client whose `EHLO` argument is not exactly this name, as a relay that requires a
|
|
* fully qualified client identity does.
|
|
*
|
|
* The refusal is a `421`, the same class of response the real relay returned: a temporary failure
|
|
* that closes the connection before a message is offered.
|
|
*/
|
|
readonly requireGreeting?: string;
|
|
}
|
|
|
|
async function startSmtpServer(options: HarnessOptions = {}): Promise<Harness> {
|
|
const captured: CapturedMessage[] = [];
|
|
const server = new SMTPServer({
|
|
authOptional: options.auth === undefined,
|
|
// Deliberately no TLS unless a scenario asks: proving the client's downgrade guard needs a
|
|
// server that does not offer the upgrade.
|
|
hideSTARTTLS: options.offerStartTls !== true,
|
|
onAuth(auth, _session, callback) {
|
|
if (
|
|
options.auth !== undefined &&
|
|
auth.username === options.auth.username &&
|
|
auth.password === options.auth.password
|
|
) {
|
|
callback(null, { user: auth.username });
|
|
return;
|
|
}
|
|
callback(new Error("Invalid credentials"));
|
|
},
|
|
onData(stream, session, callback) {
|
|
let data = "";
|
|
stream.on("data", (chunk: Buffer) => {
|
|
data += chunk.toString("utf8");
|
|
});
|
|
stream.on("end", () => {
|
|
captured.push({
|
|
data,
|
|
from: session.envelope.mailFrom === false ? "" : session.envelope.mailFrom.address,
|
|
greetedAs: session.hostNameAppearsAs,
|
|
to: session.envelope.rcptTo.map((entry) => entry.address),
|
|
});
|
|
callback();
|
|
});
|
|
},
|
|
onMailFrom(_address, session, callback) {
|
|
if (
|
|
options.requireGreeting !== undefined &&
|
|
session.hostNameAppearsAs !== options.requireGreeting.toLowerCase()
|
|
) {
|
|
const error = new Error("Try again later, closing connection.") as Error & {
|
|
responseCode?: number;
|
|
};
|
|
error.responseCode = 421;
|
|
callback(error);
|
|
return;
|
|
}
|
|
callback();
|
|
},
|
|
onRcptTo(address, _session, callback) {
|
|
if (options.rejectRecipients?.includes(address.address)) {
|
|
// 550 is a permanent refusal of this mailbox, which is what a real relay returns.
|
|
const error = new Error("Mailbox unavailable") as Error & { responseCode?: number };
|
|
error.responseCode = 550;
|
|
callback(error);
|
|
return;
|
|
}
|
|
callback();
|
|
},
|
|
});
|
|
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.server.address() as AddressInfo;
|
|
const close = async (): Promise<void> => {
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
};
|
|
openHarnesses.push(close);
|
|
return { captured, close, port: address.port };
|
|
}
|
|
|
|
/**
|
|
* A relay that refuses the greeting itself, byte for byte as the real one did.
|
|
*
|
|
* `smtp-server` exposes no `EHLO` hook — its earliest usable one is `MAIL FROM`, which
|
|
* `transporter.verify()` never reaches — so modelling this failure at the point it actually happens
|
|
* needs a socket that speaks the handful of SMTP verbs involved. That matters: the whole reason the
|
|
* defect was hard to diagnose is that it aborts the session *before* a message is offered, which is
|
|
* also why Doctor's send-nothing probe can see it.
|
|
*/
|
|
async function startGreetingStrictRelay(expected: string): Promise<Harness> {
|
|
const sockets = new Set<Socket>();
|
|
const server = createServer((socket) => {
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
socket.on("error", () => undefined);
|
|
socket.write("220 relay.invalid ESMTP\r\n");
|
|
let buffer = "";
|
|
socket.on("data", (chunk: Buffer) => {
|
|
buffer += chunk.toString("utf8");
|
|
let index = buffer.indexOf("\r\n");
|
|
while (index !== -1) {
|
|
const line = buffer.slice(0, index);
|
|
buffer = buffer.slice(index + 2);
|
|
const [verb = "", argument = ""] = line.split(" ");
|
|
if (verb.toUpperCase() === "EHLO" || verb.toUpperCase() === "HELO") {
|
|
if (argument.toLowerCase() !== expected.toLowerCase()) {
|
|
// The exact shape the deployment saw: a 421 naming no reason, then the door shut.
|
|
socket.write("421-4.7.0 Try again later, closing connection. (EHLO)\r\n");
|
|
socket.write("421 4.7.0 Goodbye\r\n");
|
|
socket.end();
|
|
return;
|
|
}
|
|
socket.write("250-relay.invalid\r\n250 OK\r\n");
|
|
} else if (verb.toUpperCase() === "QUIT") {
|
|
socket.write("221 Bye\r\n");
|
|
socket.end();
|
|
return;
|
|
} else {
|
|
socket.write("250 OK\r\n");
|
|
}
|
|
index = buffer.indexOf("\r\n");
|
|
}
|
|
});
|
|
});
|
|
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address() as AddressInfo;
|
|
const close = async (): Promise<void> => {
|
|
// `close()` waits for open sockets, and a refused client may still be holding one.
|
|
for (const socket of sockets) {
|
|
socket.destroy();
|
|
}
|
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
};
|
|
openHarnesses.push(close);
|
|
return { captured: [], close, port: address.port };
|
|
}
|
|
|
|
function config(port: number, overrides: Record<string, unknown> = {}): MailProviderConfig {
|
|
const built = createMailProviderConfig(
|
|
{
|
|
fromAddress: "[email protected]",
|
|
fromName: "Ekklesia",
|
|
host: "127.0.0.1",
|
|
port,
|
|
// The local test server speaks no TLS. Production configuration refuses this mode outright,
|
|
// which is exactly why the development environment has to be selected explicitly here.
|
|
security: "plaintext",
|
|
...overrides,
|
|
},
|
|
{ environment: "development" },
|
|
);
|
|
if (!built) {
|
|
throw new Error("The SMTP integration configuration is invalid.");
|
|
}
|
|
return built;
|
|
}
|
|
|
|
function provider(providerConfig: MailProviderConfig, bounds = {}): MailProvider {
|
|
const created = createSmtpMailProvider(providerConfig, bounds);
|
|
openProviders.push(created);
|
|
return created;
|
|
}
|
|
|
|
describe.sequential("live SMTP transport", () => {
|
|
it("delivers a transactional message with the host's sender, subject, and both bodies", async () => {
|
|
const harness = await startSmtpServer();
|
|
const mail = provider(config(harness.port));
|
|
|
|
const result = await mail.send(
|
|
validateMailMessage({
|
|
html: "<p>Open the link to continue.</p>",
|
|
subject: "Verify your email address",
|
|
text: "Open the link to continue.",
|
|
to: "[email protected]",
|
|
}),
|
|
);
|
|
|
|
expect(harness.captured).toHaveLength(1);
|
|
const message = harness.captured[0];
|
|
expect(message?.to).toEqual(["[email protected]"]);
|
|
// The envelope sender is the host's configured address — the caller never chose it and has no
|
|
// field in which to.
|
|
expect(message?.from).toBe("[email protected]");
|
|
expect(message?.data).toContain("From: Ekklesia <[email protected]>");
|
|
expect(message?.data).toContain("Subject: Verify your email address");
|
|
expect(message?.data).toContain("Open the link to continue.");
|
|
expect(message?.data).toContain("<p>Open the link to continue.</p>");
|
|
// A provider message id is the only thing that comes back, and it names no recipient.
|
|
expect(result.messageId).toMatch(/@/u);
|
|
expect(result.messageId ?? "").not.toContain("[email protected]");
|
|
});
|
|
|
|
it("sends to a small bounded list, and to exactly the addresses given", async () => {
|
|
const harness = await startSmtpServer();
|
|
const mail = provider(config(harness.port));
|
|
|
|
await mail.send(
|
|
validateMailMessage({
|
|
subject: "Invitation",
|
|
text: "You are invited.",
|
|
to: ["[email protected]", "[email protected]"],
|
|
}),
|
|
);
|
|
|
|
expect(harness.captured[0]?.to).toEqual(["[email protected]", "[email protected]"]);
|
|
// No silent extra recipient: nothing in this platform adds a Bcc.
|
|
expect(harness.captured[0]?.data).not.toMatch(/^Bcc:/imu);
|
|
});
|
|
|
|
it("carries a reply-to without letting it become the sender", async () => {
|
|
const harness = await startSmtpServer();
|
|
const mail = provider(config(harness.port));
|
|
|
|
await mail.send(
|
|
validateMailMessage({
|
|
replyTo: "[email protected]",
|
|
subject: "Notice",
|
|
text: "Body.",
|
|
to: "[email protected]",
|
|
}),
|
|
);
|
|
|
|
const data = harness.captured[0]?.data ?? "";
|
|
expect(data).toContain("Reply-To: [email protected]");
|
|
expect(data).toContain("From: Ekklesia <[email protected]>");
|
|
expect(harness.captured[0]?.from).toBe("[email protected]");
|
|
});
|
|
|
|
it("authenticates when the relay demands it, and reports a refusal as rejected", async () => {
|
|
const harness = await startSmtpServer({
|
|
auth: { password: "relay-password", username: "[email protected]" },
|
|
});
|
|
|
|
const authenticated = provider(
|
|
config(harness.port, { password: "relay-password", username: "[email protected]" }),
|
|
);
|
|
await expect(
|
|
authenticated.send(
|
|
validateMailMessage({ subject: "Authenticated", text: "Body.", to: "[email protected]" }),
|
|
),
|
|
).resolves.toMatchObject({});
|
|
expect(harness.captured).toHaveLength(1);
|
|
|
|
const wrong = provider(
|
|
config(harness.port, { password: "wrong-password", username: "[email protected]" }),
|
|
);
|
|
let caught: unknown;
|
|
try {
|
|
await wrong.send(
|
|
validateMailMessage({ subject: "Rejected", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toBeInstanceOf(MailError);
|
|
expect((caught as MailError).code).toBe("MAIL_REJECTED");
|
|
// The server's response text — which can name the account — never reaches the caller.
|
|
const rendered = `${String(caught)} ${JSON.stringify(caught)} ${(caught as Error).stack ?? ""}`;
|
|
expect(rendered).not.toContain("wrong-password");
|
|
expect(rendered).not.toContain("[email protected]");
|
|
expect(rendered).not.toContain("Invalid credentials");
|
|
// And nothing was delivered.
|
|
expect(harness.captured).toHaveLength(1);
|
|
});
|
|
|
|
it("distinguishes a refused mailbox from an outage", async () => {
|
|
const harness = await startSmtpServer({ rejectRecipients: ["[email protected]"] });
|
|
const mail = provider(config(harness.port));
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await mail.send(
|
|
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
// The one failure a human can usually act on keeps its own code.
|
|
expect((caught as MailError).code).toBe("MAIL_RECIPIENT_INVALID");
|
|
expect(harness.captured).toHaveLength(0);
|
|
});
|
|
|
|
it("reports a refused connection as provider-unavailable rather than hanging", async () => {
|
|
// A port nothing listens on: a real connection failure rather than a simulated one.
|
|
const mail = provider(config(1));
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await mail.send(
|
|
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
expect(caught).toBeInstanceOf(MailError);
|
|
expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE");
|
|
});
|
|
|
|
it("gives up on a server that accepts the connection and then never greets", async () => {
|
|
// A refused connection fails fast on its own; a server that *answers* and then stalls is the
|
|
// failure with no natural end, and the one that has to be bounded deliberately. This is the
|
|
// lesson the object-storage pass learned the hard way.
|
|
const sockets = new Set<import("node:net").Socket>();
|
|
const blackHole: Server = createServer((socket) => {
|
|
sockets.add(socket);
|
|
socket.on("close", () => sockets.delete(socket));
|
|
});
|
|
await new Promise<void>((resolve) => blackHole.listen(0, "127.0.0.1", resolve));
|
|
const port = (blackHole.address() as AddressInfo).port;
|
|
|
|
const mail = provider(config(port), {
|
|
// The production defaults are deliberately patient; this proves the mechanism, not the number.
|
|
connectionTimeoutMs: 300,
|
|
greetingTimeoutMs: 300,
|
|
socketTimeoutMs: 300,
|
|
});
|
|
|
|
const startedAt = Date.now();
|
|
let caught: unknown;
|
|
try {
|
|
await mail.send(
|
|
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
const elapsed = Date.now() - startedAt;
|
|
|
|
expect((caught as MailError).code).toBe("MAIL_PROVIDER_UNAVAILABLE");
|
|
// The point of the assertion is that it *ended*. Without a deadline Nodemailer waits minutes,
|
|
// and a person waiting on a password-reset email waits with it.
|
|
expect(elapsed).toBeLessThan(5_000);
|
|
|
|
for (const socket of sockets) {
|
|
socket.destroy();
|
|
}
|
|
await new Promise<void>((resolve) => blackHole.close(() => resolve()));
|
|
});
|
|
|
|
it("refuses to continue in the clear when STARTTLS was required but is not offered", async () => {
|
|
const harness = await startSmtpServer({ offerStartTls: false });
|
|
const mail = provider(config(harness.port, { security: "starttls" }));
|
|
|
|
let caught: unknown;
|
|
try {
|
|
await mail.send(
|
|
validateMailMessage({ subject: "Notice", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
} catch (error) {
|
|
caught = error;
|
|
}
|
|
// The downgrade guard. A server that stops advertising STARTTLS — or an attacker who strips it
|
|
// — produces a failed send, never a plaintext session carrying a password.
|
|
expect(caught).toBeInstanceOf(MailError);
|
|
expect(harness.captured).toHaveLength(0);
|
|
});
|
|
|
|
it("verifies connectivity without sending anything", async () => {
|
|
const harness = await startSmtpServer();
|
|
const mail = provider(config(harness.port));
|
|
|
|
await expect(mail.verify()).resolves.toBeUndefined();
|
|
// The assertion Doctor depends on: a diagnostic that emailed somebody every time an operator ran
|
|
// it would be its own incident.
|
|
expect(harness.captured).toHaveLength(0);
|
|
|
|
const unreachable = provider(config(1));
|
|
await expect(unreachable.verify()).rejects.toMatchObject({
|
|
code: "MAIL_PROVIDER_UNAVAILABLE",
|
|
});
|
|
});
|
|
|
|
it("sends exactly once, with no retry behind the caller's back", async () => {
|
|
const harness = await startSmtpServer();
|
|
const mail = provider(config(harness.port));
|
|
|
|
await mail.send(
|
|
validateMailMessage({ subject: "Once", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
|
|
// One call, one message. Retrying an ambiguous send is how one password-reset request becomes
|
|
// three emails, so nothing here retries (ADR 0037).
|
|
expect(harness.captured).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("SMTP client identity on the wire", () => {
|
|
/**
|
|
* The regression guard for a real production defect.
|
|
*
|
|
* A deployment relaying through an IP-authorized relay could not send at all: every attempt
|
|
* surfaced as `MAIL_PROVIDER_UNAVAILABLE`. The relay was healthy — `swaks` sent from the same
|
|
* machine — and the cause was the greeting. An SMTP client identifies itself in `EHLO`, and with
|
|
* nothing configured Nodemailer derives that from the machine hostname, substituting the address
|
|
* literal `[127.0.0.1]` whenever the hostname is not fully qualified. The relay closed the
|
|
* connection on it with a `421`.
|
|
*
|
|
* These tests are written against the wire rather than against the transport options, because the
|
|
* defect was invisible in the options: everything about the configuration looked correct.
|
|
*/
|
|
it("greets with the configured name, exactly as configured", async () => {
|
|
const harness = await startSmtpServer();
|
|
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
|
|
|
await mail.send(
|
|
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
|
|
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
|
|
});
|
|
|
|
it("never greets as the address literal when a name is configured", async () => {
|
|
const harness = await startSmtpServer();
|
|
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
|
|
|
await mail.send(
|
|
validateMailMessage({ subject: "Greeting", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
|
|
// The exact greeting the relay refused.
|
|
expect(harness.captured[0]?.greetedAs).not.toBe("[127.0.0.1]");
|
|
});
|
|
|
|
it("delivers to a relay that demands a fully qualified greeting", async () => {
|
|
const harness = await startSmtpServer({ requireGreeting: "mail.example.org" });
|
|
const mail = provider(config(harness.port, { heloName: "mail.example.org" }));
|
|
|
|
await mail.send(
|
|
validateMailMessage({ subject: "Accepted", text: "Body.", to: "[email protected]" }),
|
|
);
|
|
|
|
expect(harness.captured).toHaveLength(1);
|
|
expect(harness.captured[0]?.greetedAs).toBe("mail.example.org");
|
|
});
|
|
|
|
it("is refused by that same relay when no name is configured", async () => {
|
|
const harness = await startGreetingStrictRelay("mail.example.org");
|
|
const mail = provider(config(harness.port));
|
|
|
|
// Deterministic regardless of what this machine is called: the server accepts exactly one name,
|
|
// and Nodemailer's derivation cannot produce `mail.example.org` on any host.
|
|
await expect(
|
|
mail.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" })),
|
|
).rejects.toMatchObject({ code: "MAIL_PROVIDER_UNAVAILABLE" });
|
|
expect(harness.captured).toHaveLength(0);
|
|
});
|
|
|
|
it("carries the refused greeting into no error a caller can see", async () => {
|
|
const harness = await startGreetingStrictRelay("mail.example.org");
|
|
const mail = provider(config(harness.port));
|
|
|
|
const failure = await mail
|
|
.send(validateMailMessage({ subject: "Refused", text: "Body.", to: "[email protected]" }))
|
|
.catch((error: unknown) => error);
|
|
|
|
// A `421` is SMTP's "try later", so it stays `MAIL_PROVIDER_UNAVAILABLE` rather than becoming a
|
|
// permanent rejection — and the server's response text never reaches the caller.
|
|
const serialized = `${(failure as Error).message} ${(failure as Error).stack ?? ""}`;
|
|
expect(serialized).not.toContain("Try again later");
|
|
expect(serialized).not.toContain("closing connection");
|
|
});
|
|
|
|
it("still verifies through the same greeting Doctor's probe will use", async () => {
|
|
const harness = await startGreetingStrictRelay("mail.example.org");
|
|
const configured = provider(config(harness.port, { heloName: "mail.example.org" }));
|
|
const unconfigured = provider(config(harness.port));
|
|
|
|
// Doctor builds its provider from the identical validated configuration, so an operator whose
|
|
// relay refuses the greeting sees it in `pnpm doctor` rather than only when a reset email
|
|
// silently fails to arrive.
|
|
await expect(configured.verify()).resolves.toBeUndefined();
|
|
await expect(unconfigured.verify()).rejects.toMatchObject({
|
|
code: "MAIL_PROVIDER_UNAVAILABLE",
|
|
});
|
|
});
|
|
});
|