Hello! I'm Hong Minhee (洪 民憙), an open source software engineer in my late 30s, living in Seoul, Korea. I'm bisexual and non-binary (they/them), and an enthusiastic advocate of free/open source software and the fediverse.
I work full-time on @fedify, an ActivityPub server framework in TypeScript, funded by @sovtechfund. I'm also the creator of @hollo, a single-user ActivityPub microblog; @botkit, an ActivityPub bot framework; Hackers' Pub, a fediverse platform for software developers; and LogTape, a logging library for JavaScript and TypeScript.
I have a long interest in East Asian languages (CJK) and Unicode. I post mostly in English here, though occasionally in Japanese or in mixed-script Korean (國漢文混用體), a traditional writing style that interleaves Chinese characters with the native Korean alphabet. Wanting to write in that style was actually one of the reasons I joined the fediverse. Feel free to talk to me in English, Korean, Japanese, or even Literary Chinese!
安寧하세요! 저는 서울에 살고 있는 30代 後半의 오픈 소스 소프트웨어 엔지니어 洪民憙입니다. 兩性愛者(bisexual)이자 논바이너리(non-binary)이며, 自由·오픈 소스 소프트웨어(F/OSS)와 聯合宇宙(fediverse)의 熱烈한 支持者이기도 합니다.
STF(@sovtechfund)의 支援을 받아 TypeScript用 ActivityPub 서버 프레임워크 @fedify 開發에 專業으로 任하고 있습니다. 그 外에도 싱글 유저用 ActivityPub 마이크로블로그 @hollo, ActivityPub 봇 프레임워크 @botkit, 소프트웨어 開發者를 위한 聯合宇宙 플랫폼 Hackers' Pub, JavaScript·TypeScript用 로깅 라이브러리 LogTape 等의 製作者이기도 합니다.
東아시아 言語(이른바 CJK)와 Unicode에도 關心이 많습니다. 이 計定에서는 主로 英語로 포스팅하지만, 때때로 日本語나 國漢文混用體 韓國語로도 씁니다. 聯合宇宙에 오게 된 動機 中 하나가 바로 國漢文混用體로 글을 쓰고 싶었기 때문이기도 하고요. 韓國語, 英語, 日本語, 아니면 漢文으로도 말을 걸어주세요!
Omarchy should not matter. But sadly it does. As a power grab.
If you don't know what Omarchy is here's the short summary. David Heinemeier Hansson, creator of RubyOnRails, owner of a software business and millionaire, decided a few months ago to go all in on Linux and - because that's just his MO - thought that turning his setup into a project made sense. Omarchy is just that: A pretty standard Arch Linux distribution with a handful of configuration files, that Heinemeier Hansson (or DHH as […]
Omarchy should not matter. But sadly it does. As a power grab.
If you don’t know what Omarchy is here’s the short summary. David Heinemeier Hansson, creator of RubyOnRails, owner of a software business and millionaire, decided a few months ago to go all in on Linux and – because that’s just his MO – thought that turning his setup into a project made sense. Omarchy is just that: A pretty standard Arch Linux distribution with a handful of configuration files, that Heinemeier Hansson (or DHH as he is often called) wrote, or vibed or whatever. There is nothing wrong with this, custom, opinionated special purpose distributions have been part of the Linux culture for a long time and provide a lot of value and experimentation that sometimes even flows back to the original project. In a way Ubuntu Linux is just that to the basis Debian.
There’s also nothing wrong in more opinionated software – quite the opposite. I do think that RubyOnRails and similar frameworks gained traction especially because they do have a clear idea of how they see the respective problem domain. There’s even the statement “There should be one – and preferably only one – obvious way to do it” in the Zen of Python. Having a strong point of view is not a bad thing in software or in general. The problem is the kind of points of view DHH has.
I don’t want to go into all the gritty details here, Brennan Kenneth Brown did a great job with their article “Normalized Fascism in Open Source: $12 Million Given to DHH“, but here’s the short version. DHH is a racist and a fascist. He recently wrote articles on how there are [Content Warning: massive racism] too many brown people in London these days. In July he wrote an [Content Warning: Racism, anti-romanism] article comparing Roma communities (using a slur for them) to “wolves” that should be “shot”. His X account is also know to be “edgy” in a similar way. A few days ago he posted sort of a manifesto for his set of config files:
There’s even a longer version on the website. Even without a strong background in antifascist literature or history this reads like a fascist creed. About unity and identity. About “holding the line” (against codes of conduct and the rights of marginalized people as the website explains). About how there always needs to be a strong leader who makes the decisions. Given DHH’s other writing this is not a misguided joke, this is how he thinks about the world.
I began this text writing “Omarchy should not matter” but I have not explained why it does. It’s surely not about user numbers (it’s a clunky distro aimed at people who love to cosplay hackers). But it is about power.
DHH has used his connection to CEO’s of tech corporations and his position as influencer to collect up to now above 18 million dollars of funding for Omarchy. He uses that money not for himself (he has enough of that) but to fund certain pieces of Software he uses in his distribution. Like for example the window manager hyprland whose main developer the Omarchy money now funds. What is “Vaxry” known for outside of a niche window manager? He runs a community based around LGBTQ discrimination and participates in that (see the short summary on Drew DeVault’s “Weird Little Guys of FOSS” list). His bigotry is so consistent that he was banned from participating in the Freedesktop.org community where all the other developers of window managers and the free software desktop stack collaborate. And it was long before DHH funded him.
One could see a world where a CEO using his connections to drum up some funding for free software projects would be a good thing. But that’s not the world we live in I am afraid. DHH got 3 million USD from Digital Ocean (a cloud provider) for Omarchy. Basically at the same time where Digital Ocean cancelled their support of Flathub and GNOME that amounted to a value of 50 USD per month.
DHH is using his position and his influence to accumulate donations that he then can distribute to projects that align with his fascist worldview and ambitions. And in a time where many important projects and infrastructures are strapped for cash running just on the goodwill of a handful of people that is increasingly dangerous. Not because its corporate money – a lot of funding for open source comes from corporations, they are after all who are sucking up all the surplus from people’s labor. It’s because this amount of money gives him power.
The Omarchy foundation will be where projects in desperate need of funding will be pointed. And will a project focused on human rights and social justice get funding from our fascist overlord? What kind of pressure can that exert on projects who have to decide to either die due to lack of funding or kill their code of conduct?
An open fascist is building one of the bigger sources of funding for open source fully under his individual control and sucks up a lot of money that otherwise might have gone to liberatory projects. Community projects. Antifascist projects.
And that is why Omarchy matters. Sadly. Not because of its technical merit (even though those fascists love to use the “merit” argument to fight human rights) but because of its leader, his connections and his ability to accumulate financial power. Without anyone in that ecosystem saying anything about how that leader operates and talks. And you might know the saying: If there’s a Nazi and 10 folks sitting together at a table and nobody says and does anything? There’s 11 Nazis at that table.
Omarchy got a lot of recognition recently. Not only by corporate donors but by Youtubers and other tech influencers who kept praising it and nudged people (who might not know about DHH’s exploits) towards testing it. To become “a hardcore linux user/dev” or whatever other chauvinist argument they made. This makes it dangerous.
Running Omarchy means contributing to this dynamic. It means willingly integrating oneself into a fascist community. Legitimizing it. Supporting it. Omarchy is a fascist project that normalizes fascist thinking for everyone entering that community (even unknowingly). The baseline has to be not to use that system. And as people who believe in human rights and dignity, in community and a tech world that can be better we need to oppose Omarchy. Need to make sure it gets no seat at any table. Support projects who actively refuse to collaborate. Support projects who care about human rights and flourishing. And do not do business with the companies who willingly support fascists.
Siamo tutti antifascisti!
(Share this message about Omarchy [or some other similar post] around in the tech circles you frequent. Tell people about the background of this hyped Linux distribution and its fascist leader. Quit your 1password or Digital Ocean accounts and mention that it is because the support fascists.)
Liked it? Take a second to support tante on Patreon!
Screenshot of an X post (url: https://x.com/dhh/status/2098043643395277095) from the account @dhh saying:
Unite the nerds
Hold the line
Have some fun
Beauty is truth
Heritage is duty
Command is service
Welcome the agents
Perfect the computer
Own the machine
You’re somebody now
Kagi Translate is the machine translation service I reach for most.
I've tried Google Translate, DeepL, Papago, and others over the years, and for the language pairs I mostly work with (Korean, English, Japanese, and Chinese) Kagi Translate has consistently come out ahead in quality. It's a little slow, but that's never bothered me: I don't copy-paste raw machine output and call it done, I always review and edit by hand, so the extra seconds don't matter.
I'd been recommending it to people for a while, until earlier this year it went from free to paid and got harder to suggest in good conscience.
Now it's free again. If you've never used anything but Google Translate, this is a good moment to give Kagi Translate a try.
There's already an answer to how you send email from Node.js. Node.js has Nodemailer, and it's been a solid choice for a long time. But the code people write today doesn't only run on Node.js anymore. It's common to see it running on Deno, Bun, or an edge runtime like Cloudflare Workers, and it's common to switch providers between development and production: send nothing locally, then use SES or Resend once deployed.
So I wanted to change the question a little. Not how do you send email from Node.js, but how does an application deliver email regardless of runtime or provider. That's the question Upyo started from.
Nodemailer is great; the landscape just got bigger
If you're sending email over SMTP from Node.js, Nodemailer is enough. Its SMTP implementation is mature, it supports OAuth 2.0, and it already handles DKIM signing and calendar invitations.
The catch is that Nodemailer runs on top of Node.js built-in modules like node:net, node:tls, and node:stream. That makes it hard to use on Cloudflare Workers, Vercel Edge Runtime, or Supabase Edge Functions. Its provider support centers on SMTP too, so using an HTTP API service like Resend or SendGrid means installing that service's own SDK or relying on a third-party transport. Switching providers usually means touching application code too.
The principle of universality
Upyo is built on web standard APIs like fetch(), Web Streams, and Web Crypto. Since it doesn't depend on Node.js specific modules, the same code runs unchanged on Node.js, Deno, Bun, and edge functions.
Every email service is handled through the same Transport interface: SMTP and JMAP, Resend, SendGrid, Mailgun, Amazon SES, Plunk, Lettermint. Swap out how the transport is constructed and the application code that sends mail stays the same, whether that's a local SMTP server or a mock transport in development, or SES in production.
Keeping dependencies minimal follows from the same goal. @upyo/ses implements AWS Signature v4 itself instead of pulling in the AWS SDK. The AWS SDK assumes Node.js, and using it as is would make the transport unusable on Deno, Bun, or an edge runtime.
Separating retry logic from application logic
The Transport interface itself is small. send() sends one message, sendMany() sends several, and cancellation goes through a standard AbortSignal.
How sendMany() is implemented differs by transport. The transport for a provider like Resend or SendGrid that exposes a batch endpoint calls that endpoint directly. SMTP, where reusing one connection for several messages is the natural approach, does that instead. A transport without either optimization might still run several send() calls concurrently instead of one after another, to cut down total time.
Because the interface is this small, the answer to where retry policy should live changes. Wrap a transport in RetryTransport and application code never needs its own retry logic.
That's possible because application code only knows the Transport interface type, not a concrete implementation. The function that sends mail takes a transport: Transport parameter, and the caller decides which transport gets injected.
Providers fail differently; what if that didn't matter?
import { MockTransport } from "@upyo/mock";import { RetryTransport } from "@upyo/retry";const provider = new MockTransport();const transport = new RetryTransport(provider, { maxAttempts: 3, backoff: { baseDelayMilliseconds: 1000, maxDelayMilliseconds: 30000, factor: 2, },});
RetryTransport implements the same Transport interface, so application code doesn't need to know whether it's talking to SMTP or SES, or whether retries are even happening. Wrap it in PoolTransport and a failing provider can fail over to the next one by priority, or traffic can be spread across several with round robin.
import { PoolTransport } from "@upyo/pool";const pool = new PoolTransport({ strategy: "priority", transports: [ { transport: primaryProvider, priority: 100 }, { transport: backupProvider, priority: 10 }, ], maxRetries: 3,});
The two can be stacked. Whether you retry per provider before failing over to the next, or retry the whole pool as a single operation, comes down to which one sits on the outside.
This composition works because every transport returns failures in the same shape. Upyo's Receipt is a discriminated union of success and failure, and a failure carries structured fields like retryable, category, and retryAfterMilliseconds. Once each transport translates a provider's error into these fields, RetryTransport can decide whether to retry a 429 from Resend or a transient SMTP error the same way.
Still waiting on magic links, even in development
The annoying part of building magic-link login was never the login itself. It was opening an inbox every time and waiting for the link to show up, when the link never needed to be delivered anywhere during development.
The function that sends mail was written to take a Transport from the start.
import { createMessage } from "@upyo/core";import type { Transport } from "@upyo/core";async function sendMagicLink(transport: Transport, email: string, link: string) { await transport.send(createMessage({ from: "auth@example.com", to: email, subject: "Your login link", content: { text: `Click to log in: ${link}` }, }));}
@upyo/logtape wraps another transport and logs instead of sending. In development, that's what gets injected.
import { LogTapeTransport } from "@upyo/logtape";import { SmtpTransport } from "@upyo/smtp";const smtp = new SmtpTransport({ host: "smtp.example.com", port: 587, auth: { user: "smtp-user", pass: "smtp-password" },});const transport = process.env.NODE_ENV === "development" ? new LogTapeTransport({ category: ["app", "email"] }) : new LogTapeTransport({ transport: smtp, category: ["app", "email"] });
Copy the link straight out of the server log and paste it into the browser. No refreshing an inbox.
Automated tests use the same injection point. This time, sendMagicLink() gets @upyo/mock's MockTransport instead.
import { MockTransport } from "@upyo/mock";import assert from "node:assert/strict";const transport = new MockTransport();await sendMagicLink(transport, "user@example.com", "https://example.com/verify?token=...");const sent = await transport.waitForMessage( (msg) => msg.subject.includes("login link"), 1000,);assert.equal(sent.recipients[0].address, "user@example.com");
sendMagicLink() itself never changed. Only the Transport handed to it did.
A small API that still holds the complexity
A small Transport interface doesn't mean the messy parts of email got skipped. The SMTP transport handles internationalized addresses through SMTPUTF8 and requests delivery status notifications through DSN. Attachments stream, so even large files don't sit in memory. The Message.calendar field turns a message into a meeting invitation or cancellation, and messageId, inReplyTo, and references let outgoing mail be part of a conversation rather than a one-off notice.
Getting started
To send over SMTP, install these packages.
npm add @upyo/core @upyo/smtp
import { createMessage } from "@upyo/core";import { SmtpTransport } from "@upyo/smtp";const transport = new SmtpTransport({ host: "smtp.example.com", port: 587, auth: { user: "smtp-user", pass: "smtp-password" },});const message = createMessage({ from: "sender@example.com", to: "recipient@example.com", subject: "Hello from Upyo", content: { text: "This is a test email." },});await transport.send(message);
Layer RetryTransport, PoolTransport, or LogTapeTransport on top as needed. Configuration for each transport is in the docs, the source is on GitHub, and packages are on npm and JSR.
That's the answer so far to the question I started with: how an application delivers email regardless of runtime or provider. There's still plenty to refine, so if something's broken or missing, an issue or a discussion is welcome.
There's already an answer to how you send email from Node.js. Node.js has Nodemailer, and it's been a solid choice for a long time. But the code people write today doesn't only run on Node.js anymore. It's common to see it running on Deno, Bun, or an edge runtime like Cloudflare Workers, and it's common to switch providers between development and production: send nothing locally, then use SES or Resend once deployed.
So I wanted to change the question a little. Not how do you send email from Node.js, but how does an application deliver email regardless of runtime or provider. That's the question Upyo started from.
Nodemailer is great; the landscape just got bigger
If you're sending email over SMTP from Node.js, Nodemailer is enough. Its SMTP implementation is mature, it supports OAuth 2.0, and it already handles DKIM signing and calendar invitations.
The catch is that Nodemailer runs on top of Node.js built-in modules like node:net, node:tls, and node:stream. That makes it hard to use on Cloudflare Workers, Vercel Edge Runtime, or Supabase Edge Functions. Its provider support centers on SMTP too, so using an HTTP API service like Resend or SendGrid means installing that service's own SDK or relying on a third-party transport. Switching providers usually means touching application code too.
The principle of universality
Upyo is built on web standard APIs like fetch(), Web Streams, and Web Crypto. Since it doesn't depend on Node.js specific modules, the same code runs unchanged on Node.js, Deno, Bun, and edge functions.
Every email service is handled through the same Transport interface: SMTP and JMAP, Resend, SendGrid, Mailgun, Amazon SES, Plunk, Lettermint. Swap out how the transport is constructed and the application code that sends mail stays the same, whether that's a local SMTP server or a mock transport in development, or SES in production.
Keeping dependencies minimal follows from the same goal. @upyo/ses implements AWS Signature v4 itself instead of pulling in the AWS SDK. The AWS SDK assumes Node.js, and using it as is would make the transport unusable on Deno, Bun, or an edge runtime.
Separating retry logic from application logic
The Transport interface itself is small. send() sends one message, sendMany() sends several, and cancellation goes through a standard AbortSignal.
How sendMany() is implemented differs by transport. The transport for a provider like Resend or SendGrid that exposes a batch endpoint calls that endpoint directly. SMTP, where reusing one connection for several messages is the natural approach, does that instead. A transport without either optimization might still run several send() calls concurrently instead of one after another, to cut down total time.
Because the interface is this small, the answer to where retry policy should live changes. Wrap a transport in RetryTransport and application code never needs its own retry logic.
That's possible because application code only knows the Transport interface type, not a concrete implementation. The function that sends mail takes a transport: Transport parameter, and the caller decides which transport gets injected.
Providers fail differently; what if that didn't matter?
import { MockTransport } from "@upyo/mock";import { RetryTransport } from "@upyo/retry";const provider = new MockTransport();const transport = new RetryTransport(provider, { maxAttempts: 3, backoff: { baseDelayMilliseconds: 1000, maxDelayMilliseconds: 30000, factor: 2, },});
RetryTransport implements the same Transport interface, so application code doesn't need to know whether it's talking to SMTP or SES, or whether retries are even happening. Wrap it in PoolTransport and a failing provider can fail over to the next one by priority, or traffic can be spread across several with round robin.
import { PoolTransport } from "@upyo/pool";const pool = new PoolTransport({ strategy: "priority", transports: [ { transport: primaryProvider, priority: 100 }, { transport: backupProvider, priority: 10 }, ], maxRetries: 3,});
The two can be stacked. Whether you retry per provider before failing over to the next, or retry the whole pool as a single operation, comes down to which one sits on the outside.
This composition works because every transport returns failures in the same shape. Upyo's Receipt is a discriminated union of success and failure, and a failure carries structured fields like retryable, category, and retryAfterMilliseconds. Once each transport translates a provider's error into these fields, RetryTransport can decide whether to retry a 429 from Resend or a transient SMTP error the same way.
Still waiting on magic links, even in development
The annoying part of building magic-link login was never the login itself. It was opening an inbox every time and waiting for the link to show up, when the link never needed to be delivered anywhere during development.
The function that sends mail was written to take a Transport from the start.
import { createMessage } from "@upyo/core";import type { Transport } from "@upyo/core";async function sendMagicLink(transport: Transport, email: string, link: string) { await transport.send(createMessage({ from: "auth@example.com", to: email, subject: "Your login link", content: { text: `Click to log in: ${link}` }, }));}
@upyo/logtape wraps another transport and logs instead of sending. In development, that's what gets injected.
import { LogTapeTransport } from "@upyo/logtape";import { SmtpTransport } from "@upyo/smtp";const smtp = new SmtpTransport({ host: "smtp.example.com", port: 587, auth: { user: "smtp-user", pass: "smtp-password" },});const transport = process.env.NODE_ENV === "development" ? new LogTapeTransport({ category: ["app", "email"] }) : new LogTapeTransport({ transport: smtp, category: ["app", "email"] });
Copy the link straight out of the server log and paste it into the browser. No refreshing an inbox.
Automated tests use the same injection point. This time, sendMagicLink() gets @upyo/mock's MockTransport instead.
import { MockTransport } from "@upyo/mock";import assert from "node:assert/strict";const transport = new MockTransport();await sendMagicLink(transport, "user@example.com", "https://example.com/verify?token=...");const sent = await transport.waitForMessage( (msg) => msg.subject.includes("login link"), 1000,);assert.equal(sent.recipients[0].address, "user@example.com");
sendMagicLink() itself never changed. Only the Transport handed to it did.
A small API that still holds the complexity
A small Transport interface doesn't mean the messy parts of email got skipped. The SMTP transport handles internationalized addresses through SMTPUTF8 and requests delivery status notifications through DSN. Attachments stream, so even large files don't sit in memory. The Message.calendar field turns a message into a meeting invitation or cancellation, and messageId, inReplyTo, and references let outgoing mail be part of a conversation rather than a one-off notice.
Getting started
To send over SMTP, install these packages.
npm add @upyo/core @upyo/smtp
import { createMessage } from "@upyo/core";import { SmtpTransport } from "@upyo/smtp";const transport = new SmtpTransport({ host: "smtp.example.com", port: 587, auth: { user: "smtp-user", pass: "smtp-password" },});const message = createMessage({ from: "sender@example.com", to: "recipient@example.com", subject: "Hello from Upyo", content: { text: "This is a test email." },});await transport.send(message);
Layer RetryTransport, PoolTransport, or LogTapeTransport on top as needed. Configuration for each transport is in the docs, the source is on GitHub, and packages are on npm and JSR.
That's the answer so far to the question I started with: how an application delivers email regardless of runtime or provider. There's still plenty to refine, so if something's broken or missing, an issue or a discussion is welcome.
To mark the release of Upyo 0.6.0, a next-generation email library for JavaScript/TypeScript, I wrote a post on why it's worth choosing over an established library like Nodemailer.
There's already an answer to how you send email from Node.js. Node.js has Nodemailer, and it's been a solid choice for a long time. But the code people write today doesn't only run on Node.js anymore. It's common to see it running on Deno, Bun, or an edge runtime like Cloudflare Workers, and it's common to switch providers between development and production: send nothing locally, then use SES or Resend once deployed.
So I wanted to change the question a little. Not how do you send email from Node.js, but how does an application deliver email regardless of runtime or provider. That's the question Upyo started from.
Nodemailer is great; the landscape just got bigger
If you're sending email over SMTP from Node.js, Nodemailer is enough. Its SMTP implementation is mature, it supports OAuth 2.0, and it already handles DKIM signing and calendar invitations.
The catch is that Nodemailer runs on top of Node.js built-in modules like node:net, node:tls, and node:stream. That makes it hard to use on Cloudflare Workers, Vercel Edge Runtime, or Supabase Edge Functions. Its provider support centers on SMTP too, so using an HTTP API service like Resend or SendGrid means installing that service's own SDK or relying on a third-party transport. Switching providers usually means touching application code too.
The principle of universality
Upyo is built on web standard APIs like fetch(), Web Streams, and Web Crypto. Since it doesn't depend on Node.js specific modules, the same code runs unchanged on Node.js, Deno, Bun, and edge functions.
Every email service is handled through the same Transport interface: SMTP and JMAP, Resend, SendGrid, Mailgun, Amazon SES, Plunk, Lettermint. Swap out how the transport is constructed and the application code that sends mail stays the same, whether that's a local SMTP server or a mock transport in development, or SES in production.
Keeping dependencies minimal follows from the same goal. @upyo/ses implements AWS Signature v4 itself instead of pulling in the AWS SDK. The AWS SDK assumes Node.js, and using it as is would make the transport unusable on Deno, Bun, or an edge runtime.
Separating retry logic from application logic
The Transport interface itself is small. send() sends one message, sendMany() sends several, and cancellation goes through a standard AbortSignal.
How sendMany() is implemented differs by transport. The transport for a provider like Resend or SendGrid that exposes a batch endpoint calls that endpoint directly. SMTP, where reusing one connection for several messages is the natural approach, does that instead. A transport without either optimization might still run several send() calls concurrently instead of one after another, to cut down total time.
Because the interface is this small, the answer to where retry policy should live changes. Wrap a transport in RetryTransport and application code never needs its own retry logic.
That's possible because application code only knows the Transport interface type, not a concrete implementation. The function that sends mail takes a transport: Transport parameter, and the caller decides which transport gets injected.
Providers fail differently; what if that didn't matter?
import { MockTransport } from "@upyo/mock";import { RetryTransport } from "@upyo/retry";const provider = new MockTransport();const transport = new RetryTransport(provider, { maxAttempts: 3, backoff: { baseDelayMilliseconds: 1000, maxDelayMilliseconds: 30000, factor: 2, },});
RetryTransport implements the same Transport interface, so application code doesn't need to know whether it's talking to SMTP or SES, or whether retries are even happening. Wrap it in PoolTransport and a failing provider can fail over to the next one by priority, or traffic can be spread across several with round robin.
import { PoolTransport } from "@upyo/pool";const pool = new PoolTransport({ strategy: "priority", transports: [ { transport: primaryProvider, priority: 100 }, { transport: backupProvider, priority: 10 }, ], maxRetries: 3,});
The two can be stacked. Whether you retry per provider before failing over to the next, or retry the whole pool as a single operation, comes down to which one sits on the outside.
This composition works because every transport returns failures in the same shape. Upyo's Receipt is a discriminated union of success and failure, and a failure carries structured fields like retryable, category, and retryAfterMilliseconds. Once each transport translates a provider's error into these fields, RetryTransport can decide whether to retry a 429 from Resend or a transient SMTP error the same way.
Still waiting on magic links, even in development
The annoying part of building magic-link login was never the login itself. It was opening an inbox every time and waiting for the link to show up, when the link never needed to be delivered anywhere during development.
The function that sends mail was written to take a Transport from the start.
import { createMessage } from "@upyo/core";import type { Transport } from "@upyo/core";async function sendMagicLink(transport: Transport, email: string, link: string) { await transport.send(createMessage({ from: "auth@example.com", to: email, subject: "Your login link", content: { text: `Click to log in: ${link}` }, }));}
@upyo/logtape wraps another transport and logs instead of sending. In development, that's what gets injected.
import { LogTapeTransport } from "@upyo/logtape";import { SmtpTransport } from "@upyo/smtp";const smtp = new SmtpTransport({ host: "smtp.example.com", port: 587, auth: { user: "smtp-user", pass: "smtp-password" },});const transport = process.env.NODE_ENV === "development" ? new LogTapeTransport({ category: ["app", "email"] }) : new LogTapeTransport({ transport: smtp, category: ["app", "email"] });
Copy the link straight out of the server log and paste it into the browser. No refreshing an inbox.
Automated tests use the same injection point. This time, sendMagicLink() gets @upyo/mock's MockTransport instead.
import { MockTransport } from "@upyo/mock";import assert from "node:assert/strict";const transport = new MockTransport();await sendMagicLink(transport, "user@example.com", "https://example.com/verify?token=...");const sent = await transport.waitForMessage( (msg) => msg.subject.includes("login link"), 1000,);assert.equal(sent.recipients[0].address, "user@example.com");
sendMagicLink() itself never changed. Only the Transport handed to it did.
A small API that still holds the complexity
A small Transport interface doesn't mean the messy parts of email got skipped. The SMTP transport handles internationalized addresses through SMTPUTF8 and requests delivery status notifications through DSN. Attachments stream, so even large files don't sit in memory. The Message.calendar field turns a message into a meeting invitation or cancellation, and messageId, inReplyTo, and references let outgoing mail be part of a conversation rather than a one-off notice.
Getting started
To send over SMTP, install these packages.
npm add @upyo/core @upyo/smtp
import { createMessage } from "@upyo/core";import { SmtpTransport } from "@upyo/smtp";const transport = new SmtpTransport({ host: "smtp.example.com", port: 587, auth: { user: "smtp-user", pass: "smtp-password" },});const message = createMessage({ from: "sender@example.com", to: "recipient@example.com", subject: "Hello from Upyo", content: { text: "This is a test email." },});await transport.send(message);
Layer RetryTransport, PoolTransport, or LogTapeTransport on top as needed. Configuration for each transport is in the docs, the source is on GitHub, and packages are on npm and JSR.
That's the answer so far to the question I started with: how an application delivers email regardless of runtime or provider. There's still plenty to refine, so if something's broken or missing, an issue or a discussion is welcome.
I've released Upyo 0.6.0, an email library for JavaScript and TypeScript that works across Node.js, Deno, Bun, and edge runtimes.
This version adds MIME composition without sending, streaming attachments over SMTP, and calendar invitations. You can also set message identifiers for tracking replies and check SMTP or JMAP configuration without sending a test email.
There are new Maileroo and Mailtrap transports, plus a LogTape integration for logging delivery or trying out email workflows locally.
Drew DeVault (@drew) has compiled a sourced list tracking prominent figures in the F/OSS community and their controversial/far-right political ties and conduct:
I wish something like Android's Quick Share or Apple's AirDrop would get standardized so we could easily transfer files regardless of the platform. I wonder if there's already a standard for that?
"사회는 새로운 미래를 만들어내는 대신, 낡은 폭력을 계속해서 리믹스하고 있다. 2026년 현재까지도 계속되고 있는 가자 지구의 학살은 바로 이 식민주의라는 동일한 루프의 또 다른 변주에 다름 아니다."
"Rather than generating a new future, society keeps remixing old violence. The massacre still unfolding in Gaza as of 2026 is nothing other than another variation on this same loop of colonialism."
After almost fifteen years, I'm done with @1password. I just read through the email exchange @mvsde posted, where 1Password's support team defended the company's patronage of DHH's Omacom Foundation with the usual “we're funding the foundation, not the individual” line. That distinction doesn't hold up when the money still legitimizes him and everyone his politics attract. I've trusted 1Password with my passwords for longer than most of my relationships have lasted, and that's exactly why this matters: loyalty like that shouldn't be free. Migration starts this week.
Screenshot of an email to 1Password support:
Hi,
I'm concerned about 1Password sponsoring Omarchy and DHH.
1Password has positioned itself in support of diversity and inclusion. DHH on the other hand is strongly opposed to such values and has become increasingly far right.
https://jakelazaroff.com/words/dhh-is-way-worse-than-i-thought/
As a 1Password customer who is also queer and such the target of DHH's anti-DEI crusade, I have to think about whether I still want 1Password to receive my money. Since it's apparently directly funneled to far right white supremacists as DHH.
Regards,
Fynn Ellie Becker