mirror of
https://github.com/priyanshujain/margin-mail.git
synced 2026-10-04 20:17:05 +00:00
some fixes
This commit is contained in:
1 parent
660aef323b
commit
7274a54a6e
29 files changed
+860
-315
No files matched your search
@@ -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
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"), ¶ms))
|
||||
.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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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())?;
|
||||
|
||||
Reference in new issue
Block a user