Skip to content

Add IMAP/SMTP email account support ("bring your own server")#3919

Open
jbecke wants to merge 2 commits into
mainfrom
claude/imap-smtp-support-hvt8e3
Open

Add IMAP/SMTP email account support ("bring your own server")#3919
jbecke wants to merge 2 commits into
mainfrom
claude/imap-smtp-support-hvt8e3

Conversation

@jbecke

@jbecke jbecke commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

This PR adds support for connecting arbitrary IMAP/SMTP email servers as inboxes, complementing the existing Gmail OAuth integration. Users can now add non-Gmail email accounts by providing server credentials.

Summary

Implements a complete IMAP/SMTP integration layer including:

  • Server connection and credential management with encrypted storage
  • Poll-based inbox synchronization (since IMAP lacks push notifications)
  • RFC 5322 message parsing and mapping to the service model
  • Label/flag synchronization (read state, starred, draft)
  • Thread resolution via References/In-Reply-To headers
  • Outbound message sending via SMTP

Key Changes

Backend Infrastructure:

  • New imap_smtp_client crate for IMAP/SMTP protocol handling with TLS support
  • Credential encryption module (email_utils::credential_crypto) using AES-256-GCM
  • Database schema additions for IMAP/SMTP credentials and per-folder sync state
  • New email_db_client::imap module for credential and folder state persistence

API & Link Management:

  • POST /email/links/imap endpoint to create IMAP/SMTP links with server verification
  • Support for both implicit TLS (port 993) and STARTTLS (port 143) connections
  • Credential validation before persistence

Message Synchronization:

  • imap_poll operation for incremental inbox/sent folder syncing using UIDVALIDITY and high-water marks
  • RFC 5322 message parsing with mailparse crate
  • Automatic system label synthesis (INBOX, SENT, UNREAD, STARRED, DRAFT)
  • Thread resolution by matching Message-ID against References/In-Reply-To headers
  • Snippet generation and HTML sanitization

Label/Flag Operations:

  • imap_modify_labels operation to push read state changes to IMAP \Seen flag
  • Sent folder detection with fallback candidates

Scheduled Message Sending:

  • SMTP integration for outbound messages from IMAP/SMTP links
  • Provider-aware auth resolution (Gmail vs IMAP/SMTP)

Frontend:

  • New IMAP/SMTP connection dialog in Account settings
  • Updated link management UI with provider-specific handling
  • Generated TypeScript client types for new API endpoints

Configuration:

  • Environment variable EMAIL_CREDENTIALS_ENCRYPTION_KEY for credential encryption
  • Deployment-level feature flag (returns 501 when unconfigured)

Implementation Details

  • Messages are identified by RFC 5322 Message-ID (bracketed) as both provider_id and global_id
  • Folder sync state tracks UIDVALIDITY and last-seen UID per folder to detect server resets
  • Initial sync seeds folders with 50 most recent messages; subsequent polls fetch up to 200 new messages
  • Sent folder detection tries common names when server doesn't advertise \Sent special-use mailbox
  • Passwords stored as AES-256-GCM ciphertext; only ciphertext bytes touch the database
  • Link manager enqueues ImapPoll operations on configured refresh schedule

https://claude.ai/code/session_01MPjkey3yUVNtiPYuk8LKkT

claude added 2 commits June 9, 2026 18:44
Adds a second email provider alongside Gmail so users can connect any
mailbox that speaks IMAP (receive) and SMTP (send).

Backend:
- Migration: IMAP_SMTP provider enum value, email_imap_smtp_credentials
  (passwords encrypted at rest with AES-256-GCM via the new
  email_utils::credential_crypto module, keyed by
  EMAIL_CREDENTIALS_ENCRYPTION_KEY), and email_imap_folder_states
  (per-folder UIDVALIDITY / last-seen-UID sync state)
- New imap_smtp_client crate: rustls-based IMAP client (implicit TLS and
  STARTTLS, EXAMINE, incremental UID fetches with BODY.PEEK[],
  special-use sent-folder detection, APPEND, \Seen flag updates) and
  SMTP submission via mail-send
- POST /email/links/imap verifies both server connections before
  persisting anything, stores encrypted credentials, and enqueues an
  initial sync
- New ImapPoll inbox-sync operation: seeds recent messages per folder,
  then polls incrementally; threads by References/In-Reply-To, maps
  folders/flags onto Gmail-compatible system labels, and reuses the
  existing search/notification/contacts/CRM fan-out. Polls are
  scheduled through the link refresh cycle and on-demand resync
- Scheduled sends dispatch on provider: SMTP submit plus best-effort
  APPEND of the sent copy into the IMAP sent folder
- Read/unread changes are mirrored to the server as \Seen flags via the
  ops worker; link deletion skips Gmail-only teardown for IMAP links
- Gmail-only surfaces (attachment re-download, signature fetch) fail
  gracefully with clear messages for IMAP links

Frontend:
- "Connect IMAP/SMTP account" dialog in settings -> Account with
  per-server host/port/security fields and backend error surfacing
- Regenerated email-service client (UserProvider.IMAP_SMTP,
  ConnectionSecurity, create-link request/response types)

Infra:
- Optional email_credentials_encryption_key_secret_name stack config
  provisioning EMAIL_CREDENTIALS_ENCRYPTION_KEY for the service and
  worker containers; IMAP/SMTP linking stays disabled when unset

Known v1 limitations: no historical backfill beyond the seed window,
forwarded attachments are dropped (with a warning) on SMTP sends, and
attachment bodies aren't re-downloadable from IMAP yet.

https://claude.ai/code/session_01MPjkey3yUVNtiPYuk8LKkT
Collapse nested ifs into let-chains and allow the extra context arg on
the gmail_ops/scheduled workers, matching the inbox_sync worker.

https://claude.ai/code/session_01MPjkey3yUVNtiPYuk8LKkT
@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Added support for connecting personal email accounts via IMAP/SMTP
    • New inbox connection dialog in account settings for configuring custom email servers
    • Full email sync capabilities for personal IMAP servers
    • Email sending support through configured SMTP servers
    • Secure encrypted storage of email server credentials

Walkthrough

