feat: move and copy mail to other accounts from the folder picker

This commit is contained in:
Tomás Romero committed 2026-11-06 14:41:55 +01:00
1 parent 3ef3ccf5df
commit f60df67dfb
13 files changed
+1295 -696

No files matched your search

+521 -460
View File
File diff suppressed because it is too large. Load diff
+32
View File
@@ -1784,6 +1784,38 @@ impl CoreMailSource {
.map_err(|error| error.to_string())
}
/// Copies or moves mail to a folder of another account. The original of
/// a move goes once the copy is in place.
pub async fn transfer_to_folder(
&self,
target: MailTarget,
folder_id: i64,
is_move: bool,
) -> Result<(), String> {
self.core
.transfer_mail(target.action_target(), folder_id, is_move)
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
/// Files mail in one more folder of its JMAP account.
pub async fn copy_to_folder(&self, target: MailTarget, folder_id: i64) -> Result<(), String> {
self.core
.perform_action(PerformActionArgs {
kind: ActionKind::Copy,
target: target.action_target(),
params: Some(ActionParams {
wake_at: None,
target_folder_id: Some(folder_id),
label_id: None,
}),
})
.await
.map(|_| ())
.map_err(|error| error.to_string())
}
/// Inbox tabs route conversations, so a message's routes its conversation.
pub async fn route_to_tab(&self, target: MailTarget, tab: String) -> Result<(), String> {
self.core
+22
View File
@@ -139,4 +139,26 @@ fn drops_resolve_by_scope_not_by_mailbox_name() {
Ok(MailDropDestination::Action("trash"))
);
assert!(drop(Scope::Folder(12), 1, 12).is_err());
// Another account's folder gets a copy, and the original goes once it
// is there; its Sent and Starred take no mail.
let other = |scope, folder_id, label| MailboxEntry {
account_id: 2,
..mailbox(scope, folder_id, label)
};
let other_sent = Scope::Account {
account_id: 2,
mailbox: StandardMailbox::Sent,
};
state.mailboxes.push(other(Scope::Folder(30), 30, "Travel"));
state.mailboxes.push(other(other_sent, 31, "Sent"));
let drop = |scope, account_id, folder_id| {
resolve_single_mail_drop(&state, &item, scope, account_id, folder_id)
.map(|(_, destination)| destination)
};
assert_eq!(
drop(Scope::Folder(30), 2, 30),
Ok(MailDropDestination::Transfer(30))
);
assert!(drop(other_sent, 2, 31).is_err());
}
+43
View File
@@ -44,8 +44,27 @@ pub(super) enum Operation {
EmptyTrash(Option<i64>),
Drop(MailDropDestination),
Label(i64, bool),
/// Files mail in one more folder: another of its own account's, where
/// a message can be in several (JMAP), or a copy in another account's.
Copy {
folder_id: i64,
other_account: bool,
},
}
impl Operation {
/// Whether it starts moving (`true`) or copying mail to another
/// account, which goes on after it returns.
fn starts_transfer(&self) -> Option<bool> {
match self {
Self::Drop(MailDropDestination::Transfer(_)) => Some(true),
Self::Copy {
other_account: true,
..
} => Some(false),
_ => None,
}
}
fn queues_permanent_deletion(&self) -> bool {
match self {
Self::EmptyTrash(_) => true,
@@ -75,6 +94,17 @@ impl Operation {
core.perform_message_action(target, a).await
}
Self::Drop(MailDropDestination::Folder(id)) => core.move_to_folder(target, *id).await,
Self::Drop(MailDropDestination::Transfer(id)) => {
core.transfer_to_folder(target, *id, true).await
}
Self::Copy {
folder_id,
other_account: true,
} => core.transfer_to_folder(target, *folder_id, false).await,
Self::Copy {
folder_id,
other_account: false,
} => core.copy_to_folder(target, *folder_id).await,
Self::Drop(MailDropDestination::Label(id)) => {
core.perform_label_action(target, *id, true).await
}
@@ -96,6 +126,9 @@ struct ActionResult {
moved: Vec<i32>,
error: Option<String>,
deletion_queued: bool,
/// Mail started on its way to another account, moving (`true`) or
/// being copied.
transfer_started: Option<bool>,
}
pub(super) fn register(
@@ -279,6 +312,13 @@ pub(super) fn register(
Some(error) => {
app.set_render_status(UiMessage::detail("Message action failed: {}", error))
}
// What became of it is told once the other account has it.
None if result.transfer_started == Some(true) => {
app.set_render_status(UiMessage::plain("Moving mail to the other account…"))
}
None if result.transfer_started == Some(false) => {
app.set_render_status(UiMessage::plain("Copying mail to the other account…"))
}
None if result.deletion_queued => app.set_render_status(UiMessage::plain(
"Permanent deletion queued. Mail will disappear after the server confirms.",
)),
@@ -447,14 +487,17 @@ where
moved: vec![],
error: None,
deletion_queued: false,
transfer_started: None,
};
for (id, target, operation) in operations {
let deletion = operation.queues_permanent_deletion();
let transfer = operation.starts_transfer();
result.ids.push(id);
let moves = operation.moves();
match perform(target, operation).await {
Ok(()) => {
result.deletion_queued |= deletion;
result.transfer_started = result.transfer_started.or(transfer);
result.completed.push(id);
if moves {
result.moved.push(id);
+28 -9
View File
@@ -706,6 +706,8 @@ struct MailDragItem {
enum MailDropDestination {
Action(&'static str),
Folder(i64),
/// A folder of another account.
Transfer(i64),
Label(i64),
Route(String),
}
@@ -832,15 +834,12 @@ fn resolve_single_mail_drop(
return Err("this unified mailbox is not a drop destination".to_owned());
}
Scope::Folder(_) | Scope::Account { .. } => {
if i64::from(target_account_id) != message.account_id {
return Err("messages cannot be moved between accounts".to_owned());
}
let target = state
.mailboxes
.iter()
.find(|mailbox| {
!mailbox.is_account
&& mailbox.account_id == message.account_id
&& mailbox.account_id == i64::from(target_account_id)
&& mailbox.scope == Some(target_scope)
&& mailbox.folder_id == i64::from(target_folder_id)
})
@@ -848,17 +847,32 @@ fn resolve_single_mail_drop(
if !target.is_selectable {
return Err("this mailbox is a hierarchy container".to_owned());
}
// Folders are told apart by scope: another folder can have the
// name of the one shown.
if state.scope.shows_mail_in(target_scope) {
return Err("message is already in this destination".to_owned());
}
// Custom folders are plain moves even when named like a
// standard mailbox.
let standard = match target_scope {
Scope::Account { mailbox, .. } => Some(mailbox),
_ => None,
};
// Mail moves to another account as Thunderbird moves it: a copy
// goes there, and the original goes once the copy is in place.
if target.account_id != message.account_id {
return match standard {
Some(
StandardMailbox::Sent | StandardMailbox::Drafts | StandardMailbox::Starred,
) => Err("this mailbox is not a drop destination".to_owned()),
_ if target.folder_id >= 0 => Ok((
MailTarget::of(message)
.ok_or_else(|| "message thread is unavailable".to_owned())?,
MailDropDestination::Transfer(target.folder_id),
)),
_ => Err("mailbox destination is unavailable".to_owned()),
};
}
// Folders are told apart by scope: another folder can have the
// name of the one shown.
if state.scope.shows_mail_in(target_scope) {
return Err("message is already in this destination".to_owned());
}
match standard {
Some(StandardMailbox::Starred) if !message.starred => {
MailDropDestination::Action("star")
@@ -3869,6 +3883,11 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
app.set_render_status(message.clone());
app.set_sync_status(message);
}
if let Some(report) = pending.transfers
&& let Some(app) = mail_update_app.upgrade()
{
app.set_render_status(move_picker::transfer_message(&report));
}
if pending.calendar_changed
&& let Some(app) = mail_update_app.upgrade()
&& app.get_settings_open()
+567 -205
View File
File diff suppressed because it is too large. Load diff
+10 -1
View File
@@ -18,7 +18,7 @@ use crate::{
};
use flectar_mail_core::{
config::Paths,
events::CoreEvent,
events::{CoreEvent, TransferReport},
models::{
Account, AccountConfig, CalendarConnection, CardDavConnection, MailGrouping, MessageCursor,
Settings, ThreadCursor, normalized_workspace_list_pane_width,
@@ -584,6 +584,8 @@ pub(crate) struct PendingCoreUpdates {
pub(crate) all_mail_changed: bool,
pub(crate) account_states: HashMap<i64, (String, Option<String>)>,
pub(crate) action_error: Option<String>,
/// What became of the latest mail copied or moved to other accounts.
pub(crate) transfers: Option<TransferReport>,
pub(crate) calendar_changed: bool,
pub(crate) contacts_changed: bool,
}
@@ -738,6 +740,13 @@ pub(crate) fn spawn_core_event_listener(
.action_error = Some(error);
true
}
Ok(CoreEvent::TransfersFinished(report)) => {
pending
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.transfers = Some(report);
true
}
Ok(CoreEvent::CalendarUpdated { .. })
| Ok(CoreEvent::CalendarEventsAdded { .. }) => {
pending
+9
View File
@@ -496,6 +496,15 @@ export global I18n {
if (key == "Label deleted.") { return @tr("Label deleted."); }
if (key == "No other folder to move this mail to.") { return @tr("No other folder to move this mail to."); }
if (key == "Could not move mail: {}") { return @tr("Could not move mail: {}", argument); }
if (key == "No folder to copy this mail to.") { return @tr("No folder to copy this mail to."); }
if (key == "Could not copy mail: {}") { return @tr("Could not copy mail: {}", argument); }
if (key == "Moving mail to the other account…") { return @tr("Moving mail to the other account…"); }
if (key == "Copying mail to the other account…") { return @tr("Copying mail to the other account…"); }
if (key == "Moved {} messages to the other account.") { return @tr("Moved one message to the other account." | "Moved {0} messages to the other account." % round(argument.to-float()), argument); }
if (key == "Copied {} messages to the other account.") { return @tr("Copied one message to the other account." | "Copied {0} messages to the other account." % round(argument.to-float()), argument); }
if (key == "Moved or copied {} messages to the other account.") { return @tr("Moved or copied one message to the other account." | "Moved or copied {0} messages to the other account." % round(argument.to-float()), argument); }
if (key == "The other account had {} messages already.") { return @tr("The other account had that message already." | "The other account had those {0} messages already." % round(argument.to-float()), argument); }
if (key == "Could not move or copy {} messages: {}") { return @tr("Could not move or copy one message: {1}" | "Could not move or copy {0} messages: {1}" % round(argument.to-float()), argument, second-argument); }
if (key == "Could not update label: {}") { return @tr("Could not update label: {}", argument); }
if (key == "Could not save label: {}") { return @tr("Could not save label: {}", argument); }
if (key == "Could not delete label: {}") { return @tr("Could not delete label: {}", argument); }
+18 -10
View File
@@ -7,7 +7,8 @@ import { LineEdit, ToolbarIconButton } from "../components/controls.slint";
export struct MoveTargetRow {
// What `MovePickerUi.choose` takes; -1 on headings.
key: int,
// inbox, archive, spam, trash or folder; on headings, recent or folders.
// inbox, archive, spam, trash or folder; on headings, recent, folders,
// or the account of the rows below: account, device or unified.
kind: string,
// The name shown, which for standard mailboxes is translated; empty on
// headings, which their kind names.
@@ -18,18 +19,22 @@ export struct MoveTargetRow {
label_has_emoji: bool,
}
// The non-drag way to move mail into a folder (WCAG 2.5.7). Rust lists the
// destinations when the picker opens and as the search changes; choosing
// one does what dropping the mail on that sidebar row does.
// The non-drag way to move mail into a folder (WCAG 2.5.7), and to copy it.
// Rust lists the destinations when the picker opens and as the search
// changes; choosing one for a move does what dropping the mail on that
// sidebar row does.
export global MovePickerUi {
in-out property <bool> open: false;
// move or copy.
in property <string> mode: "move";
in property <[MoveTargetRow]> rows: [];
in-out property <string> query: "";
// Where focus goes back to once the picker closes: list or reader.
in-out property <string> return-to: "list";
// Opens the picker for "row" (a list row, with the rows checked along
// with it), "checked" (the checked rows) or "open" (the open mail).
callback request(/* source */ string, /* email id */ int);
// Opens the picker to move or copy mail of "row" (a list row, with the
// rows checked along with it), "checked" (the checked rows) or "open"
// (the open mail).
callback request(/* mode */ string, /* source */ string, /* email id */ int);
callback search(string);
callback choose(/* key */ int);
@@ -42,6 +47,8 @@ export global MovePickerUi {
if kind == "trash" { return @tr("Trash"); }
if kind == "recent" { return @tr("Recent"); }
if kind == "folders" { return @tr("All folders"); }
if kind == "device" { return @tr("On this device"); }
if kind == "unified" { return @tr("All accounts"); }
label
}
}
@@ -64,7 +71,7 @@ export component MoveDialog inherits Rectangle {
background: #00000066;
forward-focus: search-field;
accessible-role: groupbox;
accessible-label: @tr("Move to");
accessible-label: MovePickerUi.mode == "copy" ? @tr("Copy to") : @tr("Move to");
// A heading is always followed by a destination, so one step past it
// reaches one.
@@ -188,7 +195,7 @@ export component MoveDialog inherits Rectangle {
spacing: 8px;
Text {
text: @tr("Move to");
text: MovePickerUi.mode == "copy" ? @tr("Copy to") : @tr("Move to");
color: AppTheme.title;
font-size: TypeScale.heading;
font-weight: 700;
@@ -243,10 +250,11 @@ export component MoveDialog inherits Rectangle {
x: 10px;
width: parent.width - 20px;
height: parent.height - 4px;
text: MovePickerUi.display-name(row.kind, "");
text: MovePickerUi.display-name(row.kind, row.label);
color: AppTheme.muted;
font-size: TypeScale.caption;
font-weight: 600;
font-family: row.label_has_emoji ? "Noto Emoji" : "";
vertical-alignment: bottom;
overflow: elide;
accessible-role: none;
+1
View File
@@ -2396,6 +2396,7 @@ export component SettingsDialog inherits Rectangle {
ShortcutRow { title: @tr("Search mail"); keys: [Shortcuts.search, Shortcuts.search-alternate]; title-color: root.body-text; separator-color: root.faint-text; }
ShortcutRow { title: @tr("Archive"); keys: [Shortcuts.archive]; title-color: root.body-text; separator-color: root.faint-text; }
ShortcutRow { title: @tr("Move to a folder"); keys: [Shortcuts.move-to]; title-color: root.body-text; separator-color: root.faint-text; }
ShortcutRow { title: @tr("Copy to a folder"); keys: [Shortcuts.copy-to]; title-color: root.body-text; separator-color: root.faint-text; }
ShortcutRow { title: @tr("Move to spam"); keys: [Shortcuts.spam]; title-color: root.body-text; separator-color: root.faint-text; }
ShortcutRow { title: @tr("Move to trash"); keys: [Shortcuts.trash]; title-color: root.body-text; separator-color: root.faint-text; }
}
+3 -1
View File
@@ -11,8 +11,10 @@ export global Shortcuts {
out property <keys> spam: @keys(J);
// Mac keyboards label Backspace "delete", and Apple Mail trashes with it.
out property <keys> trash: KeyboardKeys.apple ? @keys(Backspace) : @keys(Delete);
// Outlook's and Evolution's key for moving mail to a folder.
// Outlook's and Evolution's keys for moving and copying mail to a
// folder.
out property <keys> move-to: @keys(Control + Shift + V);
out property <keys> copy-to: @keys(Control + Shift + Y);
// Thunderbird's keys: F5 checks the accounts in view, Shift+F5 every
// account. Apple Mail checks every account with Shift-Command-N.
+26 -7
View File
@@ -456,14 +456,19 @@ export component MailListPane inherits FocusScope {
keys: Shortcuts.move-to;
// Not while typing: text fields elsewhere paste plain text with it.
enabled: !search-toolbar.search-focused;
activated => { root.move-messages(); }
activated => { root.pick-folder("move"); }
}
KeyBinding {
keys: Shortcuts.copy-to;
enabled: !search-toolbar.search-focused;
activated => { root.pick-folder("copy"); }
}
// Move to opens for what the other shortcuts act on.
function move-messages() {
// Move to and Copy to open for what the other shortcuts act on.
function pick-folder(mode: string) {
if root.selection-mode {
MovePickerUi.return-to = "list";
MovePickerUi.request("checked", -1);
MovePickerUi.request(mode, "checked", -1);
return;
}
let target = MailListNavigation.target(root.list_entries, root.keyboard-email-id, "current", 1);
@@ -471,7 +476,7 @@ export component MailListPane inherits FocusScope {
let at-cursor = list-keyboard.has-focus && target.id == root.keyboard-email-id;
if !at-cursor && !root.list_entries[target.entry_index].email.selected { return; }
MovePickerUi.return-to = "list";
MovePickerUi.request("row", target.id);
MovePickerUi.request(mode, "row", target.id);
}
function pointer-focus(id: int) {
@@ -781,7 +786,14 @@ export component MailListPane inherits FocusScope {
title: @tr("Move to…");
activated => {
MovePickerUi.return-to = "list";
MovePickerUi.request("checked", -1);
MovePickerUi.request("move", "checked", -1);
}
}
MenuItem {
title: @tr("Copy to…");
activated => {
MovePickerUi.return-to = "list";
MovePickerUi.request("copy", "checked", -1);
}
}
MenuItem { title: @tr("Mark as read"); activated => { root.bulk_message_action("mark_read"); } }
@@ -1379,7 +1391,14 @@ export component MailListPane inherits FocusScope {
title: @tr("Move to…");
activated => {
MovePickerUi.return-to = "list";
MovePickerUi.request("row", mail-data.id);
MovePickerUi.request("move", "row", mail-data.id);
}
}
MenuItem {
title: @tr("Copy to…");
activated => {
MovePickerUi.return-to = "list";
MovePickerUi.request("copy", "row", mail-data.id);
}
}
if !mail-row.local-mail : MenuItem { title: @tr("Move to spam"); activated => { root.message_action_for_email(mail-data.id, "spam"); } }
+15 -3
View File
@@ -348,6 +348,7 @@ export component MailReadingPane inherits FocusScope {
{ id: "read", title: root.selected_unread ? @tr("Mark as read") : @tr("Mark as unread"), icon: root.selected_unread ? @image-url("../icons/lucide/outline/mail-open.svg") : @image-url("../icons/lucide/outline/mail.svg") },
{ id: "archive", title: @tr("Archive"), icon: @image-url("../icons/lucide/outline/archive.svg") },
{ id: "move", title: @tr("Move to…"), icon: @image-url("../icons/lucide/outline/folder-move.svg") },
{ id: "copy", title: @tr("Copy to…"), icon: @image-url("../icons/lucide/outline/copy.svg") },
{ id: "spam", title: @tr("Move to spam"), icon: @image-url("../icons/lucide/outline/alert.svg") },
{ id: root.trash_view ? "delete_permanently" : "trash", title: root.trash_view ? @tr("Delete permanently…") : @tr("Move to trash"), icon: @image-url("../icons/lucide/outline/trash.svg"), destructive: true },
];
@@ -366,6 +367,7 @@ export component MailReadingPane inherits FocusScope {
{ id: "read", title: root.selected_unread ? @tr("Mark as read") : @tr("Mark as unread"), icon: root.selected_unread ? @image-url("../icons/lucide/outline/mail-open.svg") : @image-url("../icons/lucide/outline/mail.svg") },
{ id: "archive", title: @tr("Archive"), icon: @image-url("../icons/lucide/outline/archive.svg") },
{ id: "move", title: @tr("Move to…"), icon: @image-url("../icons/lucide/outline/folder-move.svg") },
{ id: "copy", title: @tr("Copy to…"), icon: @image-url("../icons/lucide/outline/copy.svg") },
{ id: "spam", title: @tr("Move to spam"), icon: @image-url("../icons/lucide/outline/alert.svg") },
{ id: root.trash_view ? "delete_permanently" : "trash", title: root.trash_view ? @tr("Delete permanently…") : @tr("Move to trash"), icon: @image-url("../icons/lucide/outline/trash.svg"), destructive: true },
];
@@ -385,6 +387,7 @@ export component MailReadingPane inherits FocusScope {
{ id: "original_colors", title: MessageColors.original ? @tr("Use message appearance") : @tr("Show original colors"), icon: @image-url("../icons/lucide/outline/contrast.svg") },
{ id: "read", title: root.selected_unread ? @tr("Mark as read") : @tr("Mark as unread"), icon: root.selected_unread ? @image-url("../icons/lucide/outline/mail-open.svg") : @image-url("../icons/lucide/outline/mail.svg") },
{ id: "move", title: @tr("Move to…"), icon: @image-url("../icons/lucide/outline/folder-move.svg") },
{ id: "copy", title: @tr("Copy to…"), icon: @image-url("../icons/lucide/outline/copy.svg") },
{ id: root.trash_view ? "delete_permanently" : "trash", title: root.trash_view ? @tr("Delete permanently…") : @tr("Move to trash"), icon: @image-url("../icons/lucide/outline/trash.svg"), destructive: true },
];
private property <[ActionMenuEntry]> local-action-menu-no-print: [
@@ -401,6 +404,7 @@ export component MailReadingPane inherits FocusScope {
{ id: "original_colors", title: MessageColors.original ? @tr("Use message appearance") : @tr("Show original colors"), icon: @image-url("../icons/lucide/outline/contrast.svg") },
{ id: "read", title: root.selected_unread ? @tr("Mark as read") : @tr("Mark as unread"), icon: root.selected_unread ? @image-url("../icons/lucide/outline/mail-open.svg") : @image-url("../icons/lucide/outline/mail.svg") },
{ id: "move", title: @tr("Move to…"), icon: @image-url("../icons/lucide/outline/folder-move.svg") },
{ id: "copy", title: @tr("Copy to…"), icon: @image-url("../icons/lucide/outline/copy.svg") },
{ id: root.trash_view ? "delete_permanently" : "trash", title: root.trash_view ? @tr("Delete permanently…") : @tr("Move to trash"), icon: @image-url("../icons/lucide/outline/trash.svg"), destructive: true },
];
// The reader toolbar drops actions that the More menu also offers when
@@ -565,7 +569,15 @@ export component MailReadingPane inherits FocusScope {
enabled: root.message-keys && !root.selected_is_draft && !EmailReader.find-open;
activated => {
MovePickerUi.return-to = "reader";
MovePickerUi.request("open", -1);
MovePickerUi.request("move", "open", -1);
}
}
KeyBinding {
keys: Shortcuts.copy-to;
enabled: root.message-keys && !root.selected_is_draft && !EmailReader.find-open;
activated => {
MovePickerUi.return-to = "reader";
MovePickerUi.request("copy", "open", -1);
}
}
@@ -1064,9 +1076,9 @@ export component MailReadingPane inherits FocusScope {
MessageColors.original = !MessageColors.original;
} else if action == "read" {
root.message_action(root.selected_unread ? "mark_read" : "mark_unread");
} else if action == "move" {
} else if action == "move" || action == "copy" {
MovePickerUi.return-to = "reader";
MovePickerUi.request("open", -1);
MovePickerUi.request(action, "open", -1);
} else {
root.message_action(action);
}