Repository navigation
mailer v1: the transport layer ported from upyo 0.7.0, with 71 upstream bugs fixed - #2
Merged
Merged
Conversation
The transport layer is about to be ported from upyo 0.7.0, one subpath per upstream package. This sets up what every subpath needs before the first one lands: - LICENSE carries upyo's MIT notice, and CLAUDE.md the rules of the port (attribution header, upstream behavior kept, upstream lint suppressed on the line, describe/it kept in ported suites). - export-parity, bundle, size and check:dts read the exports map, so a subpath is checked as soon as it is declared; graph.test.js forbids require cycles and cross-subpath requires outside an allow-list. - src/version.js holds the name and version for the OpenTelemetry scope, tested against package.json and bumped by the release process. - .gitattributes keeps LF on Windows checkouts, where MIME and DKIM tests compare bytes; sideEffects is false, and tests may use console. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…yo 0.7.0 The root entry now exports @upyo/core: createMessage and the Message, Address and Attachment model, the Transport, RawTransport and VerifiableTransport contract, Receipt and its error classification, Message-ID and threading helpers, the iCalendar part and the raw-message plan. The modules live in src/core/, the types in index.d.ts, next to the createMailer placeholder. The code is upstream's, converted to CommonJS: the only changes are the fallbacks for runtimes without AbortSignal.any or Blob, which Node 22 always has. Upstream's suites are ported as they are; tests/unit/core.test.js covers the paths they leave out, and the root size budget goes to 7 KB. CLAUDE.md describes the ported architecture, revises the decisions on the transport and the providers, and lists the upstream bugs to fix after the port. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./mock exports MockTransport: a transport that keeps what it is sent in memory, with a configurable response, delay and failure rate, and helpers to find and wait for messages. It is upstream's code converted to CommonJS, with its types in src/mock/index.d.ts and its suites ported as they are; tests/unit/mock.test.js covers the setters, aborts and timeouts they leave out. The pool suites of a later commit use it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./mime exports composeMessage, which turns a Message into replayable MIME bytes with a separate envelope (Bcc stays out of the headers), optionally DKIM-signed with RSA-SHA256 or Ed25519 in buffered or streaming body mode, and MimeAttachmentReplayError. The SMTP transport of the next commit builds on its internal modules. The one runtime dependency of @upyo/mime, @noble/hashes, becomes node:crypto's createHash: the incremental SHA-256 of the body hash and of the replay check. Signing stays on WebCrypto, as upstream. Upstream reaches most of the MIME builder through the SMTP suites; the new tests/unit/mime.test.js covers it directly and reads every message back with postal-mime (a devDependency, as upstream's compose suite uses it). It found an upstream line-length bug, added to the list in CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./smtp exports SmtpTransport (send, sendRaw, sendMany, verify, async dispose) on node:net and node:tls: a connection pool, implicit TLS and STARTTLS (required when requireTls is set), AUTH PLAIN, LOGIN, XOAUTH2 and OAUTHBEARER with token refresh, PIPELINING, SIZE, DSN, SMTPUTF8 and 8BITMIME, dot-stuffing with backpressure, and DKIM through ./mime. Its errors and receipt helpers come with it; its d.ts re-exports the DKIM types. Upstream's suites run against the local mock SMTP server; the Mailpit and OAuth e2e suites move to tests/mailpit and tests/e2e behind the new test:mailpit and test:e2e scripts, with Mailpit's compose file and the test certificate (now in tests/fixtures/smtp). Changes to the tests, not to the transport: - the mock server no longer turns a client's connection reset into an unhandled 'error' event: macOS and Windows send an RST where Linux, which upstream's CI runs on, sends a FIN, and three upstream tests fail there; - the transport suite dials a released local port instead of 1025 (Mailpit's port) and 9999; - a receiver check allows the globalThis of sloppy-mode CommonJS. tests/unit/smtp.test.js covers what upstream reached only through Mailpit: broken connections, AUTH LOGIN rejections, a rejected DATA and the DATA writer's guards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./ses exports SesTransport: SES v2 SendEmail over fetch with a SigV4 signature on WebCrypto, static or session credentials, configuration sets and default tags, retries with exponential backoff for 5xx and network errors, a request timeout, and sendMany in concurrent batches. Its types are SesConfig and SesAuthentication. Upstream's unit suites are ported; the e2e suite (real credentials) goes to tests/e2e. Its test of a runtime without AbortSignal.any is not ported: Node 22 always has it, and the core keeps no fallback. tests/unit/ses.test.js covers what upstream reached only end to end: session credentials, HTTP and network failures, timeouts, an abort between retries and attachment defaults. The dead convertMessagesToBulk of upstream is excluded from the coverage, as listed in CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./resend exports ResendTransport: the Resend REST API over fetch, with attachments, tags, threading and custom headers, an idempotency key per message, retries and a request timeout, and sendMany through the batch API (in chunks of 100) when no message has attachments, tags or a calendar part. ResendApiError stays a type only, as upstream exports it. Upstream's unit suites are ported, the e2e suite goes to tests/e2e, and its two tests of a runtime without AbortSignal.any are left out (Node 22 always has it). tests/unit/resend.test.js covers what upstream reached only end to end: a 200 that is not JSON, the timeout, chunked and failed batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./sendgrid exports SendGridTransport and SendGridApiError: the v3 Mail Send API over fetch, with attachments, categories, priority headers, click and open tracking settings, retries and a request timeout; a 202 is a success, identified by X-Message-Id or a generated id. Upstream's unit suites are ported, the e2e suite goes to tests/e2e, and its test of a runtime without AbortSignal.any is left out. Upstream checked even a successful send only end to end; tests/unit/sendgrid.test.js covers it, sendMany, error bodies, retries, the timeout and an abort between retries. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./mailgun exports MailgunTransport and MailgunApiError: the Messages API over fetch with multipart form data, US and EU regions, attachments and inline parts, tags, tracking options, retries and a request timeout. Upstream's unit suites are ported and the e2e suite goes to tests/e2e. tests/unit/mailgun.test.js covers an unknown region, custom headers, a 2xx without JSON and sendMany over an async iterable. The inline bug of upstream (sent under the filename, not the content id) is kept and listed in CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./mailtrap exports MailtrapTransport, createMailtrapConfig and the MailtrapApiError, MailtrapTimeoutError and MailtrapResponseError classes: the Email Sending and Sandbox APIs over fetch, the batch API for sendMany, categories, custom variables, retries on 408, 429 and 5xx with jitter and Retry-After, a request timeout, and no retry once a 2xx body could not be read. The User-Agent becomes @alexify/mailer/mailtrap. Upstream's unit suites are ported and the e2e suite goes to tests/e2e. tests/unit/mailtrap.test.js covers what they leave out: inline attachments, id-less and unsuccessful responses, the timeout, Retry-After dates, an abort while waiting to retry, and batches that fail whole or in part. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./maileroo exports MailerooTransport, createMailerooConfig, MailerooApiError and MailerooTimeoutError: the Maileroo v2 sending API over fetch, with a reference id per message reused across retries, attachments, tags and tracking, retries on 408, 429 and 5xx with jitter and Retry-After, and a request timeout. Upstream's unit suites are ported, the e2e suite goes to tests/e2e, and its test of a runtime without AbortSignal.any is left out. tests/unit/maileroo.test.js covers the error bodies and the unsuccessful 200 they leave out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./lettermint exports LettermintTransport, createLettermintConfig, LettermintApiError and LettermintTimeoutError: the Lettermint send and batch APIs over fetch, with routes, tags, metadata and tracking settings, an idempotency key per message, retries on 408, 429 and 5xx with jitter and Retry-After, and a request timeout. Upstream's unit suites are ported, the e2e suite goes to tests/e2e, and its test of a runtime without AbortSignal.any is left out. tests/unit/lettermint.test.js covers a 200 without JSON, the error bodies, the timeout, an abort while waiting to retry, and a batch with nothing left to send. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./plunk exports PlunkTransport: the Plunk send API over fetch (hosted or self-hosted through baseUrl), with attachments, retries with exponential backoff for 5xx and network errors, and a request timeout. Its types are PlunkConfig, ResolvedPlunkConfig, PlunkResponse and PlunkError. The base64 fallback for runtimes without btoa is dropped (Node always has it), like the core's fallbacks. Upstream's unit suites are ported, the e2e suite goes to tests/e2e, and its test of a runtime without AbortSignal.any is left out. tests/unit/plunk.test.js covers malformed response bodies, an unreadable error body, the timeout, a retried 5xx and a dropped attachment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./jmap exports JmapTransport (send, sendRaw, sendMany, verify) for RFC 8620 and 8621 servers over fetch: session discovery with a cache, bearer or basic authentication, drafts mailbox and identity lookup, blob uploads for attachments and streamed raw messages, Email/set plus EmailSubmission/set, and batched sendMany. createJmapConfig, JmapApiError, JMAP_ERROR_TYPES, isCapabilityError and uploadBlob come with it. Its AbortSignal.any fallback is dropped, like the core's. Upstream's unit and local-server suites are ported; its e2e suite against Stalwart goes to tests/e2e, with the compose file and the setup script in tests/e2e/jmap. On Node 24, fetch answers a 401 to a streamed body with a network error instead of the response; the seven tests of that are todo on Node 24 (they pass on 22), and the bug is listed in CLAUDE.md. tests/unit/jmap.test.js covers what upstream reached only end to end: account, mailbox and identity discovery failures, rejected creations and submissions, a foreign abort and verification of a session without the submission capability. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./retry exports RetryTransport and createRetryTransport: a transport that wraps another one and retries a failed send with exponential backoff, full or no jitter and Retry-After, under an attempt limit; a custom shouldRetry and wait can be passed, a caller's abort is never retried, and sendMany keeps the input order under a concurrency limit and a launch interval. Upstream's suite is ported; its iterator accesses split over lines are rewritten through a variable (no-unexpected-multiline), the behavior is the same. tests/unit/retry.test.js covers the default wait, the validation of the options and receipts without a retryable flag. Every d.ts that uses AsyncDisposable now references esnext.disposable on its own, and the subpath d.ts files import types only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./pool exports PoolTransport and the RoundRobinStrategy, WeightedStrategy, PriorityStrategy and SelectorStrategy classes: a transport over several others that picks one per message by the strategy (or a strategy object of the application), fails over to another on a failed send up to maxRetries, and applies an optional per-attempt timeout. Upstream's suites are ported; they use ./mock. tests/unit/pool.test.js covers weights that leave nothing to pick, aborts before a send, a timeout without a caller signal and a dispose that throws. The unused continueOnSuccess of upstream is kept and listed in CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./otel exports OpenTelemetryTransport, createOpenTelemetryTransport, createEmailAttributeExtractor, createErrorClassifier and defaultErrorClassifier: a transport that wraps another one with a client span per send or batch, events and statuses, and counters and histograms of attempts, messages, durations, sizes and active sends, under sampling rates. The OpenTelemetry API is passed in, never required: tracerProvider and meterProvider come from the application (required while tracing or metrics are on), the fallback to the global providers of @opentelemetry/api is gone, SpanKind and SpanStatusCode become their numeric values, and the tracer and meter are named from src/version.js. The d.ts declares the providers as structural types; a type test checks that the real ones fit. The upyo.* span attributes become mailer.*, and the metric prefix mailer. Upstream's suites are ported; the integration suite runs on the real OpenTelemetry SDK 1.x (devDependencies). tests/unit/otel.test.js covers sampled-out sends, the transport name and network attributes guessed from a config, custom batch attributes and failed batches; the dead recordRetry and extractNetworkAttributes of upstream are excluded from the coverage. Also: pool's d.ts now references esnext.disposable itself, and a test keeps every d.ts that uses AsyncDisposable doing so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./logger exports LoggerTransport: the delivery lifecycle of each message (email.sending, email.sent, email.failed, with counts, duration, receipt and errors) logged through a logger the application passes in, around a wrapped transport or alone (log-only, with synthetic receipts). It is @upyo/logtape's transport with the logger injected instead of LogTape's getLogger(category): - any pino-like logger fits, called as logger[level](properties, message); pino's level names (warn, not warning), checked when the transport is made; - a thrown error goes under err, the key pino serializes; - the inline mode builds its text itself, as pino interpolates no template; - the category option is gone (pass logger.child() instead), and the log-only receipts are logger-<uuid>. The pending queue and the sendMany logic are upstream's. Upstream's suite is ported onto a recording pino-like logger in place of LogTape's recorder; tests/unit/logger.test.js checks the validation and a real pino logger (a devDependency), and the type tests check that pino fits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The messages (compose, attachments, calendar, MIME) and transports pages of upyo are adapted for the port: examples in CommonJS on @alexify/mailer/<subpath>, one install block for the one package, no Deno, Bun, JSR or edge-runtime claims, and upyo's version notes dropped. The OpenTelemetry page passes the providers in, and the logger page is new (a pino-like logger in place of LogTape). Getting Started lists the transports and decorators, the API page every entry point, and one sidebar covers the guide, the messages and the transports. README, llms.txt and CHANGELOG describe the ported transport layer and credit upyo; CLAUDE.md gets the opt-in test scripts, the platform-dependent tests, the coverage and lint policies of the port, the docs layout and the Phase 1 progress, and the npm-publish skill the per-subpath size budgets. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createMailer only threw "createMailer is not implemented yet": it held the name of the root entry while nothing else was exported. The root entry now exports the ported message model and transport contract, so the placeholder, its Mailer type and tests go. It was never released, so nothing depends on it; the reliability layer gets its own API when it is designed. The bundle test of the root entry now builds a message through the bundle, and the README, the guide, the API page, llms.txt, CHANGELOG and CLAUDE.md no longer mention the placeholder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The home page still described a library being designed, with tiles of what it would do and a warning that the package did none of it yet. The tiles now show what is here: the SMTP client, MIME and DKIM, the eight HTTP providers and JMAP, the decorators and the zero-dependency design, with one tile for the reliability layer as the next step. The hero tagline follows, a Transports action replaces the Why link (the last tile leads there), the warning becomes a status note crediting upyo, and a short example shows a message sent through SMTP with retries. The why page no longer says the transport starts with SES alone, signed on node:crypto, or that the library starts with few providers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
isEmailAddress toggled quotes over the whole address and validated the domain with URL.canParse alone, which reads "@", ":", "/", "?" and "#" as other parts of a URL. A quote in the domain hid a second "@" from the scan, so `user@example.com> NOTIFY=NEVER ORCPT=rfc822;a"@x.com` passed createMessage and the SMTP envelope and was written as `RCPT TO:<user@example.com> NOTIFY=NEVER ...>`, injecting ESMTP parameters. Only a quoted local part may now hold an "@", the domain is held to letters, digits, "-", "." and non-ASCII characters with no empty label, and a domain literal must be an IPv4 address or an IPv6 one (tagged "IPv6:" or not, no zone index). As defense in depth, ./smtp re-checks each path right before it writes MAIL FROM and RCPT TO (checkSmtpPath). An upstream bug, recorded as the first of the "fixed after the port" list in CLAUDE.md. The ported test that delivered a sender with a port now expects the envelope to refuse it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The MIME builder and formatAddress wrote a printable display name as it was, ./ses wrote it in quotes without escaping, and ./mailgun and ./resend escaped `"` but not `\`. A name such as `Support <attacker@evil.com>, Real` therefore put a second address into Reply-To, and `x\", eve@evil.example, "` broke out of the provider's quoted string. src/core/address.js now formats every mailbox: a name of atoms goes as it is, anything else (or text that reads as an encoded word) as a quoted string with `\` and `"` escaped (formatMailbox, formatQuotedMailbox for the providers whose output was always quoted, formatPhrase for the MIME builder, which keeps encoded words for non-ASCII names). createMessage refuses a line break in a name, the formatters a control character, and parseAddress reads a quoted name holding "<" or "," back, so it inverts formatAddress. An upstream bug, recorded in CLAUDE.md. The fixes of this phase add code to the root entry, so its CI size budget goes from 7 to 8 KB min+gzip, which still catches a bundled third-party module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
composeMessage wrote an attachment's contentType and contentId into the part headers as given; only createMessage refused a line break in them. A message built by hand, as one read back from the planned queue would be, could therefore inject part headers such as `X-Injected: 1`. The MIME builder already re-checks addresses and custom headers, so it now runs the same checkAttachment as createMessage. Parameters in a content type, such as `text/plain; charset=utf-8`, are still written as they are. An upstream bug, recorded in CLAUDE.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SMTP client appended every chunk of a reply to a string and split the
whole of it again, so a server that never ended a line made each chunk
rescan everything received until the socket timeout, and a reply could hold
any number of lines. createReplyReader keeps only the incomplete last line
between chunks and refuses a line over 8192 characters or a reply over 1000
lines with a RangeError, destroying the socket; the reply to DATA was
already bounded at 64 KiB.
Three parsing bugs go with it. A final reply line that is the code alone
("250"), which RFC 5321 section 4.2 allows, was taken for a continuation
and hung until the timeout, in commands, the greeting and after DATA; the
greeting resolved on a line still waiting for its CRLF; and toString() per
chunk replaced a UTF-8 character split across two chunks. The reader
decodes with a StringDecoder and only complete lines count.
Upstream bugs, recorded in CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The transports keep their configuration, and with it API keys, passwords, OAuth tokens and DKIM private keys, on plain properties: TypeScript's `private` is not private at run time. util.inspect(), console.log() and JSON.stringify() of a transport, or of a ./retry or ./pool around one, so printed them to any log or error reporter that serialized it. src/internal/redact.js copies an object with every credential field and credential header (Authorization, X-API-Key, ...) replaced by [REDACTED], naming emitters such as sockets rather than walking them and cutting cycles with a WeakSet. The ten transports that hold credentials return that copy from toJSON() and inspect it from [util.inspect.custom]; the configuration itself keeps its values. src/internal/ holds private modules any subpath may require; it is not in the exports map, so the module graph test needs no new edge. The helper adds about 0.5 KB min+gzip to each of those subpaths, so the budgets of smtp, jmap, mailgun, maileroo, lettermint and plunk go up by 1 KB. Recorded in CLAUDE.md as an upstream bug. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
./otel left error.message out of span attributes unless recordSensitiveData
was on, but the span status, the exception event and the email.send_failed
event carried the raw error text anyway, and ./logger wrote failures with
their raw error text and the provider's raw response. SMTP names the
recipient in its rejections ("RCPT TO failed for bob@example.com: ..."), so
recipient addresses reached telemetry and logs by default, contrary to
SECURITY.md. ./plunk also put the start of the recipient's local part into
its synthetic message IDs.
src/internal/redact-addresses.js replaces every address in a text with
[REDACTED], scanning from each "@" to the separators around it (with a
bounded look for a quoted local part or a domain literal), so it runs in
linear time on any input. ./otel applies it to the status and events unless
recordSensitiveData is on; ./logger applies it to errorMessages, errors and
receipt and leaves providerDetails out unless recordMessage is on. A receipt
returned to the application keeps its full text. SECURITY.md and the otel
and logger guides now say what is redacted and when.
Upstream bugs, recorded in CLAUDE.md. The otel size budget goes from 4 to
5 KB min+gzip for the helper.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A failed receipt can now carry `deliveryUnknown: true` (createFailedReceipt option `deliveryUnknown`): the request or the message data went out but no answer came back, so the message may have been accepted and sending it again may deliver it twice. Such a receipt, and the errors made for it, are never retryable. It is the `delivery_unknown` state of the roadmap's event model, to be reconciled from delivery events rather than retried. ./retry does not retry it unless shouldRetry says so. ./pool stops failing over at it and at an error of category validation or rejected, which every provider would share; upstream failed over on any failed receipt, sending a refused message to each transport in turn. The pool's own timeout now produces an unknown delivery: upstream failed over after it, so the slow transport and the next one could both deliver. Errors of the transport itself (auth, rate limit, outage) still fail over. Both decorators pin one Message-ID per message before the first attempt (pinMessageId, with a WeakMap of copies, so the caller's message is left as it is): each attempt used to compose a new one, so a duplicate could not be told apart. The sender-rooted generator moves from the MIME builder to src/core/message-id.js for that. BREAKING CHANGE: a timeout of ./pool no longer fails over and its receipt is no longer retryable; a pool no longer fails over at a rejected message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A connection that closed, timed out or failed after the terminating dot of
DATA went out raised the same error as one before it ("SMTP connection
closed during DATA.", "SMTP DATA timeout."), which classifies as a
retryable network or timeout failure. The server may well have accepted the
message by then, so a retry, by ./retry or by the application, could deliver
it twice.
writeMessageData now wraps any failure after the dot is written in an
SmtpDeliveryUnknownError (with the failure as its cause), and
createSmtpFailure turns it into a receipt with deliveryUnknown, the code
smtp.delivery-unknown and the category of the cause. A failure before the
dot is unchanged and stays retryable. The flat test of a reply over 64 KiB
after the dot now expects it.
An upstream bug, recorded in CLAUDE.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
send() and sendMany() went through a client built with replayable requests, so a network failure or a timeout on the request that creates and submits the email (Email/set with EmailSubmission/set) retried it, up to `retries` times: a lost answer could submit the message twice. Only sendRaw() avoided that. That request now goes through a submission client with no retries, still with a string body (a streamed one would turn a 401 into a network error on Node 24, known bug 14). A failure without an answer from the server marks the receipt deliveryUnknown, unless the connection was never made: JmapApiError now keeps the failure as its cause, and src/internal/unsent.js finds a refused connection, an unknown host or a rejected certificate in that chain, after which the request certainly never left and stays retryable. The session, mailbox and identity requests keep their retries. An upstream bug, recorded in CLAUDE.md; the two ported tests that pinned the retry now expect a single attempt of unknown delivery. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing called it, upstream or here; it was kept under c8 ignore with the known upstream bug 10. CLAUDE.md now says that code dead upstream is removed rather than ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ecord send (92 lines) keeps the loop over transports; one attempt, recording a failed receipt or a throw into one record, is attempt() and recordThrown, and the receipt errors are appended in loops instead of spread pushes and maps. The configuration resolves its entries and counts the enabled ones and the valid weights in one pass instead of a filter, a map and a some. Behavior is unchanged. The budget of ./pool goes from 2.5 to 3 KB: it was at 2547 of 2560 bytes, the property names of the record add 76, and round-robin across batches and the health of Phase 2 come next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
getMessagesTo looked through a spread of the three recipient lists for each message; it checks each list in place. findMessageBy, findMessagesBy and getMessagesBySubject are loops. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prepareMimeStream (94 lines) hashes the body once in hashBody, signs the headers in signHeaders, which keeps the signatures made before one that fails under send-unsigned, and checks a streamed replay in replayBody. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… loop isProgrammerError, hasRetryableError and getRetryAfterMilliseconds of ./retry are loops; the batch loop of ./otel keeps the messages of a failed receipt through keepErrorMessages, four blocks deep at most. Behavior is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The JSON parse failures were caught as jsonError and parseError, against the catch (error) of the code style. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
func-style (expressions), no-nested-ternary and max-depth 4 apply everywhere; in src/, no-restricted-properties refuses the array iteration methods, the unicorn rules no-array-for-each, no-array-reduce, no-useless-spread and prefer-set-has apply, and max-lines-per-function warns over 80 lines; pnpm lint fails on an unused disable directive. no-console is off only for scripts, bench and the opt-in suites, as CLAUDE.md says, no longer for every test. The tests follow: function declarations become expressions, nested ternaries become ifs or tables, a JMAP fetch stub reads its URL through tests/helpers/jmap/request-url.js, two unit tests stop logging, and a property access split over lines goes through a variable. Four helpers of src/ that captured nothing move to module scope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each read its whole input into an array before the first message went out, so a long or endless iterable was held in memory, or never sent. chunks() of internal/batch.js reads an iterable, synchronous or not, an array at a time: SES reads batchSize messages, Resend 100 (the batch API is now chosen per chunk, not for the whole input), and JMAP batches of maxObjectsInSet after one discovery, made only once there is a first message; a failed discovery still fails every message, read as it comes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The providers that send one message per request sent a batch strictly one after the other. With `concurrency` (1 by default, the behavior so far) they send that many at once through sendConcurrently of internal/batch.js, which reads a message only when a send can start, gives the receipts in the order of the messages, observes a later failure until its turn, and closes the input when the caller stops. The option must be a positive integer (checkConcurrency). The budgets of ./sendgrid, ./mailgun and ./plunk go from 5 to 5.5 KB: the shared module adds about 0.2 KB, which took SendGrid to 5.1 and the other two to 4.9. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only the sends in flight counted against maxConcurrent, so while a slow first message was out, each later one that finished freed its slot for the next, and the whole input could be sent with its receipts held for the first; every turn also raced all the sends in flight, adding a reaction to each. A freed slot is still refilled, as the ported tests ask, but no more than maxConcurrent finished receipts wait for an earlier one, and a send that settles wakes the loop (SendManyRun, a class with short methods in place of a 126-line generator). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sendMany reset the strategy, so round-robin sent every batch of one message through the first transport, and two batches at once reset each other's rotation. The strategy now goes on from where the sends before left it; reset() stays on the strategies for the caller's own code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The queue of messages waiting for a receipt grows only with the read-ahead of the wrapped transport, which streaming sendMany, concurrency and the bound of ./retry now keep to a batch at most; no limit of its own is needed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tests/unit/attribution.test.js reads src/ and the root index.d.ts: each file starts with the upyo header (the upstream file, or the package directory for a d.ts) and carries its notice, or starts with "Not ported from upyo:". src/index.js, src/version.js, src/core/text.js and the root index.d.ts get headers of that form, and LICENSE must keep the notice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The requests JMAP retries are those of discovery, Mailbox/get and Identity/get; the submission, the session and the uploads are not retried. executeRequest takes the onRetry of the send, and send(), sendMany() and sendRaw() pass it through getDraftsMailboxId, getIdentityId and getIdentityMap, so ./otel, ./logger and the caller hear those retries as they hear the HTTP providers'. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Inline parts (known upstream bug 1) went into the multipart/mixed of the files, so some clients showed an image the HTML refers to by cid: as an attachment. They go with the body in a multipart/related whose type names the root part (RFC 2387); with files too, the related part comes first in a multipart/mixed, as ./jmap already built it. A message without attachments keeps its structure. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first encoded word of a header (known upstream bug 13) shared its line with the field name, up to 84 characters, and other lines folded at 78, past the 76 that RFC 2047 §2 allows a line with an encoded word. A field with encoded words folds at 76; its first word is split at a whole UTF-8 character so that the first line keeps text and fits, the parts decoding as one text (§6.2); a field name too long for any text after it leaves the first line to itself. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mailgun makes the file name of an inline part its Content-ID, and the converter sent the file name (known upstream bug 2), so the cid: of the HTML matched nothing. The part goes under its contentId. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The maxSizeRequest of the Session (RFC 8620 §2) was not followed, so a batch over it, or one message with a long body, was refused whole. A batch goes in as many requests as the limit takes, grouped in order by sizes measured once per message (groupBySize), each request failing on its own; a message whose request alone is over the limit fails with jmap.request_too_large and is not sent, in a batch or alone. The budget of ./jmap goes from 11.5 to 12.5 KB: it was at its limit, and onRetry and this fix add 0.4 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node 24's fetch answers an early 401 to a streamed request body with "fetch failed" (known upstream bug 14), so an authentication rejection of the import, the submission or a raw upload became a retryable network error. Requests go as a buffer: upstream streamed them against a replay seen in Deno, and Node's fetch does not replay a POST whose connection drops (checked against Node 24.14.1, undici 7.24.4). A raw message of a known size up to 4 MiB is read into one buffer after validation; one of unknown size still has to stream, and its test stays todo on Node 24. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Custom header fields and the threading ones were dropped (known upstream bug 10), so a reply lost its place in the thread. SES v2 takes them in the Headers of Simple content since March 2024: up to 15 fields of up to 996 characters, printable ASCII, without the fields SES sets itself. They go there with those fields left out and a Latin-1 value in RFC 2047 encoded words; more fields, or a longer one, fail the send before any request. The budget of ./ses goes from 6 to 6.5 KB, as it reached 6.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
cram-md5 was a method of the SmtpAuth type of upstream (known bug 5) and failed at the first send, as any method the connection did not know. It is removed from the type, as the user decided: CRAM-MD5 is obsolete, its server keeps a password equivalent, and AUTH goes over TLS here anyway. SmtpTransport now refuses an unknown method, or one of the other kind of credentials, with a TypeError when it is made (checkAuthMethod). The docs no longer say a password's method is detected: it is PLAIN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ses, resend, sendgrid, mailgun, mailtrap and plunk accepted validateSsl and never read it (known upstream bug 3), so a caller who set it to false believed certificates went unchecked. Node's fetch checks every certificate and has no switch for it without undici, so false now throws a TypeError that points to a fetch of the caller's own with an undici dispatcher, as the user decided; true or absent change nothing. The check sits in internal/validate-ssl.js, so the other providers do not bundle it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wice continueOnSuccess was accepted and never read (known upstream bug 7); honored, it would send a message that went out through one transport again through the others, against the guard of the pool against double delivery. As the user decided, it is gone from the PoolConfig and ResolvedPoolConfig types, and continueOnSuccess: true throws a TypeError when the pool is made. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The comparison now opens with a column for @alexify/mailer itself, filled from the code of this branch: the transport as built, the reliability layer as 🔜 with a footnote to the plan, and deliveryUnknown noted on retry and failover. custom.css tints the column in the brand teal and, from 768px, pins it beside the features. The matrix moves to separate borders, as collapsed ones belong to the table and let scrolled text show through the seam of a pinned cell, and the titles of the group rows stick while the table scrolls sideways. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first release: the transport layer ported from upyo 0.7.0, with the upstream bugs fixed since. The version goes to 1.0.0 in package.json and src/version.js, CHANGELOG turns [Unreleased] into [1.0.0] with a section on upgrading from upyo 0.7, and the work-in-progress notes, the package description, llms.txt, SECURITY.md and the comparison now say what 1.0 holds and that the reliability layer is still being designed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
SmtpConnection#quit() sends QUIT and closes without waiting for the 221, so the mock SMTP server's reply to QUIT can meet a closed socket: EPIPE on Linux, ECONNABORTED on Windows. Only ECONNRESET, what macOS reports, was taken as the client leaving; the others became an uncaught 'error' and failed "a pooled connection whose reset fails is replaced" on every CI job while it passed on macOS. A new test emits each code on a server socket, so the rule is checked on every platform. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The transport layer of
@alexify/mailer, ported file by file from upyo 0.7.0 (MIT, © 2025 Hong Minhee), then hardened: 71 upstream bugs fixed, onefix:commit each with a test that fails without it. Plain CommonJS with hand-written types, Node.js ≥ 22, zero runtime dependencies. The reliability layer (queue, audit log, delivery events) is not in this PR: it is designed, with no API yet.What's in it
The port: the root entry and 16 subpaths, one per upyo package.
createMessageand the message model, theTransport/RawTransport/VerifiableTransportcontract,Receiptwith classified errors../mime(MIME builder, DKIM RSA-SHA256 and Ed25519) and./smtp(pool, STARTTLS, AUTH PLAIN/LOGIN/XOAUTH2/OAUTHBEARER, PIPELINING, DSN, SMTPUTF8).fetch:./ses(SigV4 onnode:crypto, no AWS SDK),./resend,./sendgrid,./mailgun,./mailtrap,./maileroo,./lettermint,./plunk; and./jmap(RFC 8620/8621)../pool,./retry,./otel(the providers passed in),./logger(any pino-like logger, in place of LogTape), and./mock.Fixed after the port. The full list is in CLAUDE.md ("Code ported from upyo"), the user-facing part in CHANGELOG.
deliveryUnknown. A failure after SMTP's final dot, after an HTTP 2xx, or on a timeout or reset that may have reached the provider is marked so, and neither./retrynor./poolsends it again. The Message-ID is pinned across attempts.RCPT TO), display names quoted, attachments and DKIM settings checked before they reach a header. Credentials kept out ofutil.inspectandJSON.stringify, addresses redacted in otel and logger output by default. No redirect followed with credentials, HTTPS required for provider URLs (plain HTTP only to loopback), response bodies capped at 4 MiB, MIME boundaries fromcrypto.randomUUID().Retry-Afterwaited for up to 30 s (beyond that the failure goes back to the caller), the timeout covers the body,timeout: 0andInfinitymean none, an abort keeps the caller's reason.multipart/related; lines with encoded words within 76 characters; DKIM signs field instances bottom-up, over-signs From, Reply-To and Subject, and reads PKCS#1 keys; SES sends custom and threading headers; JMAP followsmaxObjectsInSetandmaxSizeRequest, keys blobs per attachment and trusts only the Session's origin; SMTP sends SNI, bounds replies and resets a pooled connection when it is next taken.sendManystreams.Added beyond upstream
fetch,generateId(a cuid2, ULID or nanoid in place ofcrypto.randomUUID()), a pino-likelogger, andrandomfor the decorators.onRetryin the options of a send, passed through every decorator.concurrencyforsendManyof SendGrid, Mailgun, Plunk and Maileroo;sendManyof SES, Resend and JMAP reads a batch at a time.Docs. Guides for every subpath adapted from upyo, a Known issues page, and the comparison of 14 Node.js email libraries with @alexify/mailer as its first column: tinted in the brand teal and pinned beside the features on screens from 768px (🔜 marks the roadmap, with footnotes).
Tooling. oxlint rules that enforce the code conventions (loops instead of array methods in
src/), a size budget per entry (pnpm size --check), a tarball check (check:pack), an attribution-header test for every shipped file, a module-graph test with no cycles, bundle tests through esbuild, opt-in suites against Mailpit and real providers, and a zero-dependency benchmark harness.Breaking changes against upyo 0.7.0
feat(core)!: a failedReceiptmay carrydeliveryUnknown: true, never retryable.refactor(otel)!: metric and attribute names follow the OpenTelemetry semantic conventions.fix(otel)!:auto,serviceName,serviceVersionandmetrics.samplingRatethrow aTypeError(they were never read, or skewed the metrics).fix(smtp)!:cram-md5is gone, and an unknown AUTH method is refused when the transport is made.fix(pool)!:continueOnSuccess: truethrows (honored, it would deliver a message twice).validateSsl: falsethrows aTypeErroron the HTTP providers, sincefetchcannot honor it.Not in this PR
todoon Node 24).Test plan
pnpm test: 1940 tests, 0 failures, 1 todo (the JMAP case above)pnpm run test:coverage: 98.73% lines and statements, 94.31% branches, 100% functionspnpm run test:types,pnpm run check:dtspnpm run lint,pnpm run format:checkpnpm size --check,pnpm run check:pack(148 files)pnpm docs:comparisonleaves no diff;pnpm run docs:buildpnpm test:mailpit,pnpm test:e2e(opt-in, not run here)🤖 Generated with Claude Code