This PR adds comprehensive support for IMAP/SMTP email servers as a new provider alongside Gmail. It introduces credential encryption infrastructure, a new IMAP/SMTP client, message mapping and incremental syncing, label mirroring, provider-aware send operations, and a frontend UI for connecting arbitrary email servers. The implementation includes database schema changes, API endpoints, background workers for polling, and wiring of encryption keys throughout the service architecture.

@github-actions

github-actions Bot commented Jun 9, 2026

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 14

🧹 Nitpick comments (2)
rust/cloud-storage/email_db_client/src/imap/test.rs (1)

102-124: ⚡ Quick win

Add a regression assertion for stale cursor writes (same UIDVALIDITY).

Please add a case where a second upsert writes a lower last_seen_uid with unchanged uid_validity, and assert the stored value remains the higher one.

🤖 Prompt for 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.

In `@rust/cloud-storage/email_db_client/src/imap/test.rs` around lines 102 - 124,
Add a regression assertion in the folder_state_upsert_and_fetch test to ensure
stale cursor writes (same uid_validity but lower last_seen_uid) are ignored:
after the existing UIDVALIDITY-change check, call upsert_folder_state(&pool,
link.id, "INBOX", 101, 1).await? (same uid_validity 101 but last_seen_uid lower
than current 0->or if current is 0 use a higher-first then lower-second—ensure
you create the scenario by first upserting a higher last_seen_uid for "INBOX"
with uid_validity 101), then call fetch_folder_states(&pool, link.id).await? and
assert that the stored last_seen_uid for the INBOX remains the higher value
(i.e., unchanged) to prevent regression; reference upsert_folder_state and
fetch_folder_states to locate where to add this assertion.
rust/cloud-storage/imap_smtp_client/src/imap.rs (1)

298-301: ⚡ Quick win

Prefer .inspect_err for logging on Result.

As per coding guidelines, use .inspect_err(...) instead of if let Err(e) for log-side effects on errors.
As per coding guidelines, "Prefer using .inspect_err instead of if let Err(e) to perform logging on errors".

🤖 Prompt for 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.

In `@rust/cloud-storage/imap_smtp_client/src/imap.rs` around lines 298 - 301, The
logout method currently uses an if-let to log errors from
self.inner.logout().await; change this to call .inspect_err(...) on the Result
returned by self.inner.logout().await so the logging is a side-effect and the
method can await the future cleanly (e.g., await the call and chain
.inspect_err(|e| tracing::debug!(error = ?e, "IMAP logout failed"))); update the
pub async fn logout(&mut self) implementation to perform the await and use
.inspect_err for error logging instead of the if let Err(e) pattern.

Source: Coding guidelines

🤖 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 `@js/app/packages/service-clients/service-email/openapi.json`:
- Around line 4712-4717: The OpenAPI schema for ServerSettingsInput.port
currently permits 0 and values >65535 which the backend (u16) can't represent;
update the "port" property in service-email openapi.json
(ServerSettingsInput.port) to restrict the valid TCP port range by setting
"minimum": 1 and adding "maximum": 65535 (keeping "type":"integer" and
"format":"int32") so the client contract matches the backend.

In `@rust/cloud-storage/email_db_client/src/imap/mod.rs`:
- Around line 180-184: The ON CONFLICT upsert currently overwrites last_seen_uid
and can move the cursor backward when out-of-order writes arrive; modify the DO
UPDATE to only advance last_seen_uid when the uid_validity is unchanged by using
a conditional/GREATEST expression. Concretely, in the INSERT ... ON CONFLICT
(link_id, folder) DO UPDATE SET block update uid_validity =
EXCLUDED.uid_validity, last_seen_uid = CASE WHEN EXCLUDED.uid_validity =
uid_validity THEN GREATEST(last_seen_uid, EXCLUDED.last_seen_uid) ELSE
EXCLUDED.last_seen_uid END, updated_at = NOW() so last_seen_uid never regresses
for the same uid_validity but is replaced when uid_validity changes (use the
same column names uid_validity, last_seen_uid, EXCLUDED.* shown in the diff).

In `@rust/cloud-storage/email_service/src/api/email/attachments/get.rs`:
- Around line 131-142: The OpenAPI annotations for the attachment GET handler
need to include a 501 response because the handler in email/attachments/get.rs
returns StatusCode::NOT_IMPLEMENTED for IMAP links; update the
#[utoipa::path(... responses = [...])] on the same function (the get attachment
handler) to add a response entry for 501 Not Implemented with a matching
schema/description (e.g., same ErrorResponse type/description used for other
error responses) so the generated spec reflects the new branch that returns
NOT_IMPLEMENTED for UserProvider::ImapSmtp.

In `@rust/cloud-storage/email_service/src/api/email/links/imap.rs`:
- Around line 225-252: After tx.commit() succeeds, don't propagate errors from
ctx.sqs_client.enqueue_gmail_inbox_sync_notification back to the API; instead
treat enqueue failures as best-effort (log the full error and link id) and
return success to the client. Locate the code that calls
enqueue_gmail_inbox_sync_notification (uses InboxSyncPubsubMessage,
InboxSyncOperation::ImapPoll, ImapPollPayload) and change the await error
handling to inspect_err/log the error with context (including inserted.id) and
swallow it (e.g., .ok() or similar) so the endpoint returns the committed link
response rather than a 500 when enqueue fails.
- Around line 51-63: CreateImapLinkError::into_response should return an
ErrorResponse JSON body (not a plain string) to match the API contract: keep the
existing status_code logic in into_response, but construct the documented
ErrorResponse (e.g., ErrorResponse { message: self.to_string(), /* other fields
if any */ }) and return (status_code, Json(error_response)).into_response() or
otherwise serialize the ErrorResponse before converting to a Response; update
the import/use to reference the ErrorResponse type and remove the raw string
return.

