diff --git a/crates/flectar-mail-core/src/imap/mod.rs b/crates/flectar-mail-core/src/imap/mod.rs
index d5332b2e..d552aa80 100644
--- a/crates/flectar-mail-core/src/imap/mod.rs
+++ b/crates/flectar-mail-core/src/imap/mod.rs
@@ -1097,9 +1097,22 @@ pub async fn store_flag(session: &mut Session, uid: u32, flag: &str, add: bool)
}
async fn store_flag_inner(session: &mut Session, uid: u32, flag: &str, add: bool) -> Result<()> {
- let op = if add { "+FLAGS" } else { "-FLAGS" };
+ store_flag_set_inner(session, &uid.to_string(), flag, add).await
+}
+
+async fn store_flag_set_inner(
+ session: &mut Session,
+ uid_set: &str,
+ flag: &str,
+ add: bool,
+) -> Result<()> {
+ let op = if add {
+ "+FLAGS.SILENT"
+ } else {
+ "-FLAGS.SILENT"
+ };
let mut stream = session
- .uid_store(uid.to_string(), format!("{op} ({flag})"))
+ .uid_store(uid_set, format!("{op} ({flag})"))
.await
.map_err(|e| CoreError::Imap(e.to_string()))?;
while let Some(item) = stream.next().await {
@@ -1108,7 +1121,12 @@ async fn store_flag_inner(session: &mut Session, uid: u32, flag: &str, add: bool
Ok(())
}
-/// MOVE if the server supports it, else COPY + \Deleted + EXPUNGE.
+/// Move one UID without guessing at unsupported server extensions.
+///
+/// RFC 6851 only permits `UID MOVE` when `MOVE` is advertised. Legacy IMAP
+/// servers use COPY + \Deleted instead. Prefer UID EXPUNGE for that fallback;
+/// without UIDPLUS, leave the source marked \Deleted because mailbox-wide
+/// EXPUNGE could remove another client's deleted mail.
pub async fn uid_move(session: &mut Session, uid: u32, target: &str) -> Result<()> {
with_deadline(
"UID MOVE",
@@ -1119,25 +1137,50 @@ pub async fn uid_move(session: &mut Session, uid: u32, target: &str) -> Result<(
}
async fn uid_move_inner(session: &mut Session, uid: u32, target: &str) -> Result<()> {
- match session.uid_mv(uid.to_string(), target).await {
- Ok(()) => Ok(()),
- Err(_) => {
- session
- .uid_copy(uid.to_string(), target)
- .await
- .map_err(|e| CoreError::Imap(e.to_string()))?;
- store_flag(session, uid, "\\Deleted", true).await?;
- let stream = session
- .expunge()
- .await
- .map_err(|e| CoreError::Imap(e.to_string()))?;
- futures::pin_mut!(stream);
- while let Some(item) = stream.next().await {
- item.map_err(|e| CoreError::Imap(e.to_string()))?;
- }
- Ok(())
- }
+ if uid == 0 {
+ return Err(CoreError::Imap("IMAP UID must be greater than zero".into()));
}
+
+ let capabilities = session
+ .capabilities()
+ .await
+ .map_err(|e| CoreError::Imap(e.to_string()))?;
+ if capabilities.has_str("MOVE") {
+ // A failed RFC 6851 MOVE may already have moved this UID. Falling back
+ // after such a failure can duplicate the message, so propagate it and
+ // let the durable action reconcile before retrying.
+ return session
+ .uid_mv(uid.to_string(), target)
+ .await
+ .map_err(|e| CoreError::Imap(e.to_string()));
+ }
+
+ session
+ .uid_copy(uid.to_string(), target)
+ .await
+ .map_err(|e| CoreError::Imap(e.to_string()))?;
+ store_flag_set_inner(session, &uid.to_string(), "\\Deleted", true).await?;
+
+ if capabilities.has_str("UIDPLUS") {
+ let stream = session
+ .uid_expunge(uid.to_string())
+ .await
+ .map_err(|e| CoreError::Imap(e.to_string()))?;
+ futures::pin_mut!(stream);
+ while let Some(item) = stream.next().await {
+ item.map_err(|e| CoreError::Imap(e.to_string()))?;
+ }
+ return Ok(());
+ }
+
+ // There is no race-free way to expunge one UID in base IMAP. Even a
+ // SEARCH immediately before EXPUNGE cannot prevent another client from
+ // marking a different message \Deleted between those commands.
+ tracing::debug!(
+ uid,
+ "legacy IMAP move left source marked deleted because UIDPLUS is unavailable"
+ );
+ Ok(())
}
pub async fn append(session: &mut Session, folder: &str, raw: &[u8], seen: bool) -> Result<()> {
diff --git a/crates/flectar-mail-core/src/lib.rs b/crates/flectar-mail-core/src/lib.rs
index e2617ef4..3ee9c202 100644
--- a/crates/flectar-mail-core/src/lib.rs
+++ b/crates/flectar-mail-core/src/lib.rs
@@ -7062,6 +7062,13 @@ fn apply_thread_action(
let target = params
.and_then(|p| p.target_folder_id)
.ok_or_else(|| CoreError::Other("move requires targetFolderId".into()))?;
+ let target_folder = repo::folders::get(&tx, target)?
+ .ok_or_else(|| CoreError::NotFound(format!("folder {target}")))?;
+ if target_folder.account_id != account_id {
+ return Err(CoreError::Other(
+ "messages cannot be moved between accounts".into(),
+ ));
+ }
for (id, f, u, _r, _s, _role) in &msgs {
if f.is_some() && *f != Some(target) {
let aid = enqueue_move(
@@ -7150,6 +7157,56 @@ fn apply_thread_action(
Ok(out)
}
+#[cfg(test)]
+mod move_action_validation_tests {
+ use super::*;
+
+ #[test]
+ fn move_rejects_a_folder_owned_by_another_account_without_local_mutation() {
+ let mut conn = db::testutil::conn();
+ db::testutil::seed_account(&conn);
+ conn.execute(
+ "INSERT INTO accounts (id, email, provider, auth_kind, username,
+ imap_host, imap_port, smtp_host, smtp_port, created_at)
+ VALUES (2,'other@test.dev','imap','password','other','h',993,'h',587,0)",
+ [],
+ )
+ .unwrap();
+ conn.execute(
+ "INSERT INTO folders (id, account_id, imap_name, role)
+ VALUES (2, 2, 'Archive', 'archive')",
+ [],
+ )
+ .unwrap();
+ let (thread_id, message_id) =
+ db::testutil::seed_message(&conn, "sender@test.dev", "cross account", false);
+ let before = repo::messages::get_row(&conn, message_id).unwrap().unwrap();
+
+ let error = apply_thread_action(
+ &mut conn,
+ thread_id,
+ ActionKind::Move,
+ Some(&ActionParams {
+ wake_at: None,
+ target_folder_id: Some(2),
+ label_id: None,
+ }),
+ )
+ .unwrap_err();
+
+ assert!(error.to_string().contains("between accounts"));
+ let after = repo::messages::get_row(&conn, message_id).unwrap().unwrap();
+ assert_eq!(after.folder_id, before.folder_id);
+ assert_eq!(after.uid, before.uid);
+ assert_eq!(
+ conn.query_row("SELECT COUNT(*) FROM pending_actions", [], |row| row
+ .get::<_, i64>(0))
+ .unwrap(),
+ 0
+ );
+ }
+}
+
/// Inverse of an action: cancel if pending, revert the local mutation, and
/// enqueue a compensating remote action when the original already ran.
/// Returns the affected thread id.
diff --git a/crates/flectar-mail-core/src/queue/mod.rs b/crates/flectar-mail-core/src/queue/mod.rs
index 457687a2..5a95caa8 100644
--- a/crates/flectar-mail-core/src/queue/mod.rs
+++ b/crates/flectar-mail-core/src/queue/mod.rs
@@ -78,19 +78,14 @@ pub async fn execute_due(
account_id, action_id, kind = %action.kind, error = %e,
"action needs auth; pausing (check mail-host auth / SMTP AUTH enabled)",
);
- // A queued send that can't authenticate would otherwise sit in the
- // outbox indefinitely with the composer already closed: the message
- // just vanishes with no warning (only a needs-reauth dot buried in
- // Settings). Tell the UI so it can surface that the mail wasn't sent
- // and point the user at re-authentication. Other action kinds stay
- // quiet; the account's needs_reauth state already covers them.
- if action.kind == "send" {
- ctx.bus.emit(CoreEvent::ActionState {
- action_id,
- state: "paused".into(),
- error: Some(e.to_string()),
- });
- }
+ // The optimistic local mutation remains visible while auth is
+ // paused, so every action kind must tell the UI it has not yet
+ // reached the server.
+ ctx.bus.emit(CoreEvent::ActionState {
+ action_id,
+ state: "paused".into(),
+ error: Some(e.to_string()),
+ });
let msg = "authentication required".to_string();
ctx.db
.write(move |conn| {
@@ -106,18 +101,8 @@ pub async fn execute_due(
account_id, action_id, kind = %action.kind, attempts, error = %msg,
"action attempt failed",
);
- if attempts >= MAX_ATTEMPTS || is_permanent(&msg) {
- let m = msg.clone();
- ctx.db
- .write(move |conn| {
- repo::actions::set_state(conn, action_id, "failed", Some(&m))
- })
- .await?;
- ctx.bus.emit(CoreEvent::ActionState {
- action_id,
- state: "failed".into(),
- error: Some(msg),
- });
+ if attempts >= MAX_ATTEMPTS || is_permanent(&e) {
+ fail_action(ctx, &action, msg).await?;
} else {
// Exponential backoff with jitter.
let delay = (1 << attempts.min(8)) * 1000 + (action_id % 997);
@@ -127,6 +112,11 @@ pub async fn execute_due(
repo::actions::bump_attempt(conn, action_id, now_ms() + delay, &m)
})
.await?;
+ ctx.bus.emit(CoreEvent::ActionState {
+ action_id,
+ state: "retrying".into(),
+ error: Some(msg.clone()),
+ });
// Connection-level errors: bail out, actor reconnects.
if is_connection_failure(&msg) {
return Err(e);
@@ -146,9 +136,28 @@ pub async fn execute_due(
})
}
-fn is_permanent(msg: &str) -> bool {
- // IMAP tagged NO responses and SMTP 5xx are not going to succeed on retry.
- msg.contains("NO ") || msg.contains("550") || msg.contains("553") || msg.contains("bad ")
+fn is_permanent(error: &CoreError) -> bool {
+ match error {
+ CoreError::NotFound(_) => true,
+ // Tagged NO/BAD responses reject the command itself. Async IMAP has
+ // used both `NO ...` and `no: ...` display forms across versions.
+ CoreError::Imap(message) => {
+ let message = message.trim().to_ascii_lowercase();
+ ["no", "bad"].iter().any(|status| {
+ message == *status
+ || message
+ .strip_prefix(status)
+ .is_some_and(|tail| matches!(tail.chars().next(), Some(' ' | ':' | '[')))
+ })
+ }
+ // All SMTP 5xx replies are permanent failures of the current request.
+ CoreError::Smtp(message) => message.split(|c: char| !c.is_ascii_digit()).any(|token| {
+ token.len() == 3
+ && token.starts_with('5')
+ && token.bytes().all(|byte| byte.is_ascii_digit())
+ }),
+ _ => false,
+ }
}
fn is_connection_failure(msg: &str) -> bool {
@@ -160,6 +169,107 @@ fn is_connection_failure(msg: &str) -> bool {
|| msg.contains("unexpected eof")
}
+fn is_move_kind(kind: &str) -> bool {
+ matches!(
+ kind,
+ "archive" | "unarchive" | "trash" | "spam" | "not_spam" | "move"
+ )
+}
+
+/// Mark an exhausted action failed and undo its optimistic move when it is
+/// still the newest intent for that message. A later queued move owns the
+/// visible location and must not be overwritten by an older failure.
+async fn fail_action(
+ ctx: &SyncCtx,
+ action: &repo::actions::PendingAction,
+ message: String,
+) -> Result<()> {
+ let failed = action.clone();
+ let saved_message = message.clone();
+ let changed_thread = ctx
+ .db
+ .write(move |conn| mark_failed_and_rollback(conn, &failed, &saved_message))
+ .await?;
+
+ if let Some(thread_id) = changed_thread {
+ ctx.bus.emit(CoreEvent::MailUpdated {
+ thread_ids: vec![thread_id],
+ });
+ }
+ ctx.bus.emit(CoreEvent::ActionState {
+ action_id: action.id,
+ state: "failed".into(),
+ error: Some(message),
+ });
+ Ok(())
+}
+
+fn mark_failed_and_rollback(
+ conn: &mut rusqlite::Connection,
+ failed: &repo::actions::PendingAction,
+ message: &str,
+) -> Result