Skip to content

feat(providers): Gmail EmailSender adapter (HT-19) - #17

Merged
zaridan merged 6 commits into
mainfrom
feat/ht-19-gmail-sender
Jul 11, 2026
Merged

feat(providers): Gmail EmailSender adapter (HT-19)#17
zaridan merged 6 commits into
mainfrom
feat/ht-19-gmail-sender

Conversation

@zaridan

@zaridan zaridan commented Jul 11, 2026

Copy link
Copy Markdown
Contributor

Closes HT-19 — the first concrete EmailSender, which puts real mail on the wire via Gmail users.messages.send through the support Google Workspace account. Chosen over Resend/SES: same account that receives (inbound Gmail push), DKIM/SPF already set, and Gmail preserves an RFC-compliant custom Message-ID (our token is compliant by construction; the harness proved it with this exact account). The OAuth token is injected (getAccessToken) — so the adapter code + tests need no live creds; wiring the real token for help@resonantiq.app is a small deploy-time follow-up.

What's here

  • mime.tsbuildRawMessage via mimetext (MIT). Message-ID/In-Reply-To/References verbatim; text+htmlmultipart/alternative.
  • sender.tscreateGmailEmailSender({ getAccessToken, fetchImpl?, userId? }): builds MIME, base64url-encodes, POSTs with a bearer token, returns providerMessageId; throws on non-2xx so sendReply marks send-failed.
  • The wire-level contract test mints a real token and proves Message-ID: <token> lands byte-for-byte, exactly once.

The adapter caught a genuine production bug on its own: mimetext's Node entrypoint emits OS-dependent line endings (LF on Linux/Vercel → invalid RFC 5322) — worked around by importing mimetext/browser (hardcoded CRLF), test-guarded.

Codex adversarial review (MIME/wire/threading — standing rule)

Returned DON'T-SHIP with 4 findings; all fixed with tests:

  • Critical — header injection: mimetext writes header values literally, so a stored inbound Message-ID or a customer address containing \r\n could inject a header (\r\nBcc:). → assertHeaderSafe rejects any control/newline in every externally-influenced header atom before mimetext.
  • High — long lines: 8bit bodies left a long HTML line/URL over RFC 5322's 998-octet limit. → base64-encode bodies, wrapped at 76.
  • High — References folding: a long chain was one ~2KB line. → fold at WSP between msg-ids (mimetext preserves the folds; unfolding round-trips to join(' ')).
  • Medium — token leak: the error snippet could echo the Message-ID token into logs. → redact <ht.…> patterns.

Codex confirmed the Message-ID verbatim, CRLF, base64url, and sender behavior are correct.

Testing

mimetext behavior was verified empirically (it writes bodies/headers verbatim, preserves engine folds). 256 tests pass, typecheck + Biome clean. A Codex confirm pass on the hardened MIME path is running; I'll note the verdict.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • Added Gmail email delivery via the Gmail API, supporting text/HTML (including multipart/alternative), CC, and threaded replies.
    • Introduced deterministic raw RFC 5322 MIME generation (CRLF, RFC-compliant subject encoding, and base64url output).
    • Added an OAuth2-based sender that fetches a fresh access token per send and supports optional user selection.
  • Bug Fixes
    • Strengthened protection against header/body injection and improved error redaction to avoid leaking message/thread identifiers.
  • Tests
    • Added comprehensive MIME-formatting, threading, encoding, line-length, and request/error handling tests; expanded Vitest coverage for runtime adapter code.
  • Documentation
    • Updated provider docs to describe the Gmail adapter entrypoint and its sending behavior.

The first concrete EmailSender — puts real mail on the wire via Gmail
users.messages.send through the support Google Workspace account. Chosen
over Resend/SES: same account that receives, DKIM/SPF already set, and
Gmail preserves an RFC-compliant custom Message-ID (our token is compliant
by construction; harness proved it with this account).

- src/providers/adapters/gmail/mime.ts — buildRawMessage via mimetext
  (MIT). Message-ID/In-Reply-To/References set verbatim; text+html →
  multipart/alternative.
- sender.ts — createGmailEmailSender({ getAccessToken, fetchImpl?, userId? }):
  builds MIME, base64url-encodes, POSTs with a bearer token, returns
  providerMessageId; throws on non-2xx (so sendReply marks send-failed). OAuth
  token is INJECTED — live creds are a deploy-time follow-up, not this code.
