feat(providers): Gmail EmailSender adapter (HT-19) - #17
Conversation
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
|
Note Reviews pausedIt 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 Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a Gmail ChangesGmail EmailSender adapter
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
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
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
⛔ Files ignored due to path filters (1)
package-lock.jsonis excluded by!**/package-lock.json
📒 Files selected for processing (8)
package.jsonsrc/providers/README.mdsrc/providers/adapters/gmail/index.tssrc/providers/adapters/gmail/mime.test.tssrc/providers/adapters/gmail/mime.tssrc/providers/adapters/gmail/sender.test.tssrc/providers/adapters/gmail/sender.tsvitest.config.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
There was a problem hiding this comment.
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
📒 Files selected for processing (4)
src/providers/adapters/gmail/mime.test.tssrc/providers/adapters/gmail/mime.tssrc/providers/adapters/gmail/sender.test.tssrc/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
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
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
src/providers/adapters/gmail/mime.test.tssrc/providers/adapters/gmail/mime.ts
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
Closes HT-19 — the first concrete
EmailSender, which puts real mail on the wire via Gmailusers.messages.sendthrough 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 customMessage-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 forhelp@resonantiq.appis a small deploy-time follow-up.What's here
mime.ts—buildRawMessagevia mimetext (MIT).Message-ID/In-Reply-To/Referencesverbatim;text+html→multipart/alternative.sender.ts—createGmailEmailSender({ getAccessToken, fetchImpl?, userId? }): builds MIME, base64url-encodes, POSTs with a bearer token, returnsproviderMessageId; throws on non-2xx sosendReplymarks send-failed.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:
Message-IDor a customer address containing\r\ncould inject a header (\r\nBcc:). →assertHeaderSaferejects any control/newline in every externally-influenced header atom before mimetext.8bitbodies left a long HTML line/URL over RFC 5322's 998-octet limit. → base64-encode bodies, wrapped at 76.Referencesfolding: a long chain was one ~2KB line. → fold at WSP between msg-ids (mimetext preserves the folds; unfolding round-trips tojoin(' ')).Message-IDtoken into logs. → redact<ht.…>patterns.Codex confirmed the
Message-IDverbatim, 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