some fixes

This commit is contained in:
pj committed 2026-10-03 21:49:47 +05:30
1 parent 660aef323b
commit 7274a54a6e
29 files changed
+860 -315

No files matched your search

+33 -13
View File
@@ -392,28 +392,22 @@ fn free_path(dir: &Path, filename: &str) -> PathBuf {
// Commands
// ---------------------------------------------------------------------------------------------
/// The reader has asked to see the pictures.
///
/// The answer is a view and not a stored row on purpose: the `bodies` table has no column saying
/// whether images were loaded, so this is per view rather than remembered, and closing the thread
/// puts the block back. Inventing a column for it would also be inventing a policy, and the policy
/// that roams with a person is the per sender allowance on the contact card, not a flag on a body.
#[tauri::command]
pub async fn message_show_images(
app: tauri::AppHandle,
account_id: String,
message_id: String,
) -> Result<MessageView, String> {
let db = db_of(&app)?;
let account_id = account_holding(
db.inner(),
"SELECT COUNT(*) FROM messages WHERE id = ?1",
&message_id,
)?;
let (raw, options) = db.with(&account_id, |conn| {
let options = sync::hydrate::render_options(conn)?;
let view = read::message_view(conn, &account_id, &message_id, &options.own_addresses)?;
if !view.images_allowed {
return Err("images are hidden for this email".into());
}
let raw = read::raw_body(conn, &message_id)?
.ok_or("that message has not been fetched yet")?;
Ok((raw, sync::hydrate::render_options(conn)?))
Ok((raw, options))
})?;
let urls = wanted(&raw, &options)?;
@@ -422,6 +416,9 @@ pub async fn message_show_images(
let mut view = db.with(&account_id, |conn| {
read::message_view(conn, &account_id, &message_id, &options.own_addresses)
})?;
if !view.images_allowed {
return Ok(view);
}
view.html = rendered.html;
view.quoted_html = rendered.quoted_html;
view.trackers = rendered.trackers;
@@ -430,6 +427,29 @@ pub async fn message_show_images(
Ok(view)
}
#[tauri::command]
pub fn message_images_set(
app: tauri::AppHandle,
account_id: String,
message_id: String,
allowed: bool,
) -> Result<MessageView, String> {
let db = db_of(&app)?;
db.with(&account_id, |conn| {
let options = sync::hydrate::render_options(conn)?;
let mut view = read::message_view(conn, &account_id, &message_id, &options.own_addresses)?;
if view.images_allowed != allowed {
crate::state::write::set_pref(
conn,
&read::message_images_key(&view.message_id, &view.id),
if allowed { "true" } else { "false" },
)?;
}
view.images_allowed = allowed;
Ok(view)
})
}
/// The inline preview. Refuses anything too big to be a data URI before it fetches a byte, so the
/// answer to a hundred megabyte video is a sentence rather than a spinner.
#[tauri::command]
+192 -29
View File
@@ -20,10 +20,11 @@
// the queue is the `drafts` table, which already has the two columns it needs.
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
use std::sync::OnceLock;
use rusqlite::{Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use tauri::Manager;
use crate::decisions::db_of;
use crate::dto::{Draft, DraftAttachment, DraftSaved, Person};
@@ -341,7 +342,10 @@ pub async fn upload<S: Store, P: sync::Remote + ?Sized>(
for (id, provider_draft_id, held) in store.with(|conn| due(conn, now))? {
let subject = held.draft.subject.clone();
let from = crate::send::sender(&held.draft, from);
let raw = store.with(|conn| built(conn, &held.draft, &from, None, None, &[], &subject))?;
let raw = store.with(|conn| {
let threading = crate::send::threading(conn, &held.draft)?;
built(conn, &held.draft, &from, None, threading.in_reply_to.as_deref(), &threading.references, &subject)
})?;
let thread_hint = store.with(|conn| thread_hint(conn, held.draft.thread_key.as_deref()))?;
let put = provider
@@ -370,9 +374,9 @@ pub fn thread_hint(conn: &Connection, thread_key: Option<&str>) -> Result<Option
}
/// One account at a time, so two saves in the same second cannot both upload the same draft.
fn uploading() -> &'static Mutex<HashSet<String>> {
static UPLOADING: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
UPLOADING.get_or_init(|| Mutex::new(HashSet::new()))
fn uploading() -> &'static tokio::sync::Mutex<()> {
static UPLOADING: OnceLock<tokio::sync::Mutex<()>> = OnceLock::new();
UPLOADING.get_or_init(|| tokio::sync::Mutex::new(()))
}
/// Puts every draft of one account that is due in front of the provider.
@@ -381,18 +385,8 @@ fn uploading() -> &'static Mutex<HashSet<String>> {
/// before the app was quit is uploaded when the app comes back, rather than waiting for somebody to
/// open the composer again.
pub async fn upload_pending(app: &tauri::AppHandle, account_id: &str) -> Result<u32, String> {
if !uploading()
.lock()
.map(|mut held| held.insert(account_id.to_string()))
.unwrap_or(false)
{
return Ok(0);
}
let done = upload_now(app, account_id).await;
if let Ok(mut held) = uploading().lock() {
held.remove(account_id);
}
done
let _guard = uploading().lock().await;
upload_now(app, account_id).await
}
async fn upload_now(app: &tauri::AppHandle, account_id: &str) -> Result<u32, String> {
@@ -427,9 +421,145 @@ pub fn draft_save(app: tauri::AppHandle, draft: Draft) -> Result<DraftSaved, Str
tokio::time::sleep(std::time::Duration::from_millis(UPLOAD_EVERY_MS as u64)).await;
let _ = upload_pending(&handle, &account_id).await;
});
crate::emit_store_changed(&app, "drafts");
Ok(saved)
}
#[tauri::command(async)]
pub fn draft_list(app: tauri::AppHandle, account_id: Option<String>) -> Result<Vec<Draft>, String> {
let db = db_of(&app)?;
let mut drafts = Vec::new();
for (account_id, _) in sync::accounts(db.inner(), account_id.as_deref()) {
drafts.extend(db.with(&account_id, |conn| {
let mut stmt = conn
.prepare("SELECT payload FROM drafts ORDER BY updated_at DESC")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| e.to_string())?;
rows.map(|row| {
let payload = row.map_err(|e| e.to_string())?;
serde_json::from_str::<Stored>(&payload)
.map(|held| held.draft)
.map_err(|e| e.to_string())
})
.collect::<Result<Vec<_>, String>>()
})?);
}
Ok(drafts)
}
#[tauri::command]
pub async fn draft_import(
app: tauri::AppHandle,
account_id: String,
message_id: String,
) -> Result<Draft, String> {
let db = db_of(&app)?;
let auth = app
.try_state::<crate::google::AuthState>()
.ok_or("no Google session store")?;
let token = crate::google::auth::valid_access_token(&app, auth.inner(), &account_id).await?;
let mut page = None;
let provider_id = loop {
let found = crate::google::api::with_retry(|| {
crate::google::api::drafts_list(&token, page.as_deref())
})
.await
.map_err(|e| e.to_string())?;
if let Some(draft) = found
.drafts
.into_iter()
.find(|draft| draft.message.as_ref().is_some_and(|m| m.id == message_id))
{
break draft.id;
}
page = found.next_page_token;
if page.is_none() {
return Err(
"that Gmail draft is no longer available; sync the mailbox again".to_string(),
);
}
};
db.with(&account_id, |conn| {
let existing: Option<String> = conn
.query_row(
"SELECT id FROM drafts WHERE provider_draft_id = ?1",
[&provider_id],
|row| row.get(0),
)
.optional()
.map_err(|e| e.to_string())?;
let existing = existing.map(|id| stored(conn, &id)).transpose()?.flatten();
if let Some((held, _, _)) = &existing {
if held.version > held.uploaded_version {
return Ok(held.draft.clone());
}
}
let raw = read::raw_body(conn, &message_id)?.ok_or("the draft body is still loading")?;
let rendered = crate::mime::render(&raw, &crate::sync::hydrate::render_options(conn)?)?;
let key: String = conn
.query_row(
"SELECT thread_key FROM messages WHERE id = ?1 AND draft = 1",
[&message_id],
|row| row.get(0),
)
.map_err(|e| e.to_string())?;
let id = existing
.as_ref()
.and_then(|(held, _, _)| held.draft.id.clone())
.unwrap_or_else(|| write::fresh_id("draft"));
let files = db.account_dir(&account_id).join("draft-files").join(&id);
let mut attachments = Vec::new();
for (index, file) in read::attachments(conn, &message_id)?
.into_iter()
.enumerate()
{
let mut attachment = DraftAttachment {
path: None,
attachment_id: Some(file.id),
filename: file.filename,
mime_type: file.mime_type,
size: file.size,
};
let bytes = attachment_bytes(conn, &attachment)?;
std::fs::create_dir_all(&files).map_err(|e| e.to_string())?;
let path = files.join(index.to_string());
std::fs::write(&path, bytes).map_err(|e| e.to_string())?;
attachment.path = Some(path.to_string_lossy().into_owned());
attachment.attachment_id = None;
attachments.push(attachment);
}
let draft = Draft {
id: Some(id),
account_id: account_id.clone(),
thread_key: Some(key),
in_reply_to: rendered.in_reply_to,
from_alias: Some(rendered.from.address),
to: rendered.to,
cc: rendered.cc,
bcc: rendered.bcc,
subject: rendered.subject,
body_html: format!(
"{}{}",
rendered.html,
rendered.quoted_html.unwrap_or_default()
),
attachments,
remind_at_ms: None,
};
let saved = save(conn, &draft)?;
uploaded(
conn,
&saved.id,
&provider_id,
existing.map(|(held, _, _)| held.version).unwrap_or(0) + 1,
write::now_ms(),
)?;
get(conn, &saved.id)
})
}
#[tauri::command(async)]
pub fn draft_get(app: tauri::AppHandle, id: String) -> Result<Draft, String> {
let db = db_of(&app)?;
@@ -443,23 +573,56 @@ pub fn draft_get(app: tauri::AppHandle, id: String) -> Result<Draft, String> {
#[tauri::command]
pub async fn draft_delete(app: tauri::AppHandle, id: String) -> Result<(), String> {
let _guard = uploading().lock().await;
let db = db_of(&app)?;
for (account_id, _) in sync::accounts(db.inner(), None) {
let held = db.with(&account_id, |conn| stored(conn, &id))?;
if held.is_none() {
let Some((_, provider_id, _)) = db.with(&account_id, |conn| stored(conn, &id))? else {
continue;
}
let provider_draft_id = db.with(&account_id, |conn| delete(conn, &id))?;
if let Some(provider_draft_id) = provider_draft_id {
// The local row has gone either way. A provider that will not take the delete leaves a
// draft in the mailbox's own Drafts, which is visible and fixable, and refusing the
// command over it would leave the one on this machine that the person asked to be rid
// of.
if let Some(provider) = sync::remote_for(&account_id) {
let _ = provider.draft_delete(&provider_draft_id).await;
};
let mut message_id = None;
if let Some(provider_id) = provider_id {
if crate::accounts::list(&app)?
.iter()
.any(|a| a.id == account_id && a.kind == crate::dto::AccountKind::Google)
{
let auth = app
.try_state::<crate::google::AuthState>()
.ok_or("no Google session store")?;
let token =
crate::google::auth::valid_access_token(&app, auth.inner(), &account_id)
.await?;
let mut page = None;
loop {
let found = crate::google::api::with_retry(|| {
crate::google::api::drafts_list(&token, page.as_deref())
})
.await
.map_err(|e| e.to_string())?;
if let Some(draft) = found.drafts.into_iter().find(|d| d.id == provider_id) {
message_id = draft.message.map(|m| m.id);
break;
}
page = found.next_page_token;
if page.is_none() {
break;
}
}
}
let provider = sync::remote_for(&account_id)
.ok_or("connect this account before discarding its remote draft")?;
provider
.draft_delete(&provider_id)
.await
.map_err(|e| e.to_string())?;
}
crate::emit_store_changed(&app, "threads");
db.with(&account_id, |conn| {
delete(conn, &id)?;
if let Some(message_id) = message_id {
write::delete_message(conn, &message_id)?;
}
Ok(())
})?;
crate::emit_store_changed(&app, "drafts threads thread");
return Ok(());
}
Err("that draft is not on this device".to_string())
+2
View File
@@ -441,6 +441,8 @@ pub struct MessageView {
pub trackers: Vec<Tracker>,
/// Ordinary remote images that were blocked, which is a different count from the trackers.
pub blocked_images: u32,
#[serde(default)]
pub images_allowed: bool,
/// Whether the body currently rendered has remote images loaded.
pub images_loaded: bool,
pub seen: bool,
+27 -1
View File
@@ -126,6 +126,7 @@ pub enum Call {
BatchModify,
MessagesModify,
MessagesSend,
DraftsList,
DraftsCreate,
DraftsUpdate,
DraftsDelete,
@@ -143,7 +144,7 @@ impl Call {
Call::MessagesGet | Call::AttachmentsGet => 20,
Call::DraftsUpdate => 15,
Call::DraftsCreate | Call::DraftsDelete => 10,
Call::MessagesList | Call::MessagesModify => 5,
Call::MessagesList | Call::MessagesModify | Call::DraftsList => 5,
Call::HistoryList => 2,
Call::LabelsList | Call::SendAsList | Call::GetProfile => 1,
}
@@ -166,6 +167,7 @@ impl Call {
Call::BatchModify => "Gmail label change",
Call::MessagesModify => "Gmail label change",
Call::MessagesSend => "Gmail send",
Call::DraftsList => "Gmail draft list",
Call::DraftsCreate => "Gmail draft create",
Call::DraftsUpdate => "Gmail draft update",
Call::DraftsDelete => "Gmail draft delete",
@@ -1183,6 +1185,30 @@ pub async fn messages_send(
read_json(resp, call.name(), call.scope()).await
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DraftsPage {
#[serde(default)]
pub drafts: Vec<Draft>,
pub next_page_token: Option<String>,
}
pub async fn drafts_list(
access_token: &str,
page_token: Option<&str>,
) -> Result<DraftsPage, ApiError> {
let mut params = vec![("maxResults", MAX_LIST_RESULTS)];
if let Some(token) = page_token {
params.push(("pageToken", token));
}
let resp = HTTP
.get(url_with(&format!("{BASE}/users/me/drafts"), &params))
.bearer_auth(access_token)
.send()
.await?;
read_json(resp, Call::DraftsList.name(), Call::DraftsList.scope()).await
}
pub async fn drafts_create(
access_token: &str,
raw: &[u8],
+3
View File
@@ -403,6 +403,7 @@ pub fn run() {
imap::imap_trust_cert,
imap::imap_forget_cert,
attachments::message_show_images,
attachments::message_images_set,
attachments::attachment_data_url,
attachments::attachment_save,
attachments::attachment_open,
@@ -451,6 +452,8 @@ pub fn run() {
// Writing
drafts::draft_save,
drafts::draft_get,
drafts::draft_list,
drafts::draft_import,
drafts::draft_delete,
send::send,
send::send_now,
+1 -1
View File
@@ -20,7 +20,7 @@ use std::collections::HashMap;
use crate::dto::{Invite, Person, Surface, Tracker, Unsubscribe};
pub const RENDER_VERSION: i32 = 2;
pub const RENDER_VERSION: i32 = 5;
#[derive(Debug, Clone, Default)]
pub struct RenderedAttachment {
+9
View File
@@ -43,6 +43,15 @@ pub fn render(raw: &[u8], options: &RenderOptions) -> Result<Rendered, String> {
let inline_parts = inline_parts(&message);
let (source, is_html, text) = body(&message);
let source = if is_html {
css_inline::CSSInliner::options()
.load_remote_stylesheets(false)
.build()
.inline(&source)
.unwrap_or(source)
} else {
source
};
let (visible_source, quoted_source) = if is_html {
quoted::split_html(&source)
} else {
+19 -5
View File
@@ -153,7 +153,7 @@ fn place_clause(
Place::Sent => "t.trashed = 0 AND EXISTS (SELECT 1 FROM messages m \
WHERE m.provider_thread_id = t.provider_thread_id AND m.sent = 1)"
.to_string(),
Place::Drafts => "t.has_draft = 1 AND t.trashed = 0".to_string(),
Place::Drafts => "(t.has_draft = 1 OR EXISTS (SELECT 1 FROM drafts d WHERE d.thread_key = t.thread_key)) AND t.trashed = 0".to_string(),
Place::Starred => "t.starred = 1 AND t.trashed = 0".to_string(),
Place::Spam => "t.spam = 1".to_string(),
Place::Trash => "t.trashed = 1".to_string(),
@@ -305,7 +305,7 @@ const COLUMNS: &str = "\
t.trashed AS trashed,
t.spam AS spam,
t.has_attachment AS has_attachment,
t.has_draft AS has_draft,
(t.has_draft OR EXISTS (SELECT 1 FROM drafts d WHERE d.thread_key = t.thread_key)) AS has_draft,
pl.pile AS pile,
COALESCE(sn.return_at, rt.due_ms) AS snoozed_until,
COALESCE(tf.ignored, 0) AS ignored,
@@ -557,8 +557,8 @@ const VIEW_SQL: &str = "\
COALESCE(b.blocked_images, 0) AS blocked_images,
(SELECT json_group_array(json_object(
'id', a.id, 'messageId', a.message_id, 'filename', a.filename,
'mimeType', a.mime_type, 'size', a.size, 'inline', a.inline = 1,
'contentId', a.content_id, 'cached', a.cached_path IS NOT NULL))
'mimeType', a.mime_type, 'size', a.size, 'inline', json(CASE WHEN a.inline = 1 THEN 'true' ELSE 'false' END),
'contentId', a.content_id, 'cached', json(CASE WHEN a.cached_path IS NOT NULL THEN 'true' ELSE 'false' END)))
FROM attachments a WHERE a.message_id = m.id) AS attachments_json,
(SELECT name FROM state.renames WHERE thread_key = ?) AS rename,
(SELECT subject FROM threads WHERE thread_key = ? ORDER BY latest_ms DESC LIMIT 1)
@@ -632,6 +632,7 @@ pub fn thread_view(
trackers: serde_json::from_str(&row.get::<_, String>("trackers")?)
.unwrap_or_default(),
blocked_images: row.get::<_, i64>("blocked_images")? as u32,
images_allowed: false,
// The sanitiser fetches nothing, so a body is only ever served with its remote
// images already inlined by the caller that asked for them.
images_loaded: false,
@@ -677,7 +678,11 @@ pub fn thread_view(
let mut participants: Vec<Person> = Vec::new();
let mut labels: Vec<String> = Vec::new();
let mut messages = Vec::new();
for (message, message_labels, _) in rows {
for (mut message, message_labels, _) in rows {
message.images_allowed =
state::read::pref(conn, &message_images_key(&message.message_id, &message.id))?
.as_deref()
== Some("true");
for label in serde_json::from_str::<Vec<String>>(&message_labels).unwrap_or_default() {
if !labels.contains(&label) {
labels.push(label);
@@ -1052,6 +1057,15 @@ pub fn thread_key_of(conn: &Connection, provider_thread_id: &str) -> Result<Opti
.map_err(|e| e.to_string())
}
pub fn message_images_key(message_id: &str, provider_id: &str) -> String {
let id = if message_id.trim().is_empty() {
provider_id
} else {
message_id
};
format!("message-images:{id}")
}
/// One message's view, taken out of its thread's rather than assembled a second time. Sharing the
/// statement is what keeps a message looking the same whichever command handed it over.
pub fn message_view(
+69 -12
View File
@@ -43,6 +43,7 @@ use crate::dto::Tracker;
const STYLE_PROPERTIES: &[&str] = &[
"color",
"background-color",
"background",
"font",
"font-family",
"font-size",
@@ -126,16 +127,14 @@ const EXTRA_TAGS: &[&str] = &["tfoot", "font", "address"];
/// `style` because of `STYLE_PROPERTIES` above, `dir` because right to left mail is mail.
const EXTRA_GENERIC_ATTRIBUTES: &[&str] = &["style", "dir"];
/// The presentational attributes a table based newsletter is built out of. `background` is not
/// here: it is a URL in an attribute, which is the same fetch by another name.
const EXTRA_TAG_ATTRIBUTES: &[(&str, &[&str])] = &[
("table", &["bgcolor", "border", "cellpadding", "cellspacing", "width", "height"]),
("table", &["background", "bgcolor", "border", "cellpadding", "cellspacing", "width", "height"]),
("thead", &["bgcolor", "valign"]),
("tbody", &["bgcolor", "valign"]),
("tfoot", &["align", "bgcolor", "valign"]),
("tr", &["bgcolor", "height", "valign"]),
("td", &["bgcolor", "height", "nowrap", "valign", "width"]),
("th", &["bgcolor", "height", "nowrap", "valign", "width"]),
("td", &["background", "bgcolor", "height", "nowrap", "valign", "width"]),
("th", &["background", "bgcolor", "height", "nowrap", "valign", "width"]),
("font", &["color", "face", "size"]),
("img", &["border", "hspace", "vspace"]),
("a", &["name"]),
@@ -174,7 +173,8 @@ pub fn clean(
remote_images: &HashMap<String, Vec<u8>>,
policy: Policy,
) -> Result<Sanitized, String> {
let plans = plan_images(source, inline_parts, remote_images, policy);
let source = background_images(source);
let plans = plan_images(&source, inline_parts, remote_images, policy);
let log: Arc<Mutex<Vec<ImageEvent>>> = Arc::new(Mutex::new(Vec::new()));
let filter_log = Arc::clone(&log);
@@ -194,7 +194,7 @@ pub fn clean(
builder.add_tag_attributes(tag, attributes.iter().copied());
}
let html = builder.clean(source).to_string();
let html = builder.clean(&source).to_string();
let events = log
.lock()
@@ -234,7 +234,7 @@ fn filter<'u>(
};
match (element, attribute) {
("img", "src") => match plans.get(value.trim()) {
("img", "src") | (_, "background") => match plans.get(value.trim()) {
Some(ImagePlan::Inline {
data_url,
content_id,
@@ -282,10 +282,19 @@ fn plan_images(
) -> HashMap<String, ImagePlan> {
let mut plans = HashMap::new();
for tag in scan(source) {
if tag.closing || tag.name != "img" {
if tag.closing {
continue;
}
let Some(src) = tag.attr("src").map(str::trim).filter(|src| !src.is_empty()) else {
let attribute = match tag.name.as_str() {
"img" => "src",
"table" | "td" | "th" => "background",
_ => continue,
};
let Some(src) = tag
.attr(attribute)
.map(str::trim)
.filter(|src| !src.is_empty())
else {
continue;
};
if let Some(plan) = plan_one(src, &tag, inline_parts, remote_images, policy) {
@@ -295,6 +304,54 @@ fn plan_images(
plans
}
fn background_images(source: &str) -> Cow<'_, str> {
let mut out = String::new();
let mut cursor = 0;
for tag in scan(source) {
if tag.closing || !matches!(tag.name.as_str(), "table" | "td" | "th") {
continue;
}
if tag.attr("background").is_some() {
continue;
}
let Some(url) = tag.attr("style").and_then(|style| {
style
.split(';')
.filter_map(|declaration| {
let (name, value) = declaration.split_once(':')?;
matches!(
name.trim().to_ascii_lowercase().as_str(),
"background" | "background-image"
)
.then_some(value)
})
.find_map(|value| {
let start = value.to_ascii_lowercase().find("url(")? + 4;
let url = value[start..]
.split_once(')')?
.0
.trim()
.trim_matches(['\'', '"']);
(!url.is_empty()).then_some(url)
})
}) else {
continue;
};
let insertion = tag.start + 1 + tag.name.len();
out.push_str(&source[cursor..insertion]);
out.push_str(" background=\"");
out.push_str(&escape_html(url));
out.push('"');
cursor = insertion;
}
if cursor == 0 {
Cow::Borrowed(source)
} else {
out.push_str(&source[cursor..]);
Cow::Owned(out)
}
}
fn plan_one(
src: &str,
tag: &Tag,
@@ -304,8 +361,8 @@ fn plan_one(
) -> Option<ImagePlan> {
let lowercase = src.to_ascii_lowercase();
if let Some(reference) = lowercase.strip_prefix("cid:") {
let key = decode_entities(reference)
if lowercase.starts_with("cid:") {
let key = decode_entities(&src[4..])
.trim_matches(|character| character == '<' || character == '>')
.to_string();
let part = inline_parts.get(&key)?;
+10 -2
View File
@@ -289,7 +289,7 @@ fn rewrite(tag: &Tag, drop_background: bool, drop_foreground: bool) -> String {
"style" => {
let style = declarations(value)
.filter(|(property, _)| match *property {
"background-color" => !drop_background,
"background-color" | "background" => !drop_background,
"color" => !drop_foreground,
_ => true,
})
@@ -323,6 +323,11 @@ fn rewrite(tag: &Tag, drop_background: bool, drop_foreground: bool) -> String {
fn background_of(tag: &Tag) -> Option<Rgba> {
declaration(tag, "background-color")
.or_else(|| {
declarations(tag.attr("style")?)
.rfind(|(name, _)| *name == "background")
.and_then(|(_, value)| parse_colour(value).or_else(|| shorthand_colour(value)))
})
.or_else(|| tag.attr("bgcolor").and_then(parse_colour))
}
@@ -441,7 +446,10 @@ const NAMED: &[(&str, u32)] = &[
];
fn parse_colour(value: &str) -> Option<Rgba> {
let value = value.trim();
let value = value.rsplit_once('!')
.filter(|(_, priority)| priority.trim().eq_ignore_ascii_case("important"))
.map_or(value, |(colour, _)| colour)
.trim();
if value.is_empty() {
return None;
}
+1 -1
View File
@@ -123,7 +123,7 @@ pub fn threading(conn: &Connection, draft: &Draft) -> Result<Threading, String>
"SELECT m.id, m.message_id, m.provider_thread_id, m.subject
FROM messages m
JOIN threads t ON t.provider_thread_id = m.provider_thread_id
WHERE t.thread_key = ?1
WHERE t.thread_key = ?1 AND m.draft = 0
ORDER BY m.date_ms ASC, m.id ASC",
)
.map_err(|e| e.to_string())?;