@bobfrankston/smtp-direct
v0.1.8
Published
Direct SMTP client — transport-agnostic, no Node.js dependencies, browser-ready
Maintainers
Readme
@bobfrankston/smtp-direct
Direct SMTP client. Transport-agnostic, no Node.js dependencies, browser-ready.
Sibling to @bobfrankston/iflow-direct (IMAP). They share the TCP transport interface (ImapTransport / TransportFactory) but the protocols themselves have nothing in common, so they remain separate packages.
Why
The Node.js SMTP libraries (nodemailer, smtp-connection) hard-depend on node:net/node:tls and won't run in a WebView, browser, or any non-Node host. smtp-direct is a clean-room ~250-line implementation that takes a TCP byte-stream from the caller and speaks SMTP over it. The same code runs unmodified on:
- Desktop / Node — inject
NodeTransportfrom@bobfrankston/iflow-node. - Android (MAUI WebView) / browser — inject
BridgeTransportfrom@bobfrankston/iflow-direct(talks to the native shell'smsgapi.tcp).
This means mailx-desktop, mailx-android, the resender server, and any future msgapi.tcp-capable host can share one SMTP code path.
What it implements
- RFC 5321 — SMTP (EHLO, MAIL, RCPT, DATA, QUIT, RSET)
- RFC 3207 — STARTTLS upgrade on port 587
- Implicit TLS on port 465
- RFC 4954 + 4616 — AUTH PLAIN
- AUTH LOGIN (legacy, two-step base64 prompt)
- RFC 4752 — AUTH XOAUTH2 (Google's bearer-token variant; same mechanism Gmail/Outlook use)
- Multi-line server replies (
NNN-...continuation, finalNNN ...) - Per-recipient RCPT errors (partial accept = success)
- Dot-stuffing per RFC 5321 §4.5.2
- SIZE extension hint when the server advertises it
What it doesn't do (by design)
- MIME assembly. Caller passes a complete RFC 2822 message;
smtp-directonly handles the wire protocol. - Connection pooling / keep-alive. One client = one connection. For batch sends, reuse the client across
sendMail()calls beforequit(). - Retry / queue logic. Caller's outbox owns retry policy.
- Raw TCP / TLS. Delegated to the injected
Transport.
Install
{
"dependencies": {
"@bobfrankston/smtp-direct": "file:../path/to/smtp-direct",
"@bobfrankston/iflow-direct": "file:../path/to/iflow-direct",
"@bobfrankston/iflow-node": "file:../path/to/iflow-node" // desktop only
}
}Quick start — desktop
import { SmtpClient } from "@bobfrankston/smtp-direct";
import { NodeTransport } from "@bobfrankston/iflow-node";
const smtp = new SmtpClient({
host: "smtp.gmail.com",
port: 587, // STARTTLS submission
auth: { method: "XOAUTH2", user: "[email protected]", token: bearerToken },
localname: "mybox.example.com",
verbose: true,
}, () => new NodeTransport());
await smtp.connect();
const result = await smtp.sendMail(
{ from: "[email protected]", to: ["[email protected]", "[email protected]"] },
rawRfc822Message,
);
console.log(`accepted=${result.accepted.length} rejected=${result.rejected.length}`);
await smtp.quit();Quick start — Android / WebView
import { SmtpClient } from "@bobfrankston/smtp-direct";
import { BridgeTransport } from "@bobfrankston/iflow-direct";
const smtp = new SmtpClient({
host: "smtp.example.com", port: 587,
auth: { method: "PLAIN", user: "me", pass: "secret" },
}, () => new BridgeTransport());
await smtp.connect();
await smtp.sendMail({ from: "[email protected]", to: ["[email protected]"] }, raw);
await smtp.quit();The WebView host (msger / msga / Android MAUI shell) must expose msgapi.tcp — BridgeTransport calls msgapi.tcp.connect, msgapi.tcp.upgradeTLS, etc. All of msger's hosts already do this.
Config
interface SmtpClientConfig {
host: string;
port: number; // 587 (STARTTLS), 465 (implicit TLS), 25 (relay)
secure?: boolean; // implicit TLS at connect. Default: port===465
auth?: SmtpAuth; // omit for relay / unauthenticated server-to-server
localname?: string; // EHLO name. Default: "localhost"
timeoutMs?: number; // per-command inactivity. Default: 30000
verbose?: boolean; // log SMTP traffic (auth lines redacted)
}
type SmtpAuth =
| { method: "PLAIN"; user: string; pass: string }
| { method: "LOGIN"; user: string; pass: string }
| { method: "XOAUTH2"; user: string; token: string };sendMail result
interface SmtpSendResult {
accepted: string[];
rejected: { address: string; code: number; message: string }[];
response: string; // server's final 250 OK after DATA
}sendMail resolves whenever at least one recipient was accepted. Per-RCPT errors are reported in result.rejected. Only when all recipients are rejected does sendMail throw; the connection is RSET so it can be reused for the next send.
A MAIL FROM rejection or a final DATA rejection always throws.
Reusing a connection
sendMail can be called repeatedly between connect() and quit():
await smtp.connect();
for (const msg of queue) {
try {
await smtp.sendMail(msg.envelope, msg.raw);
} catch (e) {
console.error(`send failed: ${e}`);
// connection still usable — RSET was sent on partial-failure path
}
}
await smtp.quit();For long sessions, watch the server's idle policy — most servers drop idle SMTP connections after 5 minutes.
Server compatibility tested against
- Gmail (smtp.gmail.com:587, XOAUTH2) — primary target
- Office 365 / Outlook (smtp.office365.com:587, XOAUTH2 or PLAIN) — works
- Generic Dovecot+Postfix (any:587, PLAIN/LOGIN with STARTTLS) — works
- Implicit TLS submission (any:465) — works
Caveats
- One command at a time per connection. SMTP is synchronous; pipelining isn't implemented.
- TLS certificate validation is the transport's job.
NodeTransport/BridgeTransportdecide whether to verify the server cert. - AUTH XOAUTH2 failure surfaces as a generic "AUTH XOAUTH2 failed". The server's base64 error JSON is consumed but not parsed.
- No SASL mechanism negotiation beyond what the caller specifies. If the server doesn't advertise the chosen
AUTH=METHOD, you'll get a 504 from the server rather than a pre-flight error fromsmtp-direct.
Layout
smtp-direct/
index.ts # public exports
smtp-client.ts # SmtpClient class, dotStuff helper
types.ts # SmtpClientConfig, SmtpAuth, SmtpSendResult, ...
package.json
tsconfig.jsonLicense
ISC