In
`@rust/cloud-storage/email_service/src/pubsub/gmail_ops/operations/imap_modify_labels.rs`:
- Around line 61-75: The loop over FLAG_TARGET_FOLDERS calls
session.set_seen_by_message_id(...) but currently only logs Err cases and still
returns Ok(()), which acks the message even if all folder updates failed; change
the logic so the function only returns Ok(()) when at least one call succeeded
(the existing found = true / break branch), and if no folder succeeded (found is
false) return an Err that preserves or wraps the last encountered error (or
constructs a meaningful error including payload.provider_message_id and
marks_read) so the message is not acknowledged and can be retried; apply the
same fix to the analogous block handling labels (the 79-88 section) so failures
across all targets are propagated rather than silently logged.

In
`@rust/cloud-storage/email_service/src/pubsub/inbox_sync/operations/imap_poll.rs`:
- Around line 256-266: Change the thread-resolution order to try resolving by
provider_thread_id first, then fall back to ancestor/global-id lookup: after
computing thread_provider_id and setting message.provider_thread_id, call the DB
lookup that resolves a thread by provider_thread_id (use the same ctx.db and
link.id context) and if it returns a thread id use it; only if that lookup
returns none, call email_db_client::messages::get::get_thread_id_by_global_ids
with lookup_ids (the ancestor_global_ids plus global_id) as currently
implemented. Ensure you reference/keep the existing variables
thread_provider_id, message.provider_thread_id, ancestor_global_ids, lookup_ids
and the function get_thread_id_by_global_ids when adding the new provider-thread
lookup.
- Around line 164-223: The code currently always upserts new_last_seen_uid even
if ingest_message failed, which can skip failed UIDs; change the loop so it
tracks the highest successfully ingested UID (e.g., last_success_uid) and stop
advancing past a failure: when ingest_message returns Err for a fetched message,
do not continue advancing or include subsequent messages (either break or set a
had_error flag and stop updating last_success_uid), and then call
email_db_client::imap::upsert_folder_state with last_success_uid (or the
previous last_seen UID) instead of the precomputed new_last_seen_uid; update
references in the loop around ingest_message, map_imap_message_to_service, and
the upsert_folder_state call to use this guarded last_success_uid.