- Wire-level contract test mints a REAL token and proves Message-ID lands
  byte-for-byte, exactly once.

Caught: mimetext's Node entrypoint emits OS-dependent EOL (LF on Linux/Vercel
→ invalid RFC 5322) — import mimetext/browser (hardcoded CRLF), test-guarded.

Codex adversarial review (MIME/wire/threading, standing rule) → all fixed:
- CRITICAL header injection: mimetext writes header values literally, so a
  stored inbound Message-ID or address with \r\n could inject a header
  (\r\nBcc:) → assertHeaderSafe rejects control/newline in every external
  header atom before mimetext.
- HIGH long-line: 8bit bodies left long HTML/URL lines over RFC 5322's
  998-octet limit → base64-encode bodies wrapped at 76.
- HIGH References folding: a long chain was one 2KB line → fold at WSP
  (mimetext preserves the folds; unfold round-trips to join(' ')).
- MEDIUM token leak: error snippet could echo the Message-ID token into logs
  → redact <ht.…> patterns.

256 tests pass; typecheck + Biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a Gmail EmailSender adapter that constructs validated raw MIME messages, obtains OAuth2 tokens per send, posts to Gmail’s users.messages.send endpoint, redacts sensitive error tokens, and includes comprehensive tests and coverage configuration updates.

Changes

Gmail EmailSender adapter

Layer / File(s) Summary
Raw MIME construction and validation
package.json, src/providers/adapters/gmail/mime.ts, src/providers/adapters/gmail/mime.test.ts
Adds mimetext and builds CRLF-safe, base64-encoded MIME messages with threading headers, subject encoding, header-injection protection, octet limits, and line-length folding.
Gmail transport and public adapter wiring
src/providers/adapters/gmail/sender.ts, src/providers/adapters/gmail/sender.test.ts, src/providers/adapters/gmail/index.ts, src/providers/README.md, vitest.config.ts
Adds Gmail REST delivery with per-send access-token acquisition, URL-encoded user IDs, bounded and redacted errors, public exports, adapter documentation, and runtime adapter coverage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant EmailSender
  participant buildRawMessage
  participant getAccessToken
  participant GmailAPI
  EmailSender->>buildRawMessage: Build raw MIME from OutboundEmail
  EmailSender->>getAccessToken: Fetch fresh OAuth2 access token
  EmailSender->>GmailAPI: POST users.messages.send with base64url MIME
  GmailAPI-->>EmailSender: Return provider message ID or HTTP error
Loading

Possibly related PRs

  • Helpthread/helpthread#6: Adjusts Vitest provider coverage inclusion and exclusion patterns overlapping with this PR.
  • Helpthread/helpthread#8: Introduces the reply-token format used by this PR’s Message-ID handling and error redaction.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding a Gmail EmailSender adapter.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ht-19-gmail-sender

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/providers/adapters/gmail/mime.ts`:
- Around line 153-169: Suppress Biome’s
lint/suspicious/noControlCharactersInRegex rule inline on the CONTROL_OR_NEWLINE
regex declaration, preserving the intentional control-character matching used by
assertHeaderSafe without changing its behavior.
- Around line 100-111: The header-injection guard currently exempts the subject,
but pure-ASCII subjects can still be emitted literally. In the MIME construction
flow around assertHeaderSafe and setSubject(), validate email.subject with
assertHeaderSafe before passing it to setSubject(), and remove the exemption
comment.

In `@src/providers/adapters/gmail/sender.ts`:
- Around line 105-112: Add a bounded timeout to the Gmail request in the send
method by creating an AbortController, scheduling it to abort after the
configured request timeout, and passing its signal to fetchImpl; clear the timer
after completion while preserving existing error handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ac96b33-8db4-4ae6-b5bf-b3caa3f321aa

📥 Commits

Reviewing files that changed from the base of the PR and between 2f90bc6 and b2a1479.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (8)
  • package.json
  • src/providers/README.md
  • src/providers/adapters/gmail/index.ts
  • src/providers/adapters/gmail/mime.test.ts
  • src/providers/adapters/gmail/mime.ts
  • src/providers/adapters/gmail/sender.test.ts
  • src/providers/adapters/gmail/sender.ts
  • vitest.config.ts

Comment thread src/providers/adapters/gmail/mime.ts Outdated
Comment thread src/providers/adapters/gmail/mime.ts
Comment thread src/providers/adapters/gmail/sender.ts
Codex confirm residuals:
- A single over-long msg-id atom can't be folded under RFC 5322's 998-octet
  limit. And throwing on a poisoned STORED msg-id (control char / absurd
  length) would let one crafted inbound message DoS every reply to that
  conversation. Fix: REQUIRED headers (from/to/cc/messageId) still throw;
  ADVISORY headers (In-Reply-To/References) now DROP unsafe/over-long atoms
  (isSafeMsgId) instead — advisory since our threading is token-anchored.
  messageId (our own token) gets a length assert.
- Error redaction only caught literal <ht...> tokens; an echoed base64url raw
  request body carries the token decodably. Also redact long base64url runs.

259 tests pass; typecheck + Biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/providers/adapters/gmail/mime.test.ts`:
- Line 207: Replace the “Codex” attribution in the comment near the msg-id
length-bound test with a self-contained technical rationale, such as stating
that an overlong atom cannot be safely folded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 423f4f36-15d3-4e1c-b693-e239667d0f8b

