Repository navigation
mailer v1.1: ten providers, sendmail, verified webhooks and reliability decorators - #3
Merged
Merged
Conversation
1.1 takes the features of three libraries of the comparison where they lead this package. LICENSE carries the notices of nodemailer 10.0.16 (MIT-0), unemail 0.7.0 and sently 1.2.2 (MIT). The attribution test reads a table of sources, each with its version, path pattern and notice, and a second source of a file needs its own notice; the marker of code that is not a port becomes "// Not ported:". CLAUDE.md gains the rules of these ports: the feature is ported onto the contracts of the package, and a known upstream bug is fixed in the feat commit, with a test and an entry in the list. The decision on providers allows the ten most used of unemail and sently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Unicode domain under an ASCII local part needed SMTPUTF8, so mail to user@bücher.de failed on any server without it, while nodemailer sends it as user@xn--bcher-kva.de. toAsciiAddress, after nodemailer's normalizeDomain, maps the domain with domainToASCII (IDNA with the mapping of UTS #46) and leaves a domain at which the host parser would cut, an address literal and a local part beyond ASCII as given. It is applied where a message is written: header fields, SMTP envelopes (overrides and raw messages too), JMAP and the provider converters. The Message keeps the address as the application gave it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headers keeps names in lower case, so every custom field went out as x-custom-header or list-unsubscribe-post. The MIME writer capitalizes each word of the name, as nodemailer's _normalizeHeaderKey does, with MIME, DKIM, ARC and BIMI at the start, -ID, -SPF, -FBL and -MD5 at the end and X-SMTPAPI upper-cased, and Content-features as RFC 2912 spells it. Names are case-insensitive, so readers and DKIM verification see no difference; ported tests that pinned lower case say so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
createMessage takes `list`: the List-Id of RFC 2919, the URL fields of RFC 2369 (help, unsubscribe, subscribe, post, owner, archive) and, with oneClick, the List-Unsubscribe-Post of RFC 8058 that Gmail and Yahoo ask of bulk senders. The fields go into Message.headers, so every transport that sends custom header fields sends them. The formatting is nodemailer's (`<url> (comment)`, a bare address as mailto) and the one-click field unemail's. Fixed in the port: unemail built List-Unsubscribe after its line-break check, so a CRLF in the URL added header fields (U1), and turned one-click on for any URL, http included (U2); nodemailer wrote no List-Unsubscribe-Post, and neither signed it (N1). A URL with whitespace, controls or angle brackets, another scheme or none is refused rather than stripped or guessed, oneClick is opt-in and needs an https URL, and the default DKIM h= covers List-Unsubscribe and List-Unsubscribe-Post. The root budget goes to 9.5 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Every text and HTML body went quoted-printable, so Cyrillic or CJK text took about three times its UTF-8 bytes, plain ASCII was escaped for nothing, and a bare LF went as =0A on one long line. As nodemailer does, a body is first given CRLF line breaks, then written 7bit when it is ASCII with lines of at most 76 columns, quoted-printable when it has more Latin letters than other characters, and base64 otherwise. A line that ends in whitespace goes quoted-printable too, so an MTA that trims it cannot break a DKIM signature over the simple body. The budgets of ./mime and ./smtp go to 7.5 and 20.5 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From unemail's message model: scheduledAt asks the provider to deliver later, tracking turns open and click tracking on or off for one message, and metadata holds values the provider returns in its events. createMessage checks them: a valid Date, booleans of `opens` and `clicks`, and metadata keys of 1 to 128 of [A-Za-z0-9_.-] with values free of control characters, since a key may become a header or form field name (unemail wrote both into X-Metadata-* fields unchecked, U3). A transport that cannot schedule a message refuses it before anything is sent, with a non-retryable validation receipt, rather than delivering it at once: SMTP, JMAP and, until their scheduling APIs are mapped, every HTTP provider. Resend sends a chunk holding one individually, and JMAP sends the runs between refused messages, so each receipt keeps its place. The budgets of the root, Mailtrap, Maileroo and Lettermint go up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ream A new subpath makes an attachment of what nodemailer read through `path`, `href` and data URIs and sently through `resolveAttachments`: fromPath reads a regular file again each time the message is read, fromUrl fetches an http or https URL once through the injected fetch, fromDataUri decodes RFC 2397, fromStream reads a one-shot stream into memory, and inlineDataUrls turns the data-URI images of HTML into inline attachments (nodemailer's attachDataUrls). File names and types come from the path, the server or the URI, with a table of about sixty extensions. Fixed in the port: nodemailer read any path or URL an attachment named, its client following five redirects to any address with no bound on the body (N2), and turned a data URL over 50 MB into an empty attachment or read one that did not parse as a path (N3); sently's basePath compared paths as written, so a symbolic link led out of it (S1). Here nothing is read but through these helpers; fromUrl checks each redirect with allowUrl, by default refusing localhost and loopback, private and link-local addresses, sends headers to the first origin only and bounds time and bytes; fromPath compares realpaths. The budgets of Resend and SendGrid go to 6 KB with readBytes in the shared body reader. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An IDN domain arrives as its A-label, a one-click List-Unsubscribe passes Mailpit's own check of the field and of List-Unsubscribe-Post under a DKIM signature an independent verifier accepts, and bodies in base64 and 7bit and an attachment of a data URI arrive as written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`connect` opens each connection in place of a TCP connection to the host, as nodemailer's getSocket does: a proxy, a tunnel or a socket of another module, given the host, port, localAddress and a signal that the connection timeout and the caller's cancellation abort. Implicit TLS and STARTTLS go over the socket it resolves to, checked against the host. createHttpProxyConnector, ported from nodemailer's http-proxy-client, makes one for an HTTP or HTTPS proxy; `localAddress` picks the address to connect from. Fixed in the port (N4): an IPv6 destination is bracketed in the CONNECT line and Host field, an HTTPS proxy takes `ca` and is sent its name for SNI, the credentials of the proxy URL are decoded as UTF-8, and what the server sent with the reply waits in the paused socket. A connection through `connect` counts as local for plain-text AUTH by its host only, as the peer of a tunnel is the proxy. The ./smtp budget goes to 22.5 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream greeted every server with EHLO localhost, which a server that checks the name, such as Postfix with reject_non_fqdn_helo_hostname, refuses. As nodemailer's _getHostname does, the default is the host name of this machine when it is a fully qualified domain name, its address as an address literal, and [127.0.0.1] otherwise. An explicit localName, which goes into EHLO as it is, is refused when it holds whitespace or a control character that would end the command. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upstream reused a connection for any number of messages, while servers limit the messages of one connection and answer the next with an error. As nodemailer's pool does, a connection that has started maxMessages transactions (100 by default, Infinity for none) is closed with QUIT instead of going back to the pool, and sendMany takes a new connection for the rest of its batch, keeping the order of the receipts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from nodemailer's sendmail transport, pipes each message to the sendmail command of the system (Postfix, Exim, msmtp, …), one process per message: `<path> -i -f <sender> -- <recipients>`, or `args` in place of -f. The message is composed by ./mime, so it gets the DKIM signature, encodings and IDN addresses of SMTP; sendRaw pipes serialized MIME, and verify finds the command at its path or on PATH. Line endings are LF by default, and `spawn` is passed in. Fixed in the port (N5): a process killed by a signal was a success and the code of `close` went on as an error; only an exit with 0 succeeds, a signal after the whole message is deliveryUnknown, and the sysexits codes are classified (75 and 69 retryable, 127 configuration). stderr, never read there, is read bounded and redacted into the failure; `--` ends the options before the recipients; blind recipients go on the command line only, never in a Bcc field for the MTA to strip; an abort kills the process, and an attachment that cannot be read kills it with SIGINT so no part is delivered. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
checkDkimConfig read a name through domainToASCII, a WHATWG host parser that removes tabs, CR and LF before it reads the name, so "exa\r\nmple.com" passed as the valid "example.com" while the name, line break and all, went into the DKIM-Signature field as given, breaking the header block. A name with a control character, whitespace, "/", "\", "?", "#" or "%" is now refused before it reaches the parser, for the signing domain and the selector. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…and Mailgun Upstream's decorators forwarded neither sendRaw nor verify, so a transport that sends raw MIME lost it once wrapped in ./retry, ./pool, ./otel or ./logger, while nodemailer takes raw messages on every transport. Each decorator now has both: ./retry retries a raw message as a message and verifies once, ./pool sends one through its transports that have sendRaw with its strategy and failover (no selector is asked) and verifies when one of its transports verifies, ./otel traces it in a span of its own, ./logger logs it by its envelope, and ./mock keeps raw messages apart. A wrapped transport without sendRaw gives a failure of the category configuration. SES sends raw MIME as the Raw content of SendEmail and Mailgun through messages.mime, each to the envelope of the message, checked line by line as SMTP checks it. The budgets of SES and Mailgun go up. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SES, SendGrid, Resend and Mailgun get verify(), as sently's transports have, each one authenticated read of the API through the shared retry layer, retried as a read and never taken for a delivery: SES GetAccount, SendGrid's scopes with mail.send required, Resend's domains and Mailgun's sending domain. A key known to the provider but not allowed to read (403, or Resend's restricted_api_key) is verified too, so a key that may only send is no false negative; a 401 or another status throws an error with the statusCode of the answer. Mailtrap, Maileroo, Lettermint and Plunk offer no request that checks a sending key without sending, and say so in their pages. The SES budget goes to 7.5 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A message goes to Mailpit through a local HTTP proxy with createHttpProxyConnector, and through ./sendmail into Mailpit's own sendmail command in its container when MAILPIT_SENDMAIL names it, with its blind recipient on the envelope. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from the Postmark drivers of unemail and sently: a message goes to /email, a batch of up to 500 to /email/batch with the receipt of each message's own answer, and verify reads the server of the token. The first tag is Postmark's Tag (a second is refused), metadata its Metadata, tracking TrackOpens and TrackLinks; List-* fields, priority and threading go in Headers; an ErrorCode becomes the category of the receipt (406 rejected, 10 auth, sender signatures configuration). The providers of 1.1 share src/internal/api.js: their API error, an authenticated JSON request on requestWithRetry, the receipt of a failure, attachments in base64 and header fields with names capitalized (moved to internal/headers.js from ./mime). Fixed in the port: unemail's fetch followed redirects with the credentials, read bodies whole and retried after the provider may have taken the message (U4); sently sent an inline part without `cid:` and parsed JSON before the status (S2). The shared modules grew, and the budgets of ./smtp, Resend, SendGrid and Mailgun with them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from the Brevo drivers of unemail and sently: /v3/smtp/email with the api-key, scheduledAt as Brevo's scheduledAt, tags as its tags, metadata in X-Mailin-custom, List-* fields, priority and threading in its headers, a sandbox switch, and verify reading the account. Every request carries an idempotency key among the headers of the message, as Brevo's documentation places it, as a UUID (toUuid, from unemail), generated when the message has none, so a request whose delivery is unknown is retried safely; Brevo's duplicate_parameter is reported as deliveryUnknown. A second reply-to address and an inline attachment, which Brevo cannot take, are refused rather than dropped. Fixed in the port (U5): unemail grouped a batch into messageVersions by the file names and lengths of the attachments, so one recipient could get another's file, and its batch keys could collide. sendMany sends single requests, `concurrency` at a time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from unemail's Mailjet driver: Messages is the batch, sent in requests of at most 50 recipients as Mailjet counts them, and a 400 carries the answer of every message, so each gets its own receipt, those that went succeeding. The first tag is the CustomCampaign (a second is refused), metadata the EventPayload, tracking TrackOpens and TrackClicks only with an HTML part, an inline part an InlinedAttachment; the fields Mailjet sets itself (List-Id, X-Mailer, …), a second reply-to address, a subject over 255 characters and a scheduled message are refused before the request. The key pair goes as UTF-8 Basic authentication; verify reads the profile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new subpath, ported from the MailerSend drivers of unemail and sently: /v1/email with a Bearer token, the id of a message the X-Message-Id of the 202 that answers it. scheduledAt is send_at (at most 72 hours ahead), tags (up to 5) its tags, tracking its settings, metadata X-Metadata-<key> headers; In-Reply-To, References and List-Unsubscribe go in the fields MailerSend has for them, which writes the one-click Post field itself. Its limits (50 to, 10 cc, 10 bcc, one reply-to) are refused before the request, and a 422 names the fields it refuses. Fixed in the port (U6): unemail's batch went through the bulk endpoint, one id for every message and failures unknown to the receipts; sendMany sends single requests, `concurrency` at a time. sently dropped custom headers; they are sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from sently's SparkPost transport onto the shared client of the HTTP providers. The US or EU host comes from `region`; cc and bcc recipients get the shared To field in `header_to` and the Cc field in the headers of the content (S3); tags go on each recipient, metadata on the transmission, tracking and `scheduledAt` in its options; inline images in `inline_images`; and `sendRaw` sends a UTF-8 MIME message as `email_rfc822`. Rejected recipients are counted on the receipt, which fails as `rejected` when none was accepted. The budget is 8 KB: the raw-message reader of `sendRaw` adds half a kilobyte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's ZeptoMail driver onto the shared client of the HTTP providers. The Send Mail token gets its `Zoho-enczapikey` scheme unless it has it; a message's one tag is its `client_reference`, as ZeptoMail has no tags; metadata goes in `X-Metadata-<key>` headers and tracking in `track_opens` and `track_clicks`; inline images in `inline_images`. The codes in the details of an error choose the category (auth, rate limit, spent credits, malformed request). Its limits and a scheduled message are refused before a request. A batch goes as concurrent single requests rather than the batch endpoint, which sends one message to many recipients with one answer. There is no `verify()`: a Send Mail token can only send. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's SMTP2GO driver onto the shared client of the HTTP providers. The region chooses the US, EU or AU host, the global one without it; the reply-to addresses and metadata go in header fields, as SMTP2GO has no field for them, and tags and tracking, which it has none for either, are not sent, as over SMTP. `scheduledAt` is `schedule`, written in the form SMTP2GO documents and held to its three days by an injected clock. A 200 whose `failed` count or `failures` say the send failed is a `rejected` receipt; the id is the `email_id`, which the events name. `verify` reads the summary of the account, a key without that permission verified too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's Azure Communication Services driver onto the shared client of the HTTP providers. Each attempt is signed with the access key (HMAC-SHA256 on node:crypto, checked in the tests against Web Crypto) at the time it is sent. The credentials come only from the options, the endpoint is HTTPS or loopback HTTP, and every send carries its key as the Operation-Id and the Repeatability-Request-Id, with the time of the first attempt as Repeatability-First-Sent, so Azure answers a retry with the outcome of the first request; a repeat answered 409 or 412 is `deliveryUnknown` (U7). Metadata goes in `X-Metadata-<key>` headers; tracking it cannot express, a schedule and a request over 10 MB are refused before a request. `verify` reads an operation that does not exist. The budget is 8 KB: the signature and the connection string add almost a kilobyte. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's Scaleway driver onto the shared client of the HTTP providers. The region is checked and encoded as a segment of the path; the reply-to addresses and metadata go in `additional_headers`, as Scaleway has no field for them; an inline attachment, which it has no Content-ID for, is refused rather than sent as a file the HTML cannot refer to (U8), as are a schedule and a request over 2 MB. An error's type names the fields of `invalid_arguments`, and a spent quota is a `configuration` failure. `verify` lists the domains of the project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ported from unemail's REST driver of Cloudflare Email Service onto the
shared client of the HTTP providers, in the shapes of the REST API
rather than those of the Workers binding: `{ address, name }`,
`reply_to` and `content_id` (U9). The receipt's id is the `message_id`
of the answer, not one made of the recipients and the clock; recipients
that bounced or are suppressed are listed on a successful receipt,
which fails as `rejected` only when none was accepted, and a 200 with
`success: false` is a validation failure. `sendRaw` goes through
`send_raw`; `verify` asks Cloudflare whether the token is active, as a
token of the account and then of a user. The decoding of a raw message
as UTF-8, which SparkPost had of its own, moves to `internal/raw.js`.
The budget is 8 KB: the raw-message reader adds half a kilobyte.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A converter refuses a message over a limit of its provider, or with a field the provider cannot express, with a RangeError before any request. Its receipt was classified by its words, as `unknown`; it is a non-retryable `validation` failure, as for a message a transport cannot schedule. `refusalFailure` (`internal/refusal.js`) makes it, for the shared `providerFailure` and for the ported providers' own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`scheduledAt` is Resend's `scheduled_at`, read from the message rather than from an option of the conversion that nothing passed. `metadata` goes in the tags, which Resend returns in its events, after `tag1`… of the message's tags; a key or value a tag cannot hold, or a key equal to one of those names, is refused before the request. A message with either goes alone, as the batch API takes neither. Tracking is set per domain, so a message's `tracking` is not sent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`scheduledAt` is `send_at`, at most 72 hours ahead by an injected clock (`now`), a later one refused before the request; a message's `tracking` sets its open and click tracking over the configuration's; `metadata` is `custom_args`, which SendGrid returns in its events. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`scheduledAt` is `o:deliverytime`, an RFC 2822 date; a message's `tracking` sets `o:tracking-opens` and `o:tracking-clicks` over the configuration's; `metadata` goes in `v:<key>` variables, which Mailgun returns in its events. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Receipts of the library's own conditions had none and were classified by the words of their text: an answer without a message id (the shared sentWith, Mailjet, Postmark, SMTP2GO, SparkPost), a repeat Brevo or Azure took before, a pool with no error to tell, and a thrown error in the shared providerFailure, sendmail and SES. The first are unknown, as a delivery of unknown delivery is; a thrown error is classified by classifyReceiptError, the codes along its causes first, then its words. A test fails when a call of createFailedReceipt in src/ gives no category, no status to tell it by, no options that carry one and no errors of other transports. ./sparkpost goes to 8.5 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The monotonic default of the rate limit, of the memory store of ./idempotency and of the replay store of ./webhooks, which only the breaker's had a test of: a jump of Date.now refills no bucket and ends no lease. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
classify() of a provider reached the receipt alone, so a retryable 420 of SparkPost's spent limit was not retried within the send, while its receipt said it could be. The shared client passes isRetryable to requestWithRetry: the retryable of the provider's classification when it gives one, the status otherwise. ./mailgun and ./scaleway go to 8 KB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…g key with it verify() took a 403 for a known key that may only send, for every provider, so a key Postmark, Brevo, Mailjet, SendGrid, SMTP2GO or Azure refused with it passed. acceptForbidden is set where a key that may only send is refused the read with a 403: SES (AccessDenied for GetAccount), Mailgun (a domain sending key), SparkPost, MailerSend and Scaleway. The flow no longer goes through an error marked verified: requestWithRetry takes an accepts(status) whose answers go to read without being taken for a delivery, and a status the check does not take fails with it, retried as it says. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SES was the HTTP provider left with a client of its own. Its SigV4 signature is the authorize of the shared client, made anew for each attempt so that X-Amz-Date is the attempt's, the 200 SES answers is checked in the read, and SesApiError extends ProviderApiError, its errors the providerDetails. The receipts are those of before; the test of the clients now leaves JMAP alone out. ./ses goes to 9 KB for the shared client. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l/abort.js The check of a caller's abort was written in the shared client, the decorators (isAbortOf), the pool, SES and sendmail, the test of an AbortError in http.js, the pool and SES, and the reason of an aborted signal in sleep.js and retry. Each is one function of internal/abort.js now, which sleep.js passes on. A module more is a wrapper more in a bundle: ./ses goes to 9.5 KB, ./resend to 8, ./lettermint to 7.5, ./retry to 3.5, ./idempotency to 4.5 and ./hooks to 3. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The start of sendRaw() of a decorator (the caller's abort heard, then the refusal of a raw message its transport sends none of) was written seven times; it is refuseUnlessRaw of internal/decorator.js. Retry, OpenTelemetry and the logger verify and dispose through verifyWrapped and disposeOf, as the decorators of 1.1 do, in place of copies of their own; verifyWrapped takes no transport, as the logger may have none. ./otel goes to 5.5 KB and ./logger to 3 for the module they share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…throw The input of a sendMany was read through three helpers, gate.js's iteratorOf, retry's toAsyncIterator and batch.js's readItems; they are iteratorOf and readItems of internal/iterate.js, the first the input's own iterator, whose return() reaches it while a next() is pending, as a generator would not let it. The pool disposes of its transports through disposeOf, and the pool, retry and OpenTelemetry tell what was thrown with messageOf. ./jmap goes to 13.5 KB, ./brevo to 8 and ./mailjet to 9 for the module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the providers
The providers of 1.1 each wrote an address their way ({ email, name },
{ address, displayName }, { Email, Name }), seven copies of one function,
and six wrote the metadata as X-Metadata-<key> header fields with copies of
one loop. mailboxOf, mailboxesOf and visitMetadataHeaders of
internal/api.js do both; each provider passes its field names, and its
requests are those of before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each of the ten transports of 1.1 made its config and its client, redacted its secrets in toJSON() and its inspection, and sent a batch through sendManyConcurrently, in the same lines. ProviderTransport of internal/provider-transport.js does it, the id first so that toJSON() keeps its order; a transport is its send(), its verify(), and the sendMany() of Postmark, Mailjet and Azure, which batch their own way. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts map The matrix of delivery.test.js listed the providers by hand; a test now fails when an HTTP provider of the exports map is not in it. The refusal of a plain HTTP URL and the cause of a failure of fetch, which only the providers ported from upyo were held to, cover all eighteen, and a contract of the abort is added: a signal aborted before the send rejects with its reason and makes no request, and one aborted during it rejects with its reason too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…uld not fit The batches were read 500 (Postmark) or 50 (Mailjet) messages at a time and converted whole, their files in base64, before the first request, so a batch of large files was held in memory however many requests it then made. Each message is converted as it is read, and a request goes as soon as the next one would not fit by count or bytes: no more than a request and a message are held at once. The pages say a batch request has no idempotency key, and to send messages with large files one by one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ir error A failed message was counted as a failure alone, so a rate limit, a refused key and a refusal of the provider could not be told apart from the metrics. mailer.email.messages takes error.type and mailer.error.code, the category and code of the first error of the receipt (rate-limit, http.429), as the plan of the review asked of ./otel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d copy breaker.open named both the change of state, at warn, and every send the circuit refused, at debug, so counting the records by code counted both; a refusal is breaker.refused. The tee wrote MAILER_TEE_MIRROR_FAILED, the code of its process warning, in its records; they are tee.mirror_failed, as the other decorators name theirs. The records of the breaker half-opening and closing and of a key in flight are tested, and tsd holds the logger option of the four decorators and the maxQueue of the rate limit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
verify() retried its Mailbox/get and Identity/get through the client with no onRetry, so those retries were silent, though the type says the JMAP transport calls it for the reads it retries. The onRetry of its options reaches them; the read is a private method of its own. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan of the review asked for the length of the input of inlineDataUrls to be checked before any scan: maxLength (64 Mi characters) refuses longer HTML with a RangeError. The page says what fromPath checks at each reading (maxBytes, the file by its device and inode, a file renamed over it refused), and the advice on a fetch that checks the address it connects to names one that can be written without undici. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ine of them - Gate, every tenth answered in place: one counter was raised by the admissions and the receipts, so nothing was answered in place; they are counted apart, and ten of the hundred are. - Svix and SendGrid timed their signing with their verification; the requests are signed once. - New: an SNS RSA message verified with its certificate kept, fingerprintOf of 1 MiB of HTML, inlineDataUrls of 1 MB with twenty images, and 10,000 sends waiting for one bucket of the rate limit. bench/baseline.json is taken again with the twenty cases, on Node 24.14.1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Getting started has a table of the parts that keep state, what each keeps in one process and what several share through a store (SC1 of the plan). CLAUDE.md lists R21 to R41, found by the audit of the review's plan against the code, records the decision to keep one ./webhooks entry, names the modules internal/ gained (abort.js, iterate.js, warn.js, provider-transport.js), and says SES is on the shared client too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Upgrading from 1.0: verify() takes a 403 only where a sending key gets one, and a logger that throws is told once. Added: the bounded queue of the rate limit, the new log codes, the tee mirroring after the primary, the token of the replay store, the category and code of a failure in the metrics, and the maxLength of inlineDataUrls. Fixed: a timeout past what a timer keeps, and the retries of JMAP's verify. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…runtime The plan of the review asked for the lookup of the default allowUrl to be checked in tests/runtime, which runs on Node, Bun and Deno: a name that resolves to a private or the metadata address is refused before any request, and one that resolves to a public address is read. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eaches Postmark and Mailjet convert each message as they read it, so nothing calls convertBatch; and verify's requests take every answer through accepts(), so its reject is never called. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
The deadline of every mirror was unref'd, as a detached copy should not hold the process. An awaited one is what its caller waits for, so a script whose mirror hung on a promise ended with the send unsettled, and on Node 22 the suite of ./tee was cancelled from its awaitMirrors test on. Only a detached copy's deadline is unref'd now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…quest does The stub held nothing that kept the process alive, and the certificate timeout of SNS is an AbortSignal.timeout, unref'd, so Node 22 ended the test before the timeout fired. A real request holds its socket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… a relay The HTTPS server of Bun emits no `connection`, so the count stayed 0 there though the receipt was right. A TCP relay in front of it counts each connection on Node, Bun and Deno alike. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Between 1 and 2 MB, linear is 2 and quadratic 4, and a runner of CI (Windows on Node 22 and 24, Deno) took 3.1 to 3.3 for `<`, a garbage collection more in the larger run, though the code is linear (2.0 on a machine at rest). Four times apart, linear is 4 and quadratic 16, and 8 is as far from each; the smaller inputs also hold less for the collector and halve the time of the test. The quadratic code still fails it. 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
1.1.0 adds what nodemailer, unemail and sently did better than this package, ported onto its contracts, and the first parts of the reliability layer. Every finding of a code review of the branch is fixed in it.
CHANGELOG.mdhas the full[1.1.0]entry.What's new
./sendmail;./postmark,./brevo,./mailjet,./mailersend,./sparkpost,./zeptomail,./smtp2go,./azure,./scaleway,./cloudflare.src/internal/api-client.js), SES's SigV4 included.connectand an HTTP CONNECT proxy (createHttpProxyConnector);maxMessagesper pooled connection.list(List-* fields, RFC 8058 one-click);scheduledAt,trackingandmetadata;./attachments:fromPath,fromUrl,fromDataUri,fromStreamandinlineDataUrls. Nothing is read implicitly, andfromUrlrefuses private addresses, written or resolved../render: a renderer interface with React Email passed in, and a linearhtmlToText../webhooks: verifiers for 15 providers into oneDeliveryEventmodel.verify()returnscommit()/release(), and reservations are fenced by a token../breaker,./ratelimit,./idempotency,./hooks,./tee.logger.mailer.delivery_unknownon otel spans and counters;mailer.email.messages;deliveryUnknownin./logger'semail.failed;pool.verifyAll().tests/runtimeon Bun 1.4.3 and Deno 2.9.6, as well as on Node 22 and 24.Upgrading from 1.0
See "Upgrading from 1.0" in the changelog. In short:
localhost;h=also signsList-UnsubscribeandList-Unsubscribe-Post.unsupportedfailure, which./poolfails over with. A refused TLS certificate is a non-retryableconfigurationfailure at the first attempt.provider,categoryandretryable.verify(): a 403 passes only where a send-only key gets one: SES, Mailgun, SparkPost, MailerSend, Scaleway. A 403 from SendGrid or Resend now fails.fetchthat keeps no idle connection, as the docs show.Review
The branch went through a review in seven dimensions: maintainability, performance, security, scalability, resilience, governance and observability. An audit of the plan against the code followed.
CLAUDE.md. Among them:htmlToTextand a ReDoS ininlineDataUrls;beforeSendthat skippedcreateMessage's checks;Testing
pnpm run prepublishOnlypasses:todo(JMAP 401 on Node 24, upstream bug 14);test:runtime(50), tsd,check:dts, size budgets,check:pack.pnpm docs:comparisonshows no drift, andpnpm docs:buildpasses.pnpm test:e2eagainst real providers.tests/e2e/providers.e2e.test.jscovers the ten new providers and is skipped without credentials.Known gaps, by decision
fromUrlresolves names before it connects, but DNS rebinding is not closed. Closing it needs a connect-time check onnode:https, which could not be verified on Bun and Deno here. The docs say how to pass such afetch.Notes for reviewers
CLAUDE.md: each raise is in the commit that needed the room.🤖 Generated with Claude Code