feat: add sync_max_age_days to email adapter#547
feat: add sync_max_age_days to email adapter#547connorblack wants to merge 1 commit intospacedriveapp:mainfrom
Conversation
When connecting an email account for the first time, every unread email in the inbox gets imported and treated as new. This floods the agent with stale messages it shouldn't respond to. Add a `sync_max_age_days` config option (default: 0 / no limit) that combines IMAP's UNSEEN flag with a SINCE date filter so only recent unread emails are imported. The SINCE query is evaluated server-side by the IMAP server, so it's efficient even on large mailboxes. Supported on both the default email config and per-instance configs.
WalkthroughA new Changes
Estimated code review effort🎯 2 (Simple) | ⏱️ ~12 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Adds a sync_max_age_days configuration option to the email adapter to limit IMAP polling to recent unread emails, preventing first-connect inbox floods from importing long-stale UNSEEN messages.
Changes:
- Thread
sync_max_age_daysthrough TOML schema → loaded config types → adapter runtime config. - Update IMAP polling to use
UNSEEN SINCE <date>whensync_max_age_days > 0. - Update config test fixture to include the new field.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
src/messaging/email.rs |
Adds sync_max_age_days to polling config and builds UNSEEN + SINCE IMAP search queries during polling. |
src/config/types.rs |
Adds sync_max_age_days to EmailConfig and EmailInstanceConfig. |
src/config/toml_schema.rs |
Adds sync_max_age_days to TOML deserialization structs with default behavior. |
src/config/load.rs |
Threads sync_max_age_days through config loading for default and instance email configs. |
src/config.rs |
Updates config fixture to set sync_max_age_days: 0. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Combine UNSEEN with a SINCE date filter when sync_max_age_days is set, | ||
| // so first-connect doesn't flood the agent with years of unread email. | ||
| let search_query = if config.sync_max_age_days > 0 { | ||
| let since_date = (Utc::now() - ChronoDuration::days(config.sync_max_age_days as i64)) |
There was a problem hiding this comment.
config.sync_max_age_days is a u64 but is cast to i64 with as, which will wrap for values > i64::MAX and can produce a negative duration (leading to a future SINCE date or other incorrect behavior). Use a checked conversion (e.g., i64::try_from(...)) and either clamp to a reasonable max or return a config/validation error when it doesn’t fit.
| let since_date = (Utc::now() - ChronoDuration::days(config.sync_max_age_days as i64)) | |
| let sync_max_age_days = i64::try_from(config.sync_max_age_days).with_context(|| { | |
| format!( | |
| "email sync_max_age_days value {} exceeds supported maximum {}", | |
| config.sync_max_age_days, | |
| i64::MAX | |
| ) | |
| })?; | |
| let since_date = (Utc::now() - ChronoDuration::days(sync_max_age_days)) |
| // Combine UNSEEN with a SINCE date filter when sync_max_age_days is set, | ||
| // so first-connect doesn't flood the agent with years of unread email. | ||
| let search_query = if config.sync_max_age_days > 0 { | ||
| let since_date = (Utc::now() - ChronoDuration::days(config.sync_max_age_days as i64)) | ||
| .format("%d-%b-%Y") | ||
| .to_string(); | ||
| format!("UNSEEN SINCE {since_date}") | ||
| } else { | ||
| "UNSEEN".to_string() | ||
| }; |
There was a problem hiding this comment.
The comment/config description implies “last 24h”, but IMAP SINCE operates on whole dates (midnight boundary) and is inclusive, so sync_max_age_days = 1 can include up to ~48h depending on current time/timezone. Please adjust wording (or the option name/semantics) so it matches the actual behavior.
| // Combine UNSEEN with a SINCE date filter when sync_max_age_days is set, | ||
| // so first-connect doesn't flood the agent with years of unread email. | ||
| let search_query = if config.sync_max_age_days > 0 { | ||
| let since_date = (Utc::now() - ChronoDuration::days(config.sync_max_age_days as i64)) | ||
| .format("%d-%b-%Y") | ||
| .to_string(); | ||
| format!("UNSEEN SINCE {since_date}") | ||
| } else { | ||
| "UNSEEN".to_string() | ||
| }; | ||
|
|
||
| let message_uids = session | ||
| .uid_search("UNSEEN") | ||
| .uid_search(&search_query) | ||
| .with_context(|| format!("failed to search unseen messages in folder '{folder}'"))?; |
There was a problem hiding this comment.
This introduces new polling behavior (building UNSEEN vs UNSEEN SINCE <date>), but there’s no unit test covering the query construction. Consider extracting the polling search query builder into a small helper and adding tests for sync_max_age_days = 0 and a non-zero value (mirroring the existing build_imap_search_criterion tests).
| pub poll_interval_secs: u64, | ||
| pub folders: Vec<String>, | ||
| pub allowed_senders: Vec<String>, | ||
| pub max_body_bytes: usize, | ||
| pub max_attachment_bytes: usize, | ||
| pub sync_max_age_days: u64, | ||
| pub instances: Vec<EmailInstanceConfig>, | ||
| } |
There was a problem hiding this comment.
sync_max_age_days was added to the config structs, but the Debug impls for EmailConfig / EmailInstanceConfig don’t include it. Since this is not a secret, including it would make config dumps more accurate when diagnosing polling behavior.
| pub folders: Vec<String>, | ||
| pub allowed_senders: Vec<String>, | ||
| pub max_body_bytes: usize, | ||
| pub max_attachment_bytes: usize, | ||
| pub sync_max_age_days: u64, | ||
| } |
There was a problem hiding this comment.
sync_max_age_days was added here, but EmailInstanceConfig’s Debug impl doesn’t include it. Including it (it’s not sensitive) would make logs/config debugging clearer when instance polling differs from defaults.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/messaging/email.rs`:
- Around line 723-726: The cast config.sync_max_age_days as i64 can wrap for
large u64 values; validate or safely convert before calling
ChronoDuration::days() to avoid producing a future SINCE date. Replace the blind
cast in the search_query construction by using
i64::try_from(config.sync_max_age_days) (or clamp to a sensible max) and handle
the Err by returning/config error or defaulting; ensure the conversion happens
before calling ChronoDuration::days() and reference config.sync_max_age_days,
ChronoDuration::days(), and the search_query/Utc::now() usage when locating the
change.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b6b793cc-691d-47c5-85c2-9a862a5372dd
📒 Files selected for processing (5)
src/config.rssrc/config/load.rssrc/config/toml_schema.rssrc/config/types.rssrc/messaging/email.rs
| let search_query = if config.sync_max_age_days > 0 { | ||
| let since_date = (Utc::now() - ChronoDuration::days(config.sync_max_age_days as i64)) | ||
| .format("%d-%b-%Y") | ||
| .to_string(); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In Rust, what happens when casting u64toi64usingaswhen the value is greater thani64::MAX? Please include whether it can become negative.
💡 Result:
Casting u64 to i64 with as is always defined and, for same-width integers, is a bit-pattern reinterpretation (“no-op”) using two’s-complement rules. (doc.rust-lang.org)
So if the u64 value is greater than i64::MAX, the result becomes negative (it “wraps” into the negative range). Concretely:
- Result =
(value mod 2^64)interpreted asi64 - Equivalently, if
value > i64::MAX as u64, thenvalue as i64 == (value - 2^64) as i64(a negative number)
Example: (i64::MAX as u64 + 1) as i64 == i64::MIN. (doc.rust-lang.org)
If you don’t want this wraparound/negative behavior, use i64::try_from(u) / TryFrom to get an error when it doesn’t fit. (users.rust-lang.org)
Citations:
- 1: https://doc.rust-lang.org/reference/expressions/operator-expr.html?highlight=two-complement
- 2: https://doc.rust-lang.org/reference/expressions/operator-expr.html?highlight=two-complement
- 3: https://users.rust-lang.org/t/convert-u64-to-i64/56492?utm_source=openai
Guard u64 → i64 conversion for sync_max_age_days to prevent date wraparound.
On line 724, casting config.sync_max_age_days as i64 wraps to a negative number when the value exceeds i64::MAX, causing ChronoDuration::days() to compute a future date. This produces a SINCE {future_date} query that matches no emails, breaking the sync filter silently.
Add bounds validation before the cast (preferably at config load time) or use i64::try_from() to fail fast on out-of-range values.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/messaging/email.rs` around lines 723 - 726, The cast
config.sync_max_age_days as i64 can wrap for large u64 values; validate or
safely convert before calling ChronoDuration::days() to avoid producing a future
SINCE date. Replace the blind cast in the search_query construction by using
i64::try_from(config.sync_max_age_days) (or clamp to a sensible max) and handle
the Err by returning/config error or defaulting; ensure the conversion happens
before calling ChronoDuration::days() and reference config.sync_max_age_days,
ChronoDuration::days(), and the search_query/Utc::now() usage when locating the
change.
Summary
sync_max_age_daysconfig option to the email adapter that limits how far back the IMAP polling looks when fetching unread emailssync_max_age_days = 1), combinesUNSEENwithSINCE <date>in the IMAP search query so only recent unread emails are imported0(no limit), preserving existing behaviorProblem
When connecting an email account for the first time, every unread email in the inbox gets imported and the agent treats them all as new conversations. An inbox with hundreds of unread emails from months ago floods the agent with stale messages it shouldn't respond to.
Solution
IMAP natively supports compound search criteria. The
email_searchtool already buildsSINCEclauses viabuild_imap_search_criterion()— this applies the same pattern to the polling path inpoll_inbox_once().The
SINCEfilter is evaluated server-side by the IMAP server, so it's efficient even on large mailboxes. No emails are fetched and discarded locally.Config
Files changed
src/config/toml_schema.rssync_max_age_daysfield to TOML deserialization structssrc/config/types.rsEmailConfigandEmailInstanceConfigsrc/config/load.rssrc/messaging/email.rsEmailPollConfig, buildUNSEEN SINCEquery when setsrc/config.rsTest plan
cargo check— clean compilecargo test --lib— 792 tests passingcargo test --tests --no-run— integration tests compilecargo fmt --all -- --check— no new formatting issues