DocsCollaboration

Email

A thin, live IMAP/POP3/SMTP client with no local mailbox mirror, sandboxed HTML rendering, and safely pooled connections.

No local mailbox mirror#

EmailAccount stores only connection settings — host, port, protocol, username, and an encrypted password for both the incoming and outgoing side — never any message content. Every folder list, message list, and message body shown in the UI is fetched live from the mail server on the request that asks for it, the same way a desktop client like Thunderbird works, rather than syncing a copy of the mailbox into KHUB's own database. That trades a slower per-request round trip for a much smaller trust surface: nothing about what's actually in your mail ever lands in KHUB's database or backups.

Two receiving protocols, one adapter interface#

EmailClientService exposes the same five operations (listFolders, listMessages, getMessage, deleteMessage, sendMessage) regardless of which incoming protocol an account uses:

  • IMAP (via imapflow) — real folders, and paging by sequence-number range computed from the mailbox's exists count, newest first.
  • POP3 — no folder concept (always just "INBOX"), and no persistent UIDs across sessions, so a POP3 message's "uid" in the API is really just its current, session-scoped message number. Listing uses TOP <n> 0 to fetch headers only (cheap) rather than downloading the full body (RETR) just to show a subject line.

The POP3 client itself is hand-rolled directly over a raw net/tls socket rather than pulled from npm — the actively-maintained package we tried first ships a broken CommonJS build (its "main" file is literally the ESM source copied with a .cjs extension), which crashed the whole API at boot the moment anything require()d it. POP3 is a simple enough line-oriented protocol (RFC 1939) that a ~150-line client covering USER/PASS/STAT/LIST/RETR/TOP/DELE/QUIT was the more robust fix than fighting module-format interop.

Sending, for both protocols, goes through nodemailer against the account's separately-configured SMTP settings.

Credential handling#

Both the incoming and outgoing password are encrypted at rest with the same AES-256-GCM cipher and SECRETS_ENCRYPTION_KEY used for CI/CD secrets (see CI/CD pipelines) — a deliberate reuse rather than a second encryption scheme to reason about. They're decrypted server-side only for the duration of one mail operation and are stripped from every API response, even in encrypted form; a provider error message that could echo a password back (some SMTP/IMAP failure text does) is sanitized the same way GithubImportService sanitizes a leaked token, by splitting on the literal secret and redacting it before the error reaches the client. Saving or updating an account tests the real connection (both directions) before anything is persisted, so a typo'd host or an expired app password fails immediately rather than saving a mailbox that can never connect.

Connection reuse#

Opening an IMAP connection means a full TLS handshake plus a login round-trip — against a real host like Gmail, that's the dominant cost of any single operation, easily outweighing the actual list/fetch command that follows it. EmailClientService keeps one authenticated ImapFlow connection open per account (an in-memory Map<accountId, connection>, not persisted anywhere), reused across list/read/mark-seen/delete calls and closed automatically after 90 seconds of inactivity. Two things have to hold for this to be safe rather than just fast:

  • A stale connection can't be resurrected under someone else's identity. Updating an account's host, port, or credentials evicts (closes) any pooled connection for that account id immediately, so the very next request reconnects with what was actually just saved rather than continuing to use a connection authenticated under the old ones.
  • A connection is only ever dropped for an actual connection failure, checked via client.usable — not for a business-level rejection a request handler throws on purpose (a bad/stale message uid, say). Evicting on every such rejection would mean a single "message not found" throws away a perfectly good connection along with it.

Two concurrent requests resolving in the same tick (the message list's poll firing right as something else is opened) are de-duplicated onto the same in-flight connect() rather than each opening — and separately pooling — their own connection, which would otherwise leak the loser as an open, untracked socket.

POP3 gets no such pool — a POP3 mailbox allows exactly one authenticated session at a time, and a second concurrent connection attempt typically fails outright ("unable to lock maildrop"), so instead every operation against the same account is queued to run strictly one at a time.

Rendering untrusted HTML mail safely#

An HTML message body renders inside an <iframe sandbox=""> with srcDoc set directly to the source HTML and no allow-scripts token — the sandbox attribute alone is what makes this safe regardless of what the message contains, since a sandboxed frame with scripts not explicitly allowed cannot execute embedded <script> tags or inline event handlers no matter how the HTML is structured. No HTML sanitization library is involved; the browser's own sandbox is the actual security boundary. This doesn't block the mail itself from loading remote resources it references (a tracking pixel in a marketing email, say) — only from running code. There's no image-proxying or "block remote content" toggle in this iteration, unlike Gmail or Outlook.

Polling, not push#

IMAP and POP3 have no realtime channel comparable to chat's Socket.IO gateway, so the message list polls (every 30 seconds while the page is open) rather than pushing updates — the closest approximation to "seamless" new-mail delivery without holding a persistent connection open per account per user.