📥 Commits

Reviewing files that changed from the base of the PR and between b2a1479 and 7cf1b7e.

📒 Files selected for processing (4)
  • src/providers/adapters/gmail/mime.test.ts
  • src/providers/adapters/gmail/mime.ts
  • src/providers/adapters/gmail/sender.test.ts
  • src/providers/adapters/gmail/sender.ts
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/providers/adapters/gmail/mime.ts
  • src/providers/adapters/gmail/sender.test.ts
  • src/providers/adapters/gmail/sender.ts

Comment thread src/providers/adapters/gmail/mime.test.ts Outdated
Codex confirm residuals (round 4):
- RFC 5322's 998 limit is OCTETS, but the atom bound counted JS chars — a
  512-char multibyte msg-id can be ~2KB on the wire. Measure in UTF-8 octets
  (octetLength/Buffer.byteLength); rename to MAX_HEADER_ATOM_OCTETS.
- from/to/cc addresses had no length bound (only a CRLF guard) — a
  pathological long address could produce a >998 line. Now octet-bounded too
  (throw — required headers).

261 tests pass; typecheck + Biome clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/providers/adapters/gmail/mime.ts`:
- Around line 106-117: The MIME header validation block must also guard the
email subject against header injection. In the relevant message-building
function, add assertHeaderSafe('subject', email.subject) before the setSubject()
call, while retaining the existing validation for from, recipients, cc, and
messageId.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a29180ef-a14d-4d4c-8c6b-2891616eac46

📥 Commits

Reviewing files that changed from the base of the PR and between 7cf1b7e and 35c8823.

📒 Files selected for processing (2)
  • src/providers/adapters/gmail/mime.test.ts
  • src/providers/adapters/gmail/mime.ts

Comment thread src/providers/adapters/gmail/mime.ts
zaridan and others added 3 commits July 10, 2026 21:03
Codex confirm (round 5): Subject was the last unbounded header — mimetext
emits it as one non-folded RFC-2047 encoded-word line, so a pathological
inbound subject could exceed 998 octets. Truncate the subject to a 600-octet
budget (safe after base64 ~1.37x + overhead), on a char boundary. Codex also
confirmed mimetext folds multi-recipient To/Cc itself, so those are fine.

Every header line and body line is now octet-bounded under RFC 5322's 998
limit, and every header atom is CRLF/control-guarded. 262 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
…n-guard regex

The control characters are the match target — the rule exists to catch
accidental ones. Fixes the Quality CI failure on this branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
…with a timeout

Review findings (CodeRabbit):
- Subject is attacker-influenced (derived from the inbound Subject), so it
  is sanitized — control chars stripped to spaces — rather than thrown on
  (a throw would let one crafted subject block every reply). The installed
  mimetext provably RFC-2047-encodes every subject (verified empirically),
  which already neutralizes CRLF; the strip makes the invariant ours and a
  regression test locks the encoded-word behavior against library upgrades.
- users.messages.send now rides AbortSignal.timeout (default 30s,
  configurable) so a stalled Gmail API can't hang send() unboundedly.
- Review-tool attributions in test section comments replaced with the
  technical rationale (vocabulary rule: no AI-actor names in code prose).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqG66PPZreBrj17VbAqe3b
@zaridan
zaridan merged commit f56534e into main Jul 11, 2026
5 checks passed
@zaridan
zaridan deleted the feat/ht-19-gmail-sender branch August 2, 2026 19:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant