feat: added Cron scheduling for email downloads #211

This commit is contained in:
rustmailer
2026-05-23 15:40:46 +08:00
parent d8b78b8010
commit 005b1c2116
34 changed files with 1070 additions and 136 deletions
+8
View File
@@ -95,6 +95,7 @@ pub struct Account {
pub imap_quota_bytes: Option<u64>,
pub imap_quota_window: Option<QuotaWindow>,
pub auto_download_new_mailboxes: Option<bool>,
pub download_schedule: Option<String>,
}
impl MemDbModel for Account {
@@ -133,6 +134,7 @@ impl Account {
auto_download_new_mailboxes: request.auto_download_new_mailboxes,
imap_quota_bytes: request.imap_quota_bytes,
imap_quota_window: request.imap_quota_window,
download_schedule: request.download_schedule,
})
}
@@ -439,6 +441,12 @@ impl Account {
if let Some(auto_download_new_mailboxes) = request.auto_download_new_mailboxes {
new.auto_download_new_mailboxes = Some(auto_download_new_mailboxes);
}
if let Some(download_schedule) = request.download_schedule {
new.download_schedule = Some(download_schedule);
}
if request.clear_download_schedule == Some(true) {
new.download_schedule = None;
}
new.updated_at = utc_now!();
Ok(new)
}
+66 -2
View File
@@ -16,6 +16,8 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::str::FromStr;
use crate::account::entity::ImapConfig;
use crate::account::migration::{AccountModel, AccountType, QuotaWindow};
use crate::account::since::{DateSince, RelativeDate};
@@ -54,6 +56,7 @@ pub struct AccountCreateRequest {
pub imap_quota_bytes: Option<u64>,
pub imap_quota_window: Option<QuotaWindow>,
pub auto_download_new_mailboxes: Option<bool>,
pub download_schedule: Option<String>,
}
impl AccountCreateRequest {
@@ -92,12 +95,15 @@ impl AccountCreateRequest {
))
}
}
if self.download_interval_min.is_none() {
if self.download_interval_min.is_none() && self.download_schedule.is_none() {
return Err(raise_error!(
"`sync_interval_min` is required for IMAP account type".into(),
"`sync_interval_min` or `download_schedule` is required for IMAP account type".into(),
ErrorCode::InvalidParameter
));
}
if let Some(ref schedule) = self.download_schedule {
validate_cron_expression(schedule)?;
}
}
AccountType::NoSync => {}
}
@@ -178,6 +184,8 @@ pub struct AccountUpdateRequest {
pub imap_quota_bytes: Option<u64>,
pub imap_quota_window: Option<QuotaWindow>,
pub auto_download_new_mailboxes: Option<bool>,
pub download_schedule: Option<String>,
pub clear_download_schedule: Option<bool>,
}
impl AccountUpdateRequest {
@@ -230,11 +238,36 @@ impl AccountUpdateRequest {
));
}
}
if self.clear_download_schedule == Some(true) && self.download_schedule.is_some() {
return Err(raise_error!(
"clear_download_schedule cannot be combined with download_schedule".into(),
ErrorCode::InvalidParameter
));
}
if let Some(ref schedule) = self.download_schedule {
validate_cron_expression(schedule)?;
}
}
Ok(())
}
}
fn validate_cron_expression(expr: &str) -> BichonResult<()> {
if expr.trim().is_empty() {
return Err(raise_error!(
"download_schedule must not be empty".into(),
ErrorCode::InvalidParameter
));
}
cron::Schedule::from_str(expr).map_err(|e| {
raise_error!(
format!("Invalid cron expression '{}': {}", expr, e),
ErrorCode::InvalidParameter
)
})?;
Ok(())
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Deserialize, Serialize)]
#[cfg_attr(feature = "web-api", derive(poem_openapi::Object))]
@@ -253,3 +286,34 @@ pub fn filter_accessible_accounts<'a>(
.cloned()
.collect()
}
#[cfg(test)]
mod test {
use super::validate_cron_expression;
#[test]
fn valid_cron_expressions() {
assert!(validate_cron_expression("0 0 0 * * *").is_ok()); // daily at midnight
assert!(validate_cron_expression("0 */5 * * * *").is_ok()); // every 5 minutes
assert!(validate_cron_expression("0 0 12 * * 1-5").is_ok()); // weekdays at noon
assert!(validate_cron_expression("0 30 4 1 * *").is_ok()); // 1st of month at 04:30
assert!(validate_cron_expression("0 0 * * * *").is_ok()); // every hour
}
#[test]
fn invalid_cron_expression_too_few_fields() {
assert!(validate_cron_expression("0 0 * *").is_err());
assert!(validate_cron_expression("* * * * *").is_err()); // 5 fields, needs seconds
}
#[test]
fn invalid_cron_expression_empty() {
assert!(validate_cron_expression("").is_err());
assert!(validate_cron_expression(" ").is_err());
}
#[test]
fn invalid_cron_expression_garbage() {
assert!(validate_cron_expression("not a cron").is_err());
}
}
+2
View File
@@ -57,6 +57,7 @@ pub struct AccountResp {
pub imap_quota_bytes: Option<u64>,
pub imap_quota_window: Option<QuotaWindow>,
pub auto_download_new_mailboxes: Option<bool>,
pub download_schedule: Option<String>,
}
impl AccountResp {
@@ -93,6 +94,7 @@ impl AccountResp {
imap_quota_bytes: account.imap_quota_bytes,
imap_quota_window: account.imap_quota_window,
auto_download_new_mailboxes: account.auto_download_new_mailboxes,
download_schedule: account.download_schedule,
}
}
}
+99 -7
View File
@@ -16,6 +16,11 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
use std::str::FromStr;
use chrono::{DateTime, Local, TimeZone, Utc};
use cron::Schedule;
use crate::{
utc_now,
{
@@ -48,11 +53,20 @@ pub async fn decide_next_download_task(
let should_start = match trigger_type {
TriggerType::Manual => true,
TriggerType::Scheduled => should_trigger_next_download(
state.last_trigger_at,
state.last_finished_at.unwrap_or(0),
account.download_interval_min.unwrap_or(60),
),
TriggerType::Scheduled => {
let now = utc_now!();
let cooldown_ok = now - state.last_finished_at.unwrap_or(0) > 60 * 1000;
if !cooldown_ok {
false
} else if let Some(ref schedule) = account.download_schedule {
should_trigger_scheduled(schedule, state.last_trigger_at)
} else {
should_trigger_next_download(
state.last_trigger_at,
account.download_interval_min.unwrap_or(60),
)
}
}
};
if should_start {
@@ -65,9 +79,87 @@ pub async fn decide_next_download_task(
fn should_trigger_next_download(
last_trigger_at: i64,
last_finished_at: i64,
sync_interval_min: i64,
) -> bool {
let now = utc_now!();
now - last_trigger_at > (sync_interval_min * 60 * 1000) && now - last_finished_at > 60 * 1000
now - last_trigger_at > (sync_interval_min * 60 * 1000)
}
fn should_trigger_scheduled(schedule_str: &str, last_trigger_at: i64) -> bool {
let schedule = match Schedule::from_str(schedule_str) {
Ok(s) => s,
Err(e) => {
tracing::warn!(
"Invalid cron expression '{}', falling back to no trigger: {}",
schedule_str,
e
);
return false;
}
};
// last_trigger_at is a UTC millis timestamp; convert to server local time
let last_utc = match Utc.timestamp_millis_opt(last_trigger_at) {
chrono::LocalResult::Single(dt) => dt,
_ => {
tracing::warn!("Invalid last_trigger_at timestamp: {}", last_trigger_at);
return false;
}
};
let last_dt: DateTime<Local> = last_utc.with_timezone(&Local);
let now = Local::now();
schedule.after(&last_dt).next().map_or(false, |next| next <= now)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn cron_every_minute_triggers_after_60s() {
// "0 * * * * *" = every minute at second 0. last_trigger 90s ago → should trigger
let now = Local::now();
let last_trigger = now.timestamp_millis() - 90_000;
assert!(should_trigger_scheduled("0 * * * * *", last_trigger));
}
#[test]
fn cron_daily_midnight_triggers_when_missed() {
// "0 0 0 * * *" = daily at midnight
// last_trigger was 25 hours ago → should trigger (we missed midnight)
let now = Local::now();
let last_trigger = now.timestamp_millis() - 25 * 60 * 60 * 1000;
assert!(should_trigger_scheduled("0 0 0 * * *", last_trigger));
}
#[test]
fn cron_daily_midnight_no_trigger_if_already_fired() {
// "0 0 0 * * *" = daily at midnight
// last_trigger was 1 minute ago → should NOT trigger
let now = Local::now();
let last_trigger = now.timestamp_millis() - 60_000;
assert!(!should_trigger_scheduled("0 0 0 * * *", last_trigger));
}
#[test]
fn invalid_cron_returns_false() {
assert!(!should_trigger_scheduled("invalid cron expression", 0));
}
#[test]
fn cron_every_hour_triggers() {
// "0 0 * * * *" = every hour at minute 0, second 0
// last_trigger was 61 minutes ago → should trigger
let now = Local::now();
let last_trigger = now.timestamp_millis() - 61 * 60 * 1000;
assert!(should_trigger_scheduled("0 0 * * * *", last_trigger));
}
#[test]
fn cron_every_hour_no_trigger_if_recent() {
// "0 0 * * * *" = every hour at minute 0, second 0
// last_trigger was just 1 minute ago → should NOT trigger (except at :00/:01 boundary)
let now = Local::now();
let last_trigger = now.timestamp_millis() - 60_000;
assert!(!should_trigger_scheduled("0 0 * * * *", last_trigger));
}
}