Skip to content

feat: add sync_max_age_days to email adapter#547

Open
connorblack wants to merge 1 commit intospacedriveapp:mainfrom
connorblack:feat/email-sync-max-age
Open

feat: add sync_max_age_days to email adapter#547
connorblack wants to merge 1 commit intospacedriveapp:mainfrom
connorblack:feat/email-sync-max-age

Conversation

@connorblack
Copy link
Copy Markdown

Summary

  • Adds sync_max_age_days config option to the email adapter that limits how far back the IMAP polling looks when fetching unread emails
  • When set (e.g. sync_max_age_days = 1), combines UNSEEN with SINCE <date> in the IMAP search query so only recent unread emails are imported
  • Default is 0 (no limit), preserving existing behavior

Problem

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_search tool already builds SINCE clauses via build_imap_search_criterion() — this applies the same pattern to the polling path in poll_inbox_once().

The SINCE filter is evaluated server-side by the IMAP server, so it's efficient even on large mailboxes. No emails are fetched and discarded locally.

Config

[messaging.email]
sync_max_age_days = 1  # only import unread emails from the last 24h

# also works on named instances
[[messaging.email.instances]]
name = "support"
sync_max_age_days = 7

Files changed

File Change
src/config/toml_schema.rs Add sync_max_age_days field to TOML deserialization structs
src/config/types.rs Add field to EmailConfig and EmailInstanceConfig
src/config/load.rs Thread field through config loading
src/messaging/email.rs Add to EmailPollConfig, build UNSEEN SINCE query when set
src/config.rs Add field to test fixture

Test plan

  • cargo check — clean compile
  • cargo test --lib — 792 tests passing
  • cargo test --tests --no-run — integration tests compile
  • cargo fmt --all -- --check — no new formatting issues
  • Manual test with a real IMAP mailbox with old unread emails

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.
Copilot AI review requested due to automatic review settings April 6, 2026 19:32
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 6, 2026

Walkthrough

A new sync_max_age_days configuration parameter is added to the email messaging adapter configuration stack, threading through TOML schema, type definitions, and config loading. Conditional IMAP polling logic is modified to append a SINCE date filter to searches when this parameter exceeds zero.

Changes

Cohort / File(s) Summary
Config Schema & Types
src/config/toml_schema.rs, src/config/types.rs
Added optional sync_max_age_days: u64 field to TOML deserialization schemas (TomlEmailConfig, TomlEmailInstanceConfig) and corresponding public struct fields in EmailConfig and EmailInstanceConfig.
Config Initialization
src/config.rs, src/config/load.rs
Added sync_max_age_days field initialization to EmailConfig defaults and mapped the field from source config objects during EmailInstanceConfig and top-level EmailConfig construction.
Email Adapter Implementation
src/messaging/email.rs
Added sync_max_age_days: u64 field to EmailAdapter and EmailPollConfig structs; modified poll_inbox_once() to conditionally build IMAP search queries with UNSEEN SINCE {since_date} when sync_max_age_days > 0, computing since_date from current UTC time minus configured days.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 57.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately and specifically describes the main change: adding a sync_max_age_days configuration field to the email adapter.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the feature, problem it solves, and implementation details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

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

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_days through TOML schema → loaded config types → adapter runtime config.
  • Update IMAP polling to use UNSEEN SINCE <date> when sync_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))
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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))

Copilot uses AI. Check for mistakes.
Comment on lines +721 to +730
// 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()
};
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +721 to 734
// 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}'"))?;
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

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).

Copilot uses AI. Check for mistakes.
Comment on lines 2598 to 2605
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>,
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines 2625 to 2630
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,
}
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

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 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

📥 Commits

Reviewing files that changed from the base of the PR and between fb5c0f3 and 3b80855.

📒 Files selected for processing (5)
  • src/config.rs
  • src/config/load.rs
  • src/config/toml_schema.rs
  • src/config/types.rs
  • src/messaging/email.rs

Comment on lines +723 to +726
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();
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

🧩 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 as i64
  • Equivalently, if value > i64::MAX as u64, then value 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:


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.

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.

2 participants