feat: unsubscribe from mailing lists in the reader

This commit is contained in:
Tomás Romero committed 2026-11-11 00:12:53 +01:00
1 parent 0cc48d7ba9
commit 01d47d0d18
16 files changed
+1584 -675

No files matched your search

+779 -672
View File
File diff suppressed because it is too large. Load diff
+17 -1
View File
@@ -324,6 +324,16 @@ pub(super) fn reopen_draft(draft_id: i64) {
});
}
/// Open a new message already written, for the user to look over and send,
/// such as an emailed unsubscribe request (RFC 2369 appendix B.2.4).
pub(super) fn compose_written(message: SaveDraftArgs) {
with_composers(|composers| {
if let Some(app) = composers.app.upgrade() {
composers.show_written(&app, message, "new");
}
});
}
/// The application is ending. A message someone was writing, in the main
/// window or one of its own, goes to Drafts: nothing else would keep it.
pub(super) fn save_open_messages() {
@@ -748,6 +758,12 @@ impl Composers {
/// Open a saved draft in a composer.
fn show_draft(self: &Rc<Self>, app: &AppWindow, draft: SaveDraftArgs) {
self.show_written(app, draft, "draft");
}
/// Open a written message in a composer: a saved draft (`mode` "draft"),
/// or a new message the user has yet to look over ("new").
fn show_written(self: &Rc<Self>, app: &AppWindow, draft: SaveDraftArgs, mode: &str) {
// A draft already open in a composer comes forward instead of opening
// twice, where each copy's saves would overwrite the other's.
if let Some(open) = draft.draft_id.and_then(|id| self.holding_draft(id)) {
@@ -843,7 +859,7 @@ impl Composers {
in_reply_to_header: draft.in_reply_to_header,
draft_id: draft.draft_id,
};
session.show("draft", Some(draft_sender_email));
session.show(mode, Some(draft_sender_email));
}
/// A send reached its end: a sent message closes its composer, a failed
+71 -2
View File
@@ -11,8 +11,8 @@ use flectar_mail_core::{
DraftAttachmentIn, FolderInfo, Label, MailGrouping, MailHistory, MailProfile, MailProtocol,
MailboxBadgeCounts, MessageCursor, MessageDetail, MessageSearchCursor, MessageSummary,
PerformActionArgs, PortableAccountConfig, Provider, QueueSendArgs, QueueSendResult,
SaveDraftArgs, SearchCursor, Settings, Snippet, ThreadCursor, ThreadSummary, View,
normalized_workspace_list_pane_width, roles,
SaveDraftArgs, SearchCursor, Settings, Snippet, ThreadCursor, ThreadSummary,
UnsubscribeOffer, UnsubscribeOutcome, View, normalized_workspace_list_pane_width, roles,
},
};
#[cfg(test)]
@@ -87,6 +87,9 @@ pub struct MailMessage {
/// Empty, or the strongest receiver-reported authentication kind:
/// domain, microsoft, bimi, or brand.
pub sender_verification: String,
/// The message names a way to leave the mailing list it came from, and
/// is neither one's own nor in the spam folder.
pub can_unsubscribe: bool,
/// The one-time code the message asks its reader to enter.
pub verification_code: Option<String>,
/// Gmail labels are tags; other accounts file labels like folders.
@@ -129,6 +132,7 @@ impl MailMessage {
text: None,
body_pending: false,
sender_verification: String::new(),
can_unsubscribe: false,
verification_code: None,
account_uses_labels: false,
first_attachment: None,
@@ -1815,6 +1819,63 @@ impl CoreMailSource {
.map_err(|error| error.to_string())
}
/// The message a row leaves its mailing list from: the message itself,
/// or the newest of a conversation's that names a way to.
pub async fn unsubscribe_message_of(&self, target: MailTarget) -> Result<Option<i64>, String> {
match target {
MailTarget::Message { id, .. } => Ok(Some(id)),
MailTarget::Thread(thread_id) => self
.core
.thread_unsubscribe_message(thread_id)
.await
.map_err(|error| error.to_string()),
}
}
pub async fn unsubscribe_offer(
&self,
message_id: i64,
) -> Result<Option<UnsubscribeOffer>, String> {
self.core
.unsubscribe_offer(message_id)
.await
.map_err(|error| error.to_string())
}
pub async fn unsubscribe(&self, message_id: i64) -> Result<UnsubscribeOutcome, String> {
self.core
.unsubscribe_message(message_id)
.await
.map_err(|error| error.to_string())
}
/// The inbox messages from the list `message_id` came from.
pub async fn list_inbox_messages(&self, message_id: i64) -> Result<Vec<i64>, String> {
self.core
.list_inbox_messages(message_id)
.await
.map_err(|error| error.to_string())
}
/// Archive or delete these messages; the ids of the actions, for Undo.
pub async fn act_on_messages(
&self,
message_ids: Vec<i64>,
action: &str,
) -> Result<Vec<i64>, String> {
let kind = ActionKind::parse(action)
.ok_or_else(|| format!("unsupported message action: {action}"))?;
self.core
.perform_action(PerformActionArgs {
kind,
target: ActionTarget::Messages(message_ids),
params: None,
})
.await
.map(|result| result.action_ids)
.map_err(|error| error.to_string())
}
/// Take back the actions of one gesture. False when none could be.
pub async fn undo_actions(&self, action_ids: Vec<i64>) -> Result<bool, String> {
self.core
@@ -2977,6 +3038,7 @@ fn summary_to_message(
text: None,
body_pending: true,
sender_verification: String::new(),
can_unsubscribe: false,
verification_code: thread.verification_code,
account_uses_labels: thread_account
.is_some_and(|account| account.mail_protocol == MailProtocol::Gmail),
@@ -3082,6 +3144,7 @@ fn message_summary_to_message(
text: None,
body_pending: true,
sender_verification: String::new(),
can_unsubscribe: false,
verification_code: message.verification_code,
account_uses_labels: message_account
.is_some_and(|account| account.mail_protocol == MailProtocol::Gmail),
@@ -3192,6 +3255,12 @@ fn detail_to_message(row: &MailMessage, message: &MessageDetail, body_loaded: bo
},
body_pending: message.body_state != "cached",
sender_verification: message.sender_verification.as_str().to_owned(),
can_unsubscribe: !message.is_draft
&& !message.is_outgoing
&& !message.in_spam
&& message.list_unsubscribe.as_deref().is_some_and(|header| {
flectar_mail_core::unsubscribe::plan(header, false).is_some()
}),
verification_code: row.verification_code.clone(),
account_uses_labels: row.account_uses_labels,
first_attachment: row.first_attachment.clone(),
+3
View File
@@ -119,6 +119,7 @@ pub(super) fn email_viewport_size(app: &AppWindow) -> (u32, u32) {
pub(super) fn clear_reader_projection(app: &AppWindow) {
app.global::<MessageColors>().set_original(false);
app.global::<AccountMailPreferences>().invoke_close_reader();
app.global::<Unsubscribe>().set_available(false);
let reader = app.global::<EmailReader>();
reader.set_available(false);
reader.set_body_pending(false);
@@ -157,6 +158,8 @@ pub(super) fn apply_email(
app.set_selected_starred(email.starred);
app.set_selected_unread(email.unread);
app.set_selected_sender_verification(email.sender_verification.clone().into());
app.global::<Unsubscribe>()
.set_available(email.can_unsubscribe);
let reader = app.global::<EmailReader>();
reader.set_body_pending(email.body_pending);
reader.set_authored_text(email.text.clone().unwrap_or_default().into());
+1
View File
@@ -1871,6 +1871,7 @@ mod tests {
attachments: Vec::new(),
body_pending: true,
sender_verification: String::new(),
can_unsubscribe: false,
verification_code: None,
account_uses_labels: false,
first_attachment: None,
+2
View File
@@ -89,6 +89,7 @@ mod theme;
mod ui_dispatch;
mod ui_message;
mod undo;
mod unsubscribe;
mod updates;
mod window_chrome;
mod window_controller;
@@ -6081,6 +6082,7 @@ pub fn run(platform: PlatformContext) -> Result<(), Box<dyn std::error::Error>>
);
mailto_links::install(&app, composers);
undo::install(&app, &state, &runtime);
unsubscribe::install(&app, &state, &runtime, platform.external_urls.clone());
#[cfg(target_os = "macos")]
mailto_links::receive_from_macos(&app);
notifications::ui::attach(&app, &state, &runtime, platform.notifications.clone());
+3
View File
@@ -140,6 +140,8 @@ impl From<WarmStartMessage> for mail::MailMessage {
attachments: Vec::new(),
body_pending: true,
sender_verification: message.sender_verification,
// The banner waits for the message itself.
can_unsubscribe: false,
verification_code: None,
account_uses_labels: message.account_uses_labels,
first_attachment: None,
@@ -1267,6 +1269,7 @@ mod warm_start_tests {
attachments: Vec::new(),
body_pending: false,
sender_verification: "domain".into(),
can_unsubscribe: false,
verification_code: Some("48291375".into()),
account_uses_labels: true,
first_attachment: Some(flectar_mail_core::models::AttachmentMeta {
+8
View File
@@ -34,6 +34,8 @@ enum Shown {
Send { action_id: i64, draft_id: i64 },
/// The actions of one archive, move, deletion or change of marks.
Gesture { action_ids: Vec<i64> },
/// News with nothing to take back.
Note,
}
struct Undo {
@@ -134,6 +136,12 @@ pub(super) fn gesture(app: &AppWindow, action_ids: Vec<i64>, batch: UndoableBatc
});
}
/// Tell of something done that cannot be taken back here, such as leaving
/// a mailing list.
pub(super) fn tell(app: &AppWindow, message: UiMessage) {
with_undo(|undo| undo.show(app, Shown::Note, message, "", Some(ANSWER_NOTICE_FOR)));
}
/// What the notice says of a batch.
fn gesture_message(batch: UndoableBatch) -> UiMessage {
let count = batch.count;
+348
View File
@@ -0,0 +1,348 @@
//! Leaving a mailing list from the reader (#227). The core works out how the
//! list takes a request; a dialog asks first, as RFC 2369 and RFC 8058 want;
//! and after the request, the list's other mail in the inbox can be
//! archived or moved to Trash at once, with Undo.
use super::*;
use crate::mail_work::UndoableBatch;
use flectar_mail_core::models::{Address, SaveDraftArgs, UnsubscribeOffer, UnsubscribeOutcome};
use std::future::Future;
/// The list the dialog is about.
struct Leaving {
message_id: i64,
account_id: i64,
offer: UnsubscribeOffer,
/// Its messages in the inbox, once the request is made.
inbox: Vec<i64>,
}
thread_local! {
static LEAVING: RefCell<Option<Leaving>> = const { RefCell::new(None) };
}
pub(super) fn install(
app: &AppWindow,
state: &Rc<RefCell<InboxState>>,
runtime: &tokio::runtime::Handle,
opener: ExternalUrlOpener,
) {
let ui = app.global::<Unsubscribe>();
let controller = Rc::new(Controller {
app: app.as_weak(),
state: Rc::clone(state),
runtime: runtime.clone(),
opener,
});
let this = Rc::clone(&controller);
ui.on_start(move || this.start());
let this = Rc::clone(&controller);
ui.on_confirm(move || this.confirm());
let this = Rc::clone(&controller);
ui.on_review(move || this.review());
let this = Rc::clone(&controller);
ui.on_clean_up(move |action| this.clean_up(&action));
let this = controller;
ui.on_close(move || this.close());
}
struct Controller {
app: slint::Weak<AppWindow>,
state: Rc<RefCell<InboxState>>,
runtime: tokio::runtime::Handle,
opener: ExternalUrlOpener,
}
impl Controller {
/// The banner or the menu: find out how the open message's list takes a
/// request, and ask.
fn start(&self) {
let Some(app) = self.app.upgrade() else {
return;
};
if !app.global::<Unsubscribe>().get_stage().is_empty() {
return;
}
let (core, row) = {
let state = self.state.borrow();
let row = state
.selected_id
.and_then(|id| state.messages.iter().find(|message| message.id == id))
.cloned();
(state.core.clone(), row)
};
let (Some(core), Some(row)) = (core, row) else {
return;
};
let Some(target) = MailTarget::of(&row) else {
return;
};
let account_id = row.account_id;
let sender = app.get_selected_sender().to_string();
self.spawn(
async move {
let Some(message_id) = core.unsubscribe_message_of(target).await? else {
return Ok(None);
};
Ok(core
.unsubscribe_offer(message_id)
.await?
.map(|offer| (message_id, offer)))
},
move |app, result| match result {
Ok(Some((message_id, offer))) => {
let ui = app.global::<Unsubscribe>();
ui.set_sender(sender.into());
show_offer(&ui, &offer);
ui.set_inbox_count(0);
ui.set_stage("confirm".into());
LEAVING.with(|slot| {
*slot.borrow_mut() = Some(Leaving {
message_id,
account_id,
offer,
inbox: Vec::new(),
});
});
}
Ok(None) => crate::undo::tell(
&app,
UiMessage::plain("This message names no way to unsubscribe."),
),
Err(error) => {
crate::undo::tell(&app, UiMessage::detail("Could not unsubscribe: {}", error))
}
},
);
}
/// The dialog's main button: make the request, or open the list's page.
fn confirm(&self) {
let Some(app) = self.app.upgrade() else {
return;
};
let Some((message_id, offer)) = LEAVING.with(|slot| {
slot.borrow()
.as_ref()
.map(|leaving| (leaving.message_id, leaving.offer.clone()))
}) else {
return;
};
if let UnsubscribeOffer::Website { url } = &offer {
let url = url.clone();
self.close();
if let Err(error) = crate::mail_render_projection::open_email_link(&self.opener, &url) {
crate::undo::tell(&app, UiMessage::detail("Could not open link: {}", error));
}
return;
}
let Some(core) = self.state.borrow().core.clone() else {
return;
};
let ui = app.global::<Unsubscribe>();
ui.set_stage("working".into());
self.spawn(
async move {
let outcome = core.unsubscribe(message_id).await?;
let inbox = match outcome {
UnsubscribeOutcome::NeedsBrowser { .. } => Vec::new(),
// The request is made: a failure to count the list's
// mail only means not offering to clear it.
_ => core
.list_inbox_messages(message_id)
.await
.unwrap_or_default(),
};
Ok((outcome, inbox))
},
move |app, result| {
// Closed while the request went out: the request still
// counts, and the notice tells of it.
let open = LEAVING.with(|slot| {
slot.borrow()
.as_ref()
.is_some_and(|leaving| leaving.message_id == message_id)
});
let ui = app.global::<Unsubscribe>();
match result {
Ok((UnsubscribeOutcome::NeedsBrowser { url }, _)) if open => {
ui.set_host(host_of(&url).into());
ui.set_stage("website".into());
LEAVING.with(|slot| {
if let Some(leaving) = slot.borrow_mut().as_mut() {
leaving.offer = UnsubscribeOffer::Website { url };
}
});
}
Ok((UnsubscribeOutcome::NeedsBrowser { .. }, _)) => {}
Ok((outcome, inbox)) => {
let sender = ui.get_sender().to_string();
let to = ui.get_to().to_string();
if open && !inbox.is_empty() {
ui.set_inbox_count(i32::try_from(inbox.len()).unwrap_or(i32::MAX));
ui.set_stage("done".into());
LEAVING.with(|slot| {
if let Some(leaving) = slot.borrow_mut().as_mut() {
leaving.inbox = inbox;
}
});
return;
}
close_dialog(&app);
crate::undo::tell(
&app,
match outcome {
UnsubscribeOutcome::MailtoSent => {
UiMessage::detail("Unsubscribe request sent to {}.", to)
}
_ => UiMessage::detail("Unsubscribed from {}.", sender),
},
);
}
Err(error) if open => {
ui.set_error(error.into());
ui.set_stage("error".into());
}
Err(error) => crate::undo::tell(
&app,
UiMessage::detail("Could not unsubscribe: {}", error),
),
}
},
);
}
/// Open an emailed request in the composer, to be looked over and sent
/// there (RFC 2369 appendix B.2.4).
fn review(&self) {
let Some(leaving) = LEAVING.with(|slot| slot.borrow_mut().take()) else {
return;
};
self.close();
let UnsubscribeOffer::Mailto {
from,
to,
subject,
body,
} = leaving.offer
else {
return;
};
crate::composer::compose_written(SaveDraftArgs {
draft_id: None,
account_id: leaving.account_id,
from: Some(Address {
name: None,
email: from,
}),
to: vec![Address {
name: None,
email: to,
}],
cc: Vec::new(),
bcc: Vec::new(),
subject,
body_text: body,
body_html: None,
mode: "new".into(),
in_reply_to_message_id: None,
in_reply_to_header: None,
attachments: Vec::new(),
});
}
/// Archive the list's messages in the inbox, or move them to Trash.
fn clean_up(&self, action: &str) {
let Some(inbox) = LEAVING.with(|slot| slot.borrow().as_ref().map(|l| l.inbox.clone()))
else {
return;
};
self.close();
let Some(core) = self.state.borrow().core.clone() else {
return;
};
let kind: &'static str = if action == "trash" {
"trash"
} else {
"archive"
};
let count = inbox.len();
self.spawn(
async move { core.act_on_messages(inbox, kind).await },
move |app, result| match result {
Ok(action_ids) if !action_ids.is_empty() => crate::undo::gesture(
&app,
action_ids,
UndoableBatch {
kind,
conversations: false,
count,
},
),
Ok(_) => {}
Err(error) => {
app.set_render_status(UiMessage::detail("Message action failed: {}", error))
}
},
);
}
fn close(&self) {
LEAVING.with(|slot| slot.borrow_mut().take());
if let Some(app) = self.app.upgrade() {
close_dialog(&app);
}
}
/// Run `work` on the runtime and `apply` its result on the UI thread.
fn spawn<T: Send + 'static>(
&self,
work: impl Future<Output = Result<T, String>> + Send + 'static,
apply: impl FnOnce(AppWindow, Result<T, String>) + 'static,
) {
let weak = self.app.clone();
let task = self.runtime.spawn(work);
let abort = task.abort_handle();
if slint::spawn_local(async move {
let result = task.await.unwrap_or_else(|error| Err(error.to_string()));
if let Some(app) = weak.upgrade() {
apply(app, result);
}
})
.is_err()
{
abort.abort();
}
}
}
fn close_dialog(app: &AppWindow) {
app.global::<Unsubscribe>().set_stage("".into());
}
/// What the dialog says about how the list takes the request.
fn show_offer(ui: &Unsubscribe, offer: &UnsubscribeOffer) {
match offer {
UnsubscribeOffer::OneClick { host } => {
ui.set_kind("one-click".into());
ui.set_host(host.into());
}
UnsubscribeOffer::Mailto { from, to, .. } => {
ui.set_kind("mailto".into());
ui.set_from(from.into());
ui.set_to(to.into());
}
UnsubscribeOffer::Website { url } => {
ui.set_kind("website".into());
ui.set_host(host_of(url).into());
}
}
}
/// The host a web address names, or the address itself.
fn host_of(url: &str) -> String {
url::Url::parse(url)
.ok()
.and_then(|url| url.host_str().map(str::to_owned))
.unwrap_or_else(|| url.to_owned())
}
+15
View File
@@ -11,6 +11,7 @@ export { DefaultEmailApp } from "default-email-app.slint";
export { SystemNotifications } from "system-notifications.slint";
export { Updates } from "updates.slint";
export { Notice } from "notice.slint";
export { Unsubscribe } from "unsubscribe.slint";
import { AppIcon, AppIconVariant } from "app-icon.slint";
import { WindowChrome, WindowControls, WindowDragArea } from "window-chrome.slint";
// Bundled once at compile time and used throughout the application chrome.
@@ -39,6 +40,8 @@ import { CalendarEventDialog } from "dialogs/calendar-event-dialog.slint";
import { ComposeDialog } from "dialogs/compose-dialog.slint";
import { NoticeToast } from "components/notice-toast.slint";
import { Notice } from "notice.slint";
import { UnsubscribeDialog } from "dialogs/unsubscribe-dialog.slint";
import { Unsubscribe } from "unsubscribe.slint";
import { BoundComposer, ComposerUi } from "components/composer-ui.slint";
import { ComposerText } from "components/composer-fields.slint";
export { ComposerUi } from "components/composer-ui.slint";
@@ -585,6 +588,10 @@ export global I18n {
if (key == "{} messages unstarred.") { return @tr("Message unstarred." | "{0} messages unstarred." % round(argument.to-float()), argument); }
if (key == "This can no longer be undone.") { return @tr("This can no longer be undone."); }
if (key == "Could not undo: {}") { return @tr("Could not undo: {}", argument); }
if (key == "This message names no way to unsubscribe.") { return @tr("This message names no way to unsubscribe."); }
if (key == "Could not unsubscribe: {}") { return @tr("Could not unsubscribe: {}", argument); }
if (key == "Unsubscribed from {}.") { return @tr("Unsubscribed from {}.", argument); }
if (key == "Unsubscribe request sent to {}.") { return @tr("Unsubscribe request sent to {}.", argument); }
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); }
@@ -2308,6 +2315,14 @@ export component AppWindow inherits Window {
}
}
if Unsubscribe.stage != "" : UnsubscribeDialog {
x: root.content-x;
y: root.content-y;
width: root.content-width;
height: root.content-height;
z: 16000;
}
if root.trash-delete-open : ConfirmationDialog {
x: root.content-x;
y: root.content-y;
+231
View File
@@ -0,0 +1,231 @@
import { VerticalBox, HorizontalBox } from "std-widgets.slint";
import { AppTheme } from "../design-system.slint";
import { Button } from "../components/button.slint";
import { TypeScale } from "../typography.slint";
import { Unsubscribe } from "../unsubscribe.slint";
// Asks before leaving a mailing list, as RFC 2369 and RFC 8058 want, says
// how it went, and then offers to clear the list's mail out of the inbox
// (src/unsubscribe.rs).
export component UnsubscribeDialog inherits Rectangle {
private property <bool> narrow: panel.width < 360px;
private property <bool> emailed: Unsubscribe.kind == "mailto";
// Asking first, and while the request goes out: the dialog keeps what
// it asked, its buttons disabled.
private property <bool> asking: Unsubscribe.stage == "confirm" || Unsubscribe.stage == "working";
private property <bool> working: Unsubscribe.stage == "working";
private property <string> title: Unsubscribe.stage == "error" ? @tr("Could not unsubscribe")
: Unsubscribe.stage == "done" && root.emailed ? @tr("Unsubscribe request sent")
: Unsubscribe.stage == "done" ? @tr("Unsubscribed from {}", Unsubscribe.sender)
: @tr("Unsubscribe from {}?", Unsubscribe.sender);
private property <string> description: Unsubscribe.stage == "error" ? Unsubscribe.error
: Unsubscribe.stage == "website" && Unsubscribe.kind == "one-click"
? @tr("{} did not take the request. You can unsubscribe on its website instead.", Unsubscribe.host)
: Unsubscribe.stage == "done" && root.emailed
? @tr("Your request went to {}. It can take a few days for the mail to stop.", Unsubscribe.to)
: Unsubscribe.stage == "done" ? @tr("It can take a few days for the mail to stop.")
: Unsubscribe.kind == "one-click" ? @tr("Flectar Mail will ask {} to stop sending you this mail.", Unsubscribe.host)
: root.emailed ? @tr("Flectar Mail will send an email asking the list to unsubscribe you.")
: @tr("{} takes unsubscribe requests on its website.", Unsubscribe.host);
private property <string> detail: root.asking && Unsubscribe.kind == "website" ? @tr("The page opens in your browser.")
: Unsubscribe.stage == "confirm" && root.emailed ? @tr("You can review the email before it goes.")
: root.asking ? @tr("It can take a few days for the mail to stop.")
: "";
private property <bool> cleaning: Unsubscribe.stage == "done" && Unsubscribe.inbox-count > 0;
visible: Unsubscribe.stage != "";
background: #00000088;
z: 1000;
accessible-role: groupbox;
accessible-label: root.title;
function cancel() {
if !root.working {
Unsubscribe.close();
}
}
changed visible => {
if self.visible {
dialog-focus.focus();
}
}
// Consume pointer input over the scrim.
TouchArea { }
panel := Rectangle {
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
width: min(460px, parent.width - 32px);
height: max(212px, dialog-content.preferred-height);
border-radius: 12px;
border-width: 1px;
border-color: AppTheme.border;
background: AppTheme.surface;
drop-shadow-blur: 24px;
drop-shadow-offset-y: 8px;
drop-shadow-color: #00000055;
dialog-focus := FocusScope {
key-pressed(event) => {
if event.text == Key.Escape {
root.cancel();
return accept;
}
reject
}
dialog-content := VerticalBox {
padding-top: 22px;
padding-right: 22px;
padding-bottom: 20px;
padding-left: 22px;
spacing: 12px;
HorizontalBox {
height: max(root.narrow ? 56px : 42px, dialog-title.preferred-height + 16px);
padding: 0px;
spacing: 12px;
Rectangle {
width: 42px;
height: 42px;
border-radius: 21px;
background: AppTheme.accent-soft;
Image {
x: 11px;
y: 11px;
width: 20px;
height: 20px;
source: @image-url("../icons/lucide/outline/mail-x.svg");
colorize: AppTheme.accent;
image-fit: contain;
accessible-role: none;
}
}
dialog-title := Text {
text: root.title;
color: AppTheme.title;
font-size: TypeScale.heading;
font-weight: 700;
vertical-alignment: center;
wrap: word-wrap;
}
}
Text {
height: max(24px, self.preferred-height);
text: root.description;
color: AppTheme.body;
font-size: TypeScale.secondary;
wrap: word-wrap;
}
// RFC 2369 appendix B.1: where an emailed request goes, and
// from which address. Each on a line of its own, so an
// address doesn't break in two.
if root.asking && root.emailed : GridLayout {
spacing-horizontal: 12px;
spacing-vertical: 4px;
Row {
Text { text: @tr("To"); color: AppTheme.muted; font-size: TypeScale.secondary; }
Text { text: Unsubscribe.to; color: AppTheme.body; font-size: TypeScale.secondary; font-weight: 600; wrap: char-wrap; horizontal-stretch: 1; }
}
Row {
Text { text: @tr("From"); color: AppTheme.muted; font-size: TypeScale.secondary; }
Text { text: Unsubscribe.from; color: AppTheme.body; font-size: TypeScale.secondary; font-weight: 600; wrap: char-wrap; horizontal-stretch: 1; }
}
}
if root.cleaning : Text {
height: self.preferred-height;
text: @tr("One message from this list is still in your inbox." | "{n} messages from this list are still in your inbox." % Unsubscribe.inbox-count);
color: AppTheme.body;
font-size: TypeScale.secondary;
wrap: word-wrap;
}
if root.detail != "" : Text {
height: self.preferred-height;
text: root.detail;
color: AppTheme.muted;
font-size: TypeScale.caption;
wrap: word-wrap;
}
Rectangle { vertical-stretch: 1; }
// Wide panels put the buttons in a row, the main one last;
// narrow ones stack them, the main one first.
if root.asking || Unsubscribe.stage == "website" : GridLayout {
spacing: 8px;
Button {
row: root.narrow ? (root.emailed && root.asking ? 2 : 1) : 0;
col: 0;
height: 34px;
enabled: !root.working;
text: @tr("Cancel");
clicked => { Unsubscribe.close(); }
}
if root.emailed && root.asking : Button {
row: root.narrow ? 1 : 0;
col: root.narrow ? 0 : 1;
height: 34px;
enabled: !root.working;
text: @tr("Review email");
clicked => { Unsubscribe.review(); }
}
Button {
row: 0;
col: root.narrow ? 0 : (root.emailed && root.asking ? 2 : 1);
height: 34px;
primary: true;
enabled: !root.working;
text: root.working ? @tr("Unsubscribing…")
: Unsubscribe.kind == "website" || Unsubscribe.stage == "website" ? @tr("Open website")
: @tr("Unsubscribe");
clicked => { Unsubscribe.confirm(); }
}
}
if root.cleaning : GridLayout {
spacing: 8px;
Button {
row: root.narrow ? 2 : 0;
col: 0;
height: 34px;
text: @tr("Keep them");
clicked => { Unsubscribe.close(); }
}
Button {
row: root.narrow ? 1 : 0;
col: root.narrow ? 0 : 1;
height: 34px;
text: @tr("Move to trash");
clicked => { Unsubscribe.clean-up("trash"); }
}
Button {
row: 0;
col: root.narrow ? 0 : 2;
height: 34px;
primary: true;
text: @tr("Archive");
clicked => { Unsubscribe.clean-up("archive"); }
}
}
if Unsubscribe.stage == "error" : HorizontalBox {
padding: 0px;
spacing: 8px;
Rectangle { horizontal-stretch: 1; }
if root.emailed : Button {
height: 34px;
text: @tr("Review email");
clicked => { Unsubscribe.review(); }
}
Button {
height: 34px;
min-width: 96px;
primary: true;
text: @tr("Close");
clicked => { Unsubscribe.close(); }
}
}
}
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<!-- Generated display outline; originals and license in ../sources/ and ../LICENSE. -->
<!-- @license lucide-static v1.46.0 - ISC -->
<svg
class="lucide lucide-mail-x"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12.532V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8.792" />
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" />
<path d="m16.5 16.5 5 5" />
<path d="m21.5 16.5-5 5" />
</svg>

After

Width:  |  Height:  |  Size: 572 B

+7
View File
@@ -135,6 +135,9 @@
"mail.svg": {
"source": "mail"
},
"mail-x.svg": {
"source": "mail-x"
},
"paper-airplane.svg": {
"source": "send"
},
@@ -459,6 +462,10 @@
"path": "sources/mail-open.svg",
"sha256": "956ac99f10de1ea60b444ed7f4e7834a6604a2e57e4c71b349106def64ea5d9b"
},
"mail-x": {
"path": "sources/mail-x.svg",
"sha256": "60f0553127a77adbefafd92cdfc692988444e8a4df0abba4494295cbc4f1fe38"
},
"maximize": {
"path": "sources/maximize.svg",
"sha256": "ec608f4e02fa0f9ec9da6b0b9b50b4796def96462d2095211ea2714b740db35b"
+18
View File
@@ -0,0 +1,18 @@
<!-- @license lucide-static v1.46.0 - ISC -->
<svg
class="lucide lucide-mail-x"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M22 12.532V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8.792" />
<path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7" />
<path d="m16.5 16.5 5 5" />
<path d="m21.5 16.5-5 5" />
</svg>

After

Width:  |  Height:  |  Size: 481 B

+28
View File
@@ -0,0 +1,28 @@
// Leaving the mailing list the open message came from (src/unsubscribe.rs).
export global Unsubscribe {
// The open message names a way to leave its list.
in property <bool> available;
// Where the dialog is: "" (closed), "confirm", "working", "done",
// "website" (the list's page has to finish it) or "error".
in property <string> stage;
// How the list takes the request: "one-click", "mailto" or "website".
in property <string> kind;
// Who the list mail is from.
in property <string> sender;
// The web host a request goes to.
in property <string> host;
// An emailed request's address, and the address it goes from.
in property <string> to;
in property <string> from;
// After the request, the list's messages still in the inbox.
in property <int> inbox-count;
// Why the request failed, as the core put it.
in property <string> error;
callback start();
callback confirm();
// Open an emailed request in the composer instead of sending it.
callback review();
// "archive" or "trash" the list's messages in the inbox.
callback clean-up(string);
callback close();
}
+34
View File
@@ -20,6 +20,7 @@ import { MessageColors } from "../message-colors.slint";
import { WindowChrome, WindowControlsClearance } from "../window-chrome.slint";
import { Shortcuts } from "../shortcuts.slint";
import { LocalMailUi } from "../local-mail.slint";
import { Unsubscribe } from "../unsubscribe.slint";
component ReaderControl inherits ToolbarIconButton {
tooltip-above: true;
@@ -352,6 +353,7 @@ export component MailReadingPane inherits FocusScope {
{ 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: "unsubscribe", title: @tr("Unsubscribe…"), icon: @image-url("../icons/lucide/outline/mail-x.svg"), disabled: !Unsubscribe.available },
{ 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 },
];
@@ -372,6 +374,7 @@ export component MailReadingPane inherits FocusScope {
{ 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: "unsubscribe", title: @tr("Unsubscribe…"), icon: @image-url("../icons/lucide/outline/mail-x.svg"), disabled: !Unsubscribe.available },
{ 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 },
];
@@ -1073,6 +1076,8 @@ export component MailReadingPane inherits FocusScope {
label-popup.show();
} else if action == "openpgp" {
AccountMailPreferences.read-message();
} else if action == "unsubscribe" {
Unsubscribe.start();
} else if action == "print" {
root.print_message();
} else if action == "browser" {
@@ -1311,6 +1316,35 @@ export component MailReadingPane inherits FocusScope {
ReaderControl { compact: root.compact; icon: @image-url("../icons/lucide/outline/chevron-down.svg"); tooltip-text: @tr("Next match"); clicked => { EmailReader.find(EmailReader.query, 1); } }
ReaderControl { compact: root.compact; icon: @image-url("../icons/lucide/outline/x.svg"); tooltip-text: @tr("Close find"); clicked => { EmailReader.find-open = false; } }
}
// RFC 2369 list mail: a way out, above the message as
// Apple Mail, Outlook and Fastmail put it.
if Unsubscribe.available && !LocalMailUi.selected : HorizontalBox {
// The padding leaves the button's height, so the
// text and icon center on it.
height: root.compact ? 44px : 32px; padding: 0px; spacing: 8px;
padding-top: root.compact ? 4px : 3px; padding-bottom: root.compact ? 4px : 3px;
accessible-role: groupbox;
accessible-label: @tr("This message is from a mailing list.");
Rectangle {
width: LayoutMetrics.icon-size;
Image {
y: (parent.height - self.height) / 2;
width: LayoutMetrics.icon-size; height: LayoutMetrics.icon-size;
source: @image-url("../icons/lucide/outline/mail-x.svg");
colorize: root.secondary-text;
image-fit: contain;
accessible-role: none;
}
}
Text { text: @tr("This message is from a mailing list."); overflow: elide; color: root.secondary-text; vertical-alignment: center; horizontal-stretch: 1; }
Button {
width: max(96px, self.content-width);
height: root.compact ? 36px : 26px;
text: @tr("Unsubscribe");
accessible-id: "message-unsubscribe";
clicked => { Unsubscribe.start(); }
}
}
if EmailReader.failed-images > 0 && !root.remote-images-blocked && !root.text_mode && !root.source_mode && !EmailReader.reader-mode : HorizontalBox {
height: root.compact ? 44px : 32px; padding: 0px; spacing: 6px;
Text { text: @tr("Some images could not be loaded."); overflow: elide; color: root.secondary-text; vertical-alignment: center; horizontal-stretch: 1; }