In `@rust/cloud-storage/imap_smtp_client/src/imap.rs`:
- Around line 63-103: Wrap all network/handshake awaits in connect with explicit
timeouts: use tokio::time::timeout (or a configurable timeout value) around
connect_tls, read_greeting(&mut client, ...), TcpStream::connect,
plain_client.run_command_and_check_ok("STARTTLS", ...),
crate::tls::upgrade_tls(...), and the client.login(...) future so each can fail
fast; when a timeout elapses convert the timeout error into an anyhow::Error
with context mentioning the operation and server (e.g., "IMAP connect to {}:{}
timed out", "IMAP STARTTLS negotiation timed out", "IMAP login to {}:{} timed
out") so callers can distinguish timeout vs other failures and avoid unbounded
awaits.
- Around line 175-176: Guard against count == 0 before doing count - 1 to avoid
underflow: check if count == 0 and return/skip the fetch (or otherwise produce
an empty/no-op range) instead of computing start; otherwise compute start using
status.exists.saturating_sub(count - 1).max(1) and build range =
format!("{}:{}", start, status.exists). Make the change where start and range
are computed (variables status.exists, count, start, range) so no subtraction
occurs when count is zero.

In `@rust/cloud-storage/imap_smtp_client/src/smtp.rs`:
- Around line 116-120: Wrap the unbounded network calls connect(...) and
client.send(envelope).await in timeouts (e.g., tokio::time::timeout) so a
slow/blocked SMTP server cannot stall processing; use a configured Duration
(from settings or a sensible constant) for both the connect and send operations,
map a timeout result into an error with context (preserving the existing
with_context for send and adding context for connect) and ensure the timeout
error is returned/propagated the same way as other failures so callers of
connect(...) and client.send(...) (symbols: connect and client.send) receive a
clear timeout error.
- Around line 92-96: The code currently calls message.attachments.take() which
mutates the Message before the SMTP send; change this to iterate over the
attachments without consuming them (e.g., use
message.attachments.as_ref()/iter() and clone the attachment data or the minimal
fields needed) and call builder = builder.attachment(...) for each cloned
attachment so the original message.attachments remains intact if the build/send
fails; update the loop near the builder usage in smtp.rs to use non-consuming
iteration instead of message.attachments.take().

In
`@rust/cloud-storage/macro_db_client/migrations/20260609173003_imap_smtp_support.sql`:
- Around line 20-45: Add DB-level CHECK constraints to validate port and UID
fields: in the email_imap_smtp_credentials table add CHECKs on imap_port and
smtp_port to ensure values are between 1 and 65535; in the
email_imap_folder_states table add CHECKs on uid_validity and last_seen_uid to
ensure they are non-negative (>= 0) (or an appropriate upper bound for bigint if
desired). You can implement these by adding CHECK clauses to the CREATE TABLE
statements for email_imap_smtp_credentials and email_imap_folder_states or by
issuing ALTER TABLE ... ADD CONSTRAINT statements referencing the columns
imap_port, smtp_port, uid_validity, and last_seen_uid and giving each constraint
a clear name.

In `@rust/cloud-storage/models_email/src/email/service/imap.rs`:
- Around line 31-38: ServerSettings derives Debug but contains plaintext
password; implement a custom Debug (or remove the derived Debug) for
ServerSettings to redact the password field (e.g., show "<redacted>") and do the
same for ImapSmtpCredentials so no credentials are printed in logs; locate the
ServerSettings struct and ImapSmtpCredentials type and replace the derived Debug
with a manual impl that omits or masks password/secret fields.

---

Nitpick comments:
In `@rust/cloud-storage/email_db_client/src/imap/test.rs`:
- Around line 102-124: Add a regression assertion in the
folder_state_upsert_and_fetch test to ensure stale cursor writes (same
uid_validity but lower last_seen_uid) are ignored: after the existing
UIDVALIDITY-change check, call upsert_folder_state(&pool, link.id, "INBOX", 101,
1).await? (same uid_validity 101 but last_seen_uid lower than current 0->or if
current is 0 use a higher-first then lower-second—ensure you create the scenario
by first upserting a higher last_seen_uid for "INBOX" with uid_validity 101),
then call fetch_folder_states(&pool, link.id).await? and assert that the stored
last_seen_uid for the INBOX remains the higher value (i.e., unchanged) to
prevent regression; reference upsert_folder_state and fetch_folder_states to
locate where to add this assertion.

In `@rust/cloud-storage/imap_smtp_client/src/imap.rs`:
- Around line 298-301: The logout method currently uses an if-let to log errors
from self.inner.logout().await; change this to call .inspect_err(...) on the
Result returned by self.inner.logout().await so the logging is a side-effect and
the method can await the future cleanly (e.g., await the call and chain
.inspect_err(|e| tracing::debug!(error = ?e, "IMAP logout failed"))); update the
pub async fn logout(&mut self) implementation to perform the await and use
.inspect_err for error logging instead of the if let Err(e) pattern.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c5783d87-c541-4521-b689-2a5faf7f7aca

📥 Commits

Reviewing files that changed from the base of the PR and between e9c10ec and 32ab2d3.

⛔ Files ignored due to path filters (30)
  • js/app/packages/service-clients/service-email/generated/client.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/connectionSecurity.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/createImapLinkRequest.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/createImapLinkResponse.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/index.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/resyncResponse.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/resyncResponseBackfillJobId.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/serverSettingsInput.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-email/generated/schemas/userProvider.ts is excluded by !**/generated/**
  • rust/cloud-storage/.sqlx/query-15b00cf9b57c2c077f7e55bd25c267a6cf83c9efdc10ba23981f7b85847881c3.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-2ba42e1b8b6e432c5cbe966076ca05523a4b1f4be0798ae362a325c172b19a43.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-2c1df8ba2035cc1f63f9d566e8ac763f4d0b5e28370570971935d29237a0211a.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-45fced9bee3cf06b7f54821c6c96e3b483654160636aab0f6c5ab18f64addc99.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-4857541516ce135939f34754fde00c114cdf9ed7f203553020cf032732892e80.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-4a8b04d2fa922cd2f007241f83d6ad7e4dbcb5e7f6b0560316ca474878971e63.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-50318d6d13fb931307f2115b33d277b5298c45ed798719e792e722bd30615a95.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-6b5a41c0c33c20537ef484ad312d80da7dfa7251e2333f8981e2a76f40e623c2.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-7630425280ebc21d6c451df7474e42b3ae2d0a78219ea44d4831cbbc6ecbd2ca.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-7bdcbf27b9edb2a2efc0e30c83230f6400385b295a50b48776985089d6ae3c84.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-7f9dea3f16070c9e5c67107643cef03d9c7e1704353496d79faf02dc998bf6fa.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-8942100b50a073f4f95b37246d355dc8b17c5a105492901ad72da89438ec6601.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-8a1debc8a93f83a63f33dfd12a1eb8f707e99f0a207a9b34f27848ab362a7bff.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-94058ba40f828251432cad4a2514e4ec0dcd8d9f6517694f3c00d7c9aa8ccc82.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-95c5d4f79e729713642efcda97838eddd466c0ec2ec9f96ae2be185ef9ff1547.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-a81b373188c98bdfa52a878a659909f73796aef0302323f7930fe4d17ccfe6af.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-c588fc5c8adc1e58d7127b11242eb9f9a29d35648304934dc750040aad9e8f1c.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-c8569a737734eab039a3ca9759478b203596330442305a6f3925edf67a8b52a3.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-f4dd3bd30a64a7fd82d9a254a0fb0ea36e8607ebd4c272509afba882a3e0b572.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/.sqlx/query-f634430e82099c69b9aead94a2870bcf8a7052dce3ea4b07da7ee1586945726f.json is excluded by !**/.sqlx/**
  • rust/cloud-storage/Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (62)
  • infra/stacks/email-service/index.ts
  • js/app/packages/app/component/settings/Account.tsx
  • js/app/packages/queries/email/link.ts
  • js/app/packages/service-clients/service-email/client.ts
  • js/app/packages/service-clients/service-email/openapi.json
  • rust/cloud-storage/email/src/domain/models/link.rs
  • rust/cloud-storage/email/src/inbound/axum/thread_labels_router.rs
  • rust/cloud-storage/email/src/outbound/email_pg_repo/db_types.rs
  • rust/cloud-storage/email/src/outbound/email_pg_repo/link.rs
  • rust/cloud-storage/email_db_client/src/imap/mod.rs
  • rust/cloud-storage/email_db_client/src/imap/test.rs
  • rust/cloud-storage/email_db_client/src/lib.rs
  • rust/cloud-storage/email_db_client/src/links/types.rs
  • rust/cloud-storage/email_db_client/src/messages/get.rs
  • rust/cloud-storage/email_refresh_handler/src/handler.rs
  • rust/cloud-storage/email_service/Cargo.toml
  • rust/cloud-storage/email_service/src/api/context.rs
  • rust/cloud-storage/email_service/src/api/email/attachments/get.rs
  • rust/cloud-storage/email_service/src/api/email/links/imap.rs
  • rust/cloud-storage/email_service/src/api/email/links/list.rs
  • rust/cloud-storage/email_service/src/api/email/links/mod.rs
  • rust/cloud-storage/email_service/src/api/email/links/resync.rs
  • rust/cloud-storage/email_service/src/api/middleware/link.rs
  • rust/cloud-storage/email_service/src/api/swagger.rs
  • rust/cloud-storage/email_service/src/bin/pubsub_workers/pubsub_workers.rs
  • rust/cloud-storage/email_service/src/config.rs
  • rust/cloud-storage/email_service/src/convert/imap.rs
  • rust/cloud-storage/email_service/src/convert/imap/test.rs
  • rust/cloud-storage/email_service/src/convert/mod.rs
  • rust/cloud-storage/email_service/src/main.rs
  • rust/cloud-storage/email_service/src/pubsub/backfill/worker.rs
  • rust/cloud-storage/email_service/src/pubsub/context.rs
  • rust/cloud-storage/email_service/src/pubsub/gmail_ops/operations/imap_modify_labels.rs
  • rust/cloud-storage/email_service/src/pubsub/gmail_ops/operations/mod.rs
  • rust/cloud-storage/email_service/src/pubsub/gmail_ops/process.rs
  • rust/cloud-storage/email_service/src/pubsub/gmail_ops/worker.rs
  • rust/cloud-storage/email_service/src/pubsub/inbox_sync/operations/imap_poll.rs
  • rust/cloud-storage/email_service/src/pubsub/inbox_sync/operations/mod.rs
  • rust/cloud-storage/email_service/src/pubsub/inbox_sync/operations/upsert_message.rs
  • rust/cloud-storage/email_service/src/pubsub/inbox_sync/process.rs
  • rust/cloud-storage/email_service/src/pubsub/inbox_sync/worker.rs
  • rust/cloud-storage/email_service/src/pubsub/link_manager/process.rs
  • rust/cloud-storage/email_service/src/pubsub/scheduled/context.rs
  • rust/cloud-storage/email_service/src/pubsub/scheduled/process.rs
  • rust/cloud-storage/email_service/src/pubsub/scheduled/worker.rs
  • rust/cloud-storage/email_service/src/util/imap/mod.rs
  • rust/cloud-storage/email_service/src/util/mod.rs
  • rust/cloud-storage/email_utils/Cargo.toml
  • rust/cloud-storage/email_utils/src/credential_crypto.rs
  • rust/cloud-storage/email_utils/src/credential_crypto/test.rs
  • rust/cloud-storage/email_utils/src/lib.rs
  • rust/cloud-storage/imap_smtp_client/Cargo.toml
  • rust/cloud-storage/imap_smtp_client/src/imap.rs
  • rust/cloud-storage/imap_smtp_client/src/lib.rs
  • rust/cloud-storage/imap_smtp_client/src/smtp.rs
  • rust/cloud-storage/imap_smtp_client/src/tls.rs
  • rust/cloud-storage/macro_db_client/migrations/20260609173003_imap_smtp_support.sql
  • rust/cloud-storage/models_email/src/email/api/link.rs
  • rust/cloud-storage/models_email/src/email/service/imap.rs
  • rust/cloud-storage/models_email/src/email/service/link.rs
  • rust/cloud-storage/models_email/src/email/service/mod.rs
  • rust/cloud-storage/models_email/src/gmail/inbox_sync.rs

Comment on lines +4712 to +4717
"port": {
"type": "integer",
"format": "int32",
"description": "Server port, e.g. 993 for IMAP over TLS, 465/587 for SMTP.",
"minimum": 0
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Constrain ServerSettingsInput.port to the backend-supported TCP range.

This schema currently allows values (0 and >65535) that the backend model cannot represent (u16), creating a client/server contract break.

💡 Proposed fix
       "port": {
         "type": "integer",
         "format": "int32",
         "description": "Server port, e.g. 993 for IMAP over TLS, 465/587 for SMTP.",
-        "minimum": 0
+        "minimum": 1,
+        "maximum": 65535
       },
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"port": {
"type": "integer",
"format": "int32",
"description": "Server port, e.g. 993 for IMAP over TLS, 465/587 for SMTP.",
"minimum": 0
},
"port": {
"type": "integer",
"format": "int32",
"description": "Server port, e.g. 993 for IMAP over TLS, 465/587 for SMTP.",
"minimum": 1,
"maximum": 65535
},
🤖 Prompt for 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.

In `@js/app/packages/service-clients/service-email/openapi.json` around lines 4712
- 4717, The OpenAPI schema for ServerSettingsInput.port currently permits 0 and
values >65535 which the backend (u16) can't represent; update the "port"
property in service-email openapi.json (ServerSettingsInput.port) to restrict
the valid TCP port range by setting "minimum": 1 and adding "maximum": 65535
(keeping "type":"integer" and "format":"int32") so the client contract matches
the backend.

Comment on lines +180 to +184
ON CONFLICT (link_id, folder)
DO UPDATE SET
uid_validity = EXCLUDED.uid_validity,
last_seen_uid = EXCLUDED.last_seen_uid,
updated_at = NOW()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent last_seen_uid from regressing on conflict updates.

Current upserts can move the folder cursor backward when writes arrive out of order for the same uid_validity, which causes avoidable reprocessing.

Suggested conflict update logic
         ON CONFLICT (link_id, folder)
         DO UPDATE SET
             uid_validity = EXCLUDED.uid_validity,
-            last_seen_uid = EXCLUDED.last_seen_uid,
+            last_seen_uid = CASE
+                WHEN email_imap_folder_states.uid_validity = EXCLUDED.uid_validity
+                    THEN GREATEST(email_imap_folder_states.last_seen_uid, EXCLUDED.last_seen_uid)
+                ELSE EXCLUDED.last_seen_uid
+            END,
             updated_at = NOW()
🤖 Prompt for 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.

In `@rust/cloud-storage/email_db_client/src/imap/mod.rs` around lines 180 - 184,
The ON CONFLICT upsert currently overwrites last_seen_uid and can move the
cursor backward when out-of-order writes arrive; modify the DO UPDATE to only
advance last_seen_uid when the uid_validity is unchanged by using a
conditional/GREATEST expression. Concretely, in the INSERT ... ON CONFLICT
(link_id, folder) DO UPDATE SET block update uid_validity =
EXCLUDED.uid_validity, last_seen_uid = CASE WHEN EXCLUDED.uid_validity =
uid_validity THEN GREATEST(last_seen_uid, EXCLUDED.last_seen_uid) ELSE
EXCLUDED.last_seen_uid END, updated_at = NOW() so last_seen_uid never regresses
for the same uid_validity but is replaced when uid_validity changes (use the
same column names uid_validity, last_seen_uid, EXCLUDED.* shown in the diff).

Comment on lines +131 to +142
// On-demand fetch is Gmail-only for now: IMAP attachments aren't
// re-downloadable yet (no per-part provider id is stored), so fail
// with a clear message instead of a token error.
if link.provider == models_email::service::link::UserProvider::ImapSmtp {
return Err((
StatusCode::NOT_IMPLEMENTED,
Json(ErrorResponse {
message: "attachment downloads are not yet supported for IMAP inboxes".into(),
}),
)
.into_response());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add the new 501 response to this endpoint’s OpenAPI annotation.

This handler now returns StatusCode::NOT_IMPLEMENTED for IMAP inboxes, but the #[utoipa::path(...responses...)] list for this route doesn’t declare 501.

Proposed fix
     responses(
             (status = 200, body=GetAttachmentResponse),
             (status = 400, body=ErrorResponse),
             (status = 401, body=ErrorResponse),
             (status = 404, body=ErrorResponse),
             (status = 500, body=ErrorResponse),
+            (status = 501, body=ErrorResponse),
     )
 )]
🤖 Prompt for 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.

In `@rust/cloud-storage/email_service/src/api/email/attachments/get.rs` around
lines 131 - 142, The OpenAPI annotations for the attachment GET handler need to
include a 501 response because the handler in email/attachments/get.rs returns
StatusCode::NOT_IMPLEMENTED for IMAP links; update the #[utoipa::path(...
responses = [...])] on the same function (the get attachment handler) to add a
response entry for 501 Not Implemented with a matching schema/description (e.g.,
same ErrorResponse type/description used for other error responses) so the
generated spec reflects the new branch that returns NOT_IMPLEMENTED for
UserProvider::ImapSmtp.

Comment on lines +51 to +63
impl IntoResponse for CreateImapLinkError {
fn into_response(self) -> Response {
let status_code = match &self {
CreateImapLinkError::AlreadyInitialized
| CreateImapLinkError::BadRequest(_)
| CreateImapLinkError::ImapVerificationFailed(_)
| CreateImapLinkError::SmtpVerificationFailed(_) => StatusCode::BAD_REQUEST,
CreateImapLinkError::NotConfigured => StatusCode::NOT_IMPLEMENTED,
CreateImapLinkError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};

(status_code, self.to_string()).into_response()
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Return ErrorResponse JSON to match the documented API contract.

CreateImapLinkError::into_response currently emits a plain string, but this endpoint is declared with ErrorResponse bodies. This breaks runtime/spec consistency for generated clients.

Proposed fix
 impl IntoResponse for CreateImapLinkError {
     fn into_response(self) -> Response {
         let status_code = match &self {
             CreateImapLinkError::AlreadyInitialized
             | CreateImapLinkError::BadRequest(_)
             | CreateImapLinkError::ImapVerificationFailed(_)
             | CreateImapLinkError::SmtpVerificationFailed(_) => StatusCode::BAD_REQUEST,
             CreateImapLinkError::NotConfigured => StatusCode::NOT_IMPLEMENTED,
             CreateImapLinkError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
         };
-
-        (status_code, self.to_string()).into_response()
+        (
+            status_code,
+            Json(ErrorResponse {
+                message: self.to_string().into(),
+            }),
+        )
+            .into_response()
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
impl IntoResponse for CreateImapLinkError {
fn into_response(self) -> Response {
let status_code = match &self {
CreateImapLinkError::AlreadyInitialized
| CreateImapLinkError::BadRequest(_)
| CreateImapLinkError::ImapVerificationFailed(_)
| CreateImapLinkError::SmtpVerificationFailed(_) => StatusCode::BAD_REQUEST,
CreateImapLinkError::NotConfigured => StatusCode::NOT_IMPLEMENTED,
CreateImapLinkError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
(status_code, self.to_string()).into_response()
}
impl IntoResponse for CreateImapLinkError {
fn into_response(self) -> Response {
let status_code = match &self {
CreateImapLinkError::AlreadyInitialized
| CreateImapLinkError::BadRequest(_)
| CreateImapLinkError::ImapVerificationFailed(_)
| CreateImapLinkError::SmtpVerificationFailed(_) => StatusCode::BAD_REQUEST,
CreateImapLinkError::NotConfigured => StatusCode::NOT_IMPLEMENTED,
CreateImapLinkError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
(
status_code,
Json(ErrorResponse {
message: self.to_string().into(),
}),
)
.into_response()
}
}
🤖 Prompt for 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.

In `@rust/cloud-storage/email_service/src/api/email/links/imap.rs` around lines 51
- 63, CreateImapLinkError::into_response should return an ErrorResponse JSON
body (not a plain string) to match the API contract: keep the existing
status_code logic in into_response, but construct the documented ErrorResponse
(e.g., ErrorResponse { message: self.to_string(), /* other fields if any */ })
and return (status_code, Json(error_response)).into_response() or otherwise
serialize the ErrorResponse before converting to a Response; update the
import/use to reference the ErrorResponse type and remove the raw string return.

Comment on lines +225 to +252
tx.commit()
.await
.context("failed to commit link transaction")?;

// Record link creation in history table for tracking (best-effort)
email_db_client::links_history::insert::insert_email_link_history(
&ctx.db,
inserted.id,
&inserted.fusionauth_user_id,
inserted.email_address.0.as_ref(),
inserted.provider,
)
.await
.inspect_err(|e| {
tracing::error!(error=?e, link_id=?inserted.id, "Failed to insert email link history");
})
.ok();

// Seed the inbox with recent mail; subsequent polls are scheduled by the
// link refresh cycle.
ctx.sqs_client
.enqueue_gmail_inbox_sync_notification(InboxSyncPubsubMessage {
link_id: inserted.id,
operation: InboxSyncOperation::ImapPoll(ImapPollPayload { initial: true }),
})
.await
.context("failed to enqueue initial IMAP sync")?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t return 500 after the link is already committed.

After commit succeeds, enqueue failure returns an error response. That leaves a saved link with a failed API result, which creates confusing retries and “already connected” follow-ups.

Proposed fix
-    ctx.sqs_client
-        .enqueue_gmail_inbox_sync_notification(InboxSyncPubsubMessage {
-            link_id: inserted.id,
-            operation: InboxSyncOperation::ImapPoll(ImapPollPayload { initial: true }),
-        })
-        .await
-        .context("failed to enqueue initial IMAP sync")?;
+    if let Err(e) = ctx
+        .sqs_client
+        .enqueue_gmail_inbox_sync_notification(InboxSyncPubsubMessage {
+            link_id: inserted.id,
+            operation: InboxSyncOperation::ImapPoll(ImapPollPayload { initial: true }),
+        })
+        .await
+    {
+        tracing::error!(error=?e, link_id=?inserted.id, "failed to enqueue initial IMAP sync");
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
tx.commit()
.await
.context("failed to commit link transaction")?;
// Record link creation in history table for tracking (best-effort)
email_db_client::links_history::insert::insert_email_link_history(
&ctx.db,
inserted.id,
&inserted.fusionauth_user_id,
inserted.email_address.0.as_ref(),
inserted.provider,
)
.await
.inspect_err(|e| {
tracing::error!(error=?e, link_id=?inserted.id, "Failed to insert email link history");
})
.ok();
// Seed the inbox with recent mail; subsequent polls are scheduled by the
// link refresh cycle.
ctx.sqs_client
.enqueue_gmail_inbox_sync_notification(InboxSyncPubsubMessage {
link_id: inserted.id,
operation: InboxSyncOperation::ImapPoll(ImapPollPayload { initial: true }),
})
.await
.context("failed to enqueue initial IMAP sync")?;
tx.commit()
.await
.context("failed to commit link transaction")?;
// Record link creation in history table for tracking (best-effort)
email_db_client::links_history::insert::insert_email_link_history(
&ctx.db,
inserted.id,
&inserted.fusionauth_user_id,
inserted.email_address.0.as_ref(),
inserted.provider,
)
.await
.inspect_err(|e| {
tracing::error!(error=?e, link_id=?inserted.id, "Failed to insert email link history");
})
.ok();
// Seed the inbox with recent mail; subsequent polls are scheduled by the
// link refresh cycle.
ctx.sqs_client
.enqueue_gmail_inbox_sync_notification(InboxSyncPubsubMessage {
link_id: inserted.id,
operation: InboxSyncOperation::ImapPoll(ImapPollPayload { initial: true }),
})
.await
.inspect_err(|e| {
tracing::error!(error=?e, link_id=?inserted.id, "failed to enqueue initial IMAP sync");
})
.ok();
🤖 Prompt for 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.

In `@rust/cloud-storage/email_service/src/api/email/links/imap.rs` around lines
225 - 252, After tx.commit() succeeds, don't propagate errors from
ctx.sqs_client.enqueue_gmail_inbox_sync_notification back to the API; instead
treat enqueue failures as best-effort (log the full error and link id) and
return success to the client. Locate the code that calls
enqueue_gmail_inbox_sync_notification (uses InboxSyncPubsubMessage,
InboxSyncOperation::ImapPoll, ImapPollPayload) and change the await error
handling to inspect_err/log the error with context (including inserted.id) and
swallow it (e.g., .ok() or similar) so the endpoint returns the committed link
response rather than a 500 when enqueue fails.

Comment on lines +175 to +176
let start = status.exists.saturating_sub(count - 1).max(1);
let range = format!("{}:{}", start, status.exists);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard zero-count fetches before subtracting.

count - 1 underflows when count == 0, which can panic in debug builds and produce incorrect range math otherwise.

Suggested fix
     pub async fn fetch_recent_messages(
         &mut self,
         status: &FolderStatus,
         count: u32,
     ) -> anyhow::Result<Vec<FetchedMessage>> {
+        if count == 0 {
+            return Ok(Vec::new());
+        }
         if status.exists == 0 {
             return Ok(Vec::new());
         }

         // Sequence numbers are contiguous (1..=exists), so the last `count`
         // sequence numbers are the most recent messages.
         let start = status.exists.saturating_sub(count - 1).max(1);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
let start = status.exists.saturating_sub(count - 1).max(1);
let range = format!("{}:{}", start, status.exists);
pub async fn fetch_recent_messages(
&mut self,
status: &FolderStatus,
count: u32,
) -> anyhow::Result<Vec<FetchedMessage>> {
if count == 0 {
return Ok(Vec::new());
}
if status.exists == 0 {
return Ok(Vec::new());
}
// Sequence numbers are contiguous (1..=exists), so the last `count`
// sequence numbers are the most recent messages.
let start = status.exists.saturating_sub(count - 1).max(1);
let range = format!("{}:{}", start, status.exists);
// ... rest of function
🤖 Prompt for 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.

In `@rust/cloud-storage/imap_smtp_client/src/imap.rs` around lines 175 - 176,
Guard against count == 0 before doing count - 1 to avoid underflow: check if
count == 0 and return/skip the fetch (or otherwise produce an empty/no-op range)
instead of computing start; otherwise compute start using
status.exists.saturating_sub(count - 1).max(1) and build range =
format!("{}:{}", start, status.exists). Make the change where start and range
are computed (variables status.exists, count, start, range) so no subtraction
occurs when count is zero.

Comment on lines +92 to +96
if let Some(attachments) = message.attachments.take() {
for att in attachments {
builder = builder.attachment(att.content_type, att.file_name, att.data);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not clear attachments before SMTP send succeeds.

message.attachments.take() mutates the input before the network call. If build/send fails, retries can send without attachments.

Suggested fix
-    if let Some(attachments) = message.attachments.take() {
-        for att in attachments {
-            builder = builder.attachment(att.content_type, att.file_name, att.data);
-        }
-    }
+    if let Some(attachments) = message.attachments.as_ref() {
+        for att in attachments {
+            builder = builder.attachment(
+                att.content_type.clone(),
+                att.file_name.clone(),
+                att.data.clone(),
+            );
+        }
+    }
🤖 Prompt for 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.

In `@rust/cloud-storage/imap_smtp_client/src/smtp.rs` around lines 92 - 96, The
code currently calls message.attachments.take() which mutates the Message before
the SMTP send; change this to iterate over the attachments without consuming
them (e.g., use message.attachments.as_ref()/iter() and clone the attachment
data or the minimal fields needed) and call builder = builder.attachment(...)
for each cloned attachment so the original message.attachments remains intact if
the build/send fails; update the loop near the builder usage in smtp.rs to use
non-consuming iteration instead of message.attachments.take().

Comment on lines +116 to +120
let mut client = connect(settings).await?;
client
.send(envelope)
.await
.with_context(|| format!("SMTP send failed for link_id: {}", message.link_id))?;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Bound SMTP connect/send with timeouts.

These are unbounded network operations right now; one unhealthy remote server can stall scheduled-send processing.

🤖 Prompt for 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.

In `@rust/cloud-storage/imap_smtp_client/src/smtp.rs` around lines 116 - 120, Wrap
the unbounded network calls connect(...) and client.send(envelope).await in
timeouts (e.g., tokio::time::timeout) so a slow/blocked SMTP server cannot stall
processing; use a configured Duration (from settings or a sensible constant) for
both the connect and send operations, map a timeout result into an error with
context (preserving the existing with_context for send and adding context for
connect) and ensure the timeout error is returned/propagated the same way as
other failures so callers of connect(...) and client.send(...) (symbols: connect
and client.send) receive a clear timeout error.

Comment on lines +20 to +45
CREATE TABLE public.email_imap_smtp_credentials (
link_id uuid PRIMARY KEY REFERENCES public.email_links (id) ON DELETE CASCADE,
imap_host character varying(255) NOT NULL,
imap_port integer NOT NULL,
imap_security public.email_connection_security_enum NOT NULL,
imap_username character varying(320) NOT NULL,
imap_password_ciphertext bytea NOT NULL,
smtp_host character varying(255) NOT NULL,
smtp_port integer NOT NULL,
smtp_security public.email_connection_security_enum NOT NULL,
smtp_username character varying(320) NOT NULL,
smtp_password_ciphertext bytea NOT NULL,
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL
);

-- Incremental sync state per IMAP folder, the IMAP analogue of
-- email_gmail_histories. last_seen_uid tracks the highest UID we've ingested;
-- a UIDVALIDITY change invalidates stored UIDs and forces a folder re-sync.
CREATE TABLE public.email_imap_folder_states (
link_id uuid NOT NULL REFERENCES public.email_links (id) ON DELETE CASCADE,
folder text NOT NULL,
uid_validity bigint NOT NULL,
last_seen_uid bigint DEFAULT 0 NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY (link_id, folder)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add DB-level bounds checks for ports and IMAP UID fields.

imap_port, smtp_port, uid_validity, and last_seen_uid are currently unconstrained. Invalid values can be persisted and later cause runtime sync/connect failures.

Suggested schema hardening
 CREATE TABLE public.email_imap_smtp_credentials (
     link_id uuid PRIMARY KEY REFERENCES public.email_links (id) ON DELETE CASCADE,
     imap_host character varying(255) NOT NULL,
-    imap_port integer NOT NULL,
+    imap_port integer NOT NULL CHECK (imap_port BETWEEN 1 AND 65535),
     imap_security public.email_connection_security_enum NOT NULL,
     imap_username character varying(320) NOT NULL,
     imap_password_ciphertext bytea NOT NULL,
     smtp_host character varying(255) NOT NULL,
-    smtp_port integer NOT NULL,
+    smtp_port integer NOT NULL CHECK (smtp_port BETWEEN 1 AND 65535),
     smtp_security public.email_connection_security_enum NOT NULL,
     smtp_username character varying(320) NOT NULL,
     smtp_password_ciphertext bytea NOT NULL,
     created_at timestamp with time zone DEFAULT now() NOT NULL,
     updated_at timestamp with time zone DEFAULT now() NOT NULL
 );
 
 CREATE TABLE public.email_imap_folder_states (
     link_id uuid NOT NULL REFERENCES public.email_links (id) ON DELETE CASCADE,
     folder text NOT NULL,
-    uid_validity bigint NOT NULL,
-    last_seen_uid bigint DEFAULT 0 NOT NULL,
+    uid_validity bigint NOT NULL CHECK (uid_validity > 0),
+    last_seen_uid bigint DEFAULT 0 NOT NULL CHECK (last_seen_uid >= 0),
     updated_at timestamp with time zone DEFAULT now() NOT NULL,
     PRIMARY KEY (link_id, folder)
 );
🤖 Prompt for 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.

In
`@rust/cloud-storage/macro_db_client/migrations/20260609173003_imap_smtp_support.sql`
around lines 20 - 45, Add DB-level CHECK constraints to validate port and UID
fields: in the email_imap_smtp_credentials table add CHECKs on imap_port and
smtp_port to ensure values are between 1 and 65535; in the
email_imap_folder_states table add CHECKs on uid_validity and last_seen_uid to
ensure they are non-negative (>= 0) (or an appropriate upper bound for bigint if
desired). You can implement these by adding CHECK clauses to the CREATE TABLE
statements for email_imap_smtp_credentials and email_imap_folder_states or by
issuing ALTER TABLE ... ADD CONSTRAINT statements referencing the columns
imap_port, smtp_port, uid_validity, and last_seen_uid and giving each constraint
a clear name.

Comment on lines +31 to +38
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerSettings {
pub host: String,
pub port: u16,
pub security: ConnectionSecurity,
pub username: String,
pub password: String,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Redact credentials from Debug output for IMAP/SMTP settings.

ServerSettings includes a plaintext password but derives Debug; any debug logging can expose credentials (including via ImapSmtpCredentials).

💡 Proposed fix
-#[derive(Debug, Clone, PartialEq, Eq)]
+#[derive(Clone, PartialEq, Eq)]
 pub struct ServerSettings {
     pub host: String,
     pub port: u16,
     pub security: ConnectionSecurity,
     pub username: String,
     pub password: String,
 }
+
+impl std::fmt::Debug for ServerSettings {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ServerSettings")
+            .field("host", &self.host)
+            .field("port", &self.port)
+            .field("security", &self.security)
+            .field("username", &self.username)
+            .field("password", &"<redacted>")
+            .finish()
+    }
+}

Also applies to: 41-46

🤖 Prompt for 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.

In `@rust/cloud-storage/models_email/src/email/service/imap.rs` around lines 31 -
38, ServerSettings derives Debug but contains plaintext password; implement a
custom Debug (or remove the derived Debug) for ServerSettings to redact the
password field (e.g., show "<redacted>") and do the same for ImapSmtpCredentials
so no credentials are printed in logs; locate the ServerSettings struct and
ImapSmtpCredentials type and replace the derived Debug with a manual impl that
omits or masks password/secret fields.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants