Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1706,6 +1706,7 @@ maintenance_merge_similarity_threshold = 1.1
allowed_senders: vec![],
max_body_bytes: 1_000_000,
max_attachment_bytes: 10_000_000,
sync_max_age_days: 0,
instances: vec![],
}),
webhook: None,
Expand Down
2 changes: 2 additions & 0 deletions src/config/load.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2161,6 +2161,7 @@ impl Config {
allowed_senders: instance.allowed_senders,
max_body_bytes: instance.max_body_bytes,
max_attachment_bytes: instance.max_attachment_bytes,
sync_max_age_days: instance.sync_max_age_days,
}
})
.collect::<Vec<_>>();
Expand Down Expand Up @@ -2231,6 +2232,7 @@ impl Config {
allowed_senders: email.allowed_senders,
max_body_bytes: email.max_body_bytes,
max_attachment_bytes: email.max_attachment_bytes,
sync_max_age_days: email.sync_max_age_days,
instances,
})
}),
Expand Down
4 changes: 4 additions & 0 deletions src/config/toml_schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,8 @@ pub(super) struct TomlEmailConfig {
#[serde(default = "default_email_max_attachment_bytes")]
pub(super) max_attachment_bytes: usize,
#[serde(default)]
pub(super) sync_max_age_days: u64,
#[serde(default)]
pub(super) instances: Vec<TomlEmailInstanceConfig>,
}

Expand Down Expand Up @@ -661,6 +663,8 @@ pub(super) struct TomlEmailInstanceConfig {
pub(super) max_body_bytes: usize,
#[serde(default = "default_email_max_attachment_bytes")]
pub(super) max_attachment_bytes: usize,
#[serde(default)]
pub(super) sync_max_age_days: u64,
}

#[derive(Deserialize)]
Expand Down
2 changes: 2 additions & 0 deletions src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2600,6 +2600,7 @@ pub struct EmailConfig {
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>,
}

Expand All @@ -2625,6 +2626,7 @@ pub struct EmailInstanceConfig {
pub allowed_senders: Vec<String>,
pub max_body_bytes: usize,
pub max_attachment_bytes: usize,
pub sync_max_age_days: u64,
}

impl std::fmt::Debug for EmailInstanceConfig {
Expand Down
19 changes: 18 additions & 1 deletion src/messaging/email.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ struct EmailPollConfig {
poll_interval: Duration,
allowed_senders: Vec<String>,
max_body_bytes: usize,
sync_max_age_days: u64,
runtime_key: String,
}

Expand Down Expand Up @@ -142,6 +143,7 @@ pub struct EmailAdapter {
allowed_senders: Vec<String>,
max_body_bytes: usize,
max_attachment_bytes: usize,
sync_max_age_days: u64,
smtp_transport: AsyncSmtpTransport<Tokio1Executor>,
shutdown_tx: Arc<RwLock<Option<watch::Sender<bool>>>>,
poll_task: Arc<RwLock<Option<JoinHandle<()>>>>,
Expand Down Expand Up @@ -199,6 +201,7 @@ impl EmailAdapter {
allowed_senders: config.allowed_senders.clone(),
max_body_bytes: config.max_body_bytes,
max_attachment_bytes: config.max_attachment_bytes,
sync_max_age_days: config.sync_max_age_days,
instances: Vec::new(),
};
Self::build(runtime_key.into(), &email_config)
Expand Down Expand Up @@ -238,6 +241,7 @@ impl EmailAdapter {
allowed_senders: config.allowed_senders.clone(),
max_body_bytes: config.max_body_bytes.max(1024),
max_attachment_bytes: config.max_attachment_bytes.max(1024),
sync_max_age_days: config.sync_max_age_days,
smtp_transport,
shutdown_tx: Arc::new(RwLock::new(None)),
poll_task: Arc::new(RwLock::new(None)),
Expand All @@ -257,6 +261,7 @@ impl EmailAdapter {
poll_interval: self.poll_interval,
allowed_senders: self.allowed_senders.clone(),
max_body_bytes: self.max_body_bytes,
sync_max_age_days: self.sync_max_age_days,
runtime_key: self.runtime_key.clone(),
}
}
Expand Down Expand Up @@ -713,8 +718,19 @@ fn poll_inbox_once(config: &EmailPollConfig) -> anyhow::Result<Vec<InboundMessag
continue;
}

// 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.
.format("%d-%b-%Y")
.to_string();
Comment on lines +723 to +726
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.

format!("UNSEEN SINCE {since_date}")
} else {
"UNSEEN".to_string()
};
Comment on lines +721 to +730
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.

let message_uids = session
.uid_search("UNSEEN")
.uid_search(&search_query)
.with_context(|| format!("failed to search unseen messages in folder '{folder}'"))?;
Comment on lines +721 to 734
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.

for uid in message_uids {
Expand Down Expand Up @@ -1210,6 +1226,7 @@ pub fn search_mailbox(
poll_interval: Duration::from_secs(config.poll_interval_secs.max(5)),
allowed_senders: config.allowed_senders.clone(),
max_body_bytes: config.max_body_bytes.max(1024),
sync_max_age_days: config.sync_max_age_days,
runtime_key: "email".to_string(),
})?;

Expand Down
Loading