v0.2.14
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 15:34:49 +01:00
1 parent 12e0c99850
commit edda5fc9e4
42 files changed
+733 -122

No files matched your search

+32 -8
View File
@@ -414,12 +414,6 @@ pub fn update_preferences(
if let Some(v) = data.row_height {
range("row_height", v, 42, 88)?;
}
if let Some(v) = data.folder_pane_width {
range("folder_pane_width", v, 180, 360)?;
}
if let Some(v) = data.message_pane_width {
range("message_pane_width", v, 300, 720)?;
}
if let Some(v) = data.border_radius {
range("border_radius", v, 0, 12)?;
}
@@ -505,8 +499,16 @@ pub fn update_preferences(
set_text!(font_family, "font_family");
set_int!(font_size, "font_size");
set_int!(row_height, "row_height");
set_int!(folder_pane_width, "folder_pane_width");
set_int!(message_pane_width, "message_pane_width");
if let Some(value) = data.folder_pane_width {
sets.push(format!("folder_pane_width = ?{idx}"));
values.push(value.clamp(180, 420).to_db_value());
idx += 1;
}
if let Some(value) = data.message_pane_width {
sets.push(format!("message_pane_width = ?{idx}"));
values.push(value.clamp(300, 760).to_db_value());
idx += 1;
}
set_int!(border_radius, "border_radius");
set_text!(contrast, "contrast");
set_bool!(show_avatars, "show_avatars");
@@ -578,6 +580,28 @@ mod tests {
assert!(prefs.preset_scale_unlocked);
}
#[test]
fn pane_widths_are_clamped_instead_of_rejected() {
let conn = open_test_db();
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({
"folder_pane_width": 9999,
"message_pane_width": 9999
}))
.unwrap();
let prefs = update_preferences(&conn, &update).unwrap();
assert_eq!(prefs.folder_pane_width, 420);
assert_eq!(prefs.message_pane_width, 760);
let update: UpdateDisplayPreferences = serde_json::from_value(serde_json::json!({
"folder_pane_width": -1,
"message_pane_width": -1
}))
.unwrap();
let prefs = update_preferences(&conn, &update).unwrap();
assert_eq!(prefs.folder_pane_width, 180);
assert_eq!(prefs.message_pane_width, 300);
}
#[test]
fn built_in_interface_skins_are_accepted() {
for skin in [
+1
View File
@@ -116,6 +116,7 @@ mod tests {
default_sort: None,
flagged_on_top: Some(true),
sync_imap_keywords: None,
compact_wide_rows: None,
},
)
.unwrap();
+37 -4
View File
@@ -17,6 +17,7 @@ pub struct Identity {
pub signature_reply: bool,
pub signature_forward: bool,
pub signature_position: String,
pub signature_insert_mode: String,
pub pgp_fingerprint: Option<String>,
pub pgp_sign_default: bool,
pub pgp_encrypt_default: bool,
@@ -50,6 +51,8 @@ pub struct CreateIdentity {
pub signature_forward: bool,
#[serde(default = "default_signature_position")]
pub signature_position: String,
#[serde(default = "default_signature_insert_mode")]
pub signature_insert_mode: String,
#[serde(default)]
pub pgp_fingerprint: Option<String>,
#[serde(default)]
@@ -74,6 +77,7 @@ pub struct UpdateIdentity {
pub signature_reply: Option<bool>,
pub signature_forward: Option<bool>,
pub signature_position: Option<String>,
pub signature_insert_mode: Option<String>,
pub pgp_fingerprint: Option<Option<String>>,
pub pgp_sign_default: Option<bool>,
pub pgp_encrypt_default: Option<bool>,
@@ -87,6 +91,18 @@ fn default_signature_position() -> String {
"above".to_string()
}
fn default_signature_insert_mode() -> String {
"silent".to_string()
}
fn validate_signature_insert_mode(value: &str) -> Result<(), String> {
if matches!(value, "silent" | "visible") {
Ok(())
} else {
Err(format!("Invalid signature_insert_mode: {value}"))
}
}
fn validate_signature_position(value: &str) -> Result<(), String> {
if matches!(value, "above" | "below") {
Ok(())
@@ -112,10 +128,11 @@ fn row_to_identity(row: &crate::db::pool::Row<'_>) -> Result<Identity, crate::db
signature_reply: row.get::<_, i32>(12)? != 0,
signature_forward: row.get::<_, i32>(13)? != 0,
signature_position: row.get(14)?,
pgp_fingerprint: row.get(15)?,
pgp_sign_default: row.get::<_, i32>(16)? != 0,
pgp_encrypt_default: row.get::<_, i32>(17)? != 0,
pgp_inline_default: row.get::<_, i32>(18)? != 0,
signature_insert_mode: row.get(15)?,
pgp_fingerprint: row.get(16)?,
pgp_sign_default: row.get::<_, i32>(17)? != 0,
pgp_encrypt_default: row.get::<_, i32>(18)? != 0,
pgp_inline_default: row.get::<_, i32>(19)? != 0,
})
}
@@ -155,6 +172,7 @@ pub fn get_default_identity(conn: &Connection) -> Result<Option<Identity>, Strin
pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result<Identity, String> {
validate_signature_position(&data.signature_position)?;
validate_signature_insert_mode(&data.signature_insert_mode)?;
if data.is_default {
clear_default(conn)?;
}
@@ -174,6 +192,7 @@ pub fn create_identity(conn: &Connection, data: &CreateIdentity) -> Result<Ident
data.signature_reply as i32,
data.signature_forward as i32,
data.signature_position,
data.signature_insert_mode,
data.pgp_fingerprint,
data.pgp_sign_default as i32,
data.pgp_encrypt_default as i32,
@@ -198,6 +217,9 @@ pub fn update_identity(
if let Some(value) = data.signature_position.as_deref() {
validate_signature_position(value)?;
}
if let Some(value) = data.signature_insert_mode.as_deref() {
validate_signature_insert_mode(value)?;
}
let mut sets = Vec::new();
let mut values: Vec<DbValue> = Vec::new();
let mut idx = 1usize;
@@ -231,6 +253,7 @@ pub fn update_identity(
boolean!(signature_reply, "signature_reply");
boolean!(signature_forward, "signature_forward");
text!(signature_position, "signature_position");
text!(signature_insert_mode, "signature_insert_mode");
if let Some(ref value) = data.pgp_fingerprint {
sets.push(format!("pgp_fingerprint = ?{idx}"));
values.push(value.to_db_value());
@@ -293,6 +316,7 @@ mod tests {
signature_reply: true,
signature_forward: true,
signature_position: "above".into(),
signature_insert_mode: "silent".into(),
pgp_fingerprint: None,
pgp_sign_default: false,
pgp_encrypt_default: false,
@@ -313,6 +337,7 @@ mod tests {
assert_eq!(identity.bcc, "archive@example.com");
assert_eq!(identity.organization, "Example");
assert_eq!(identity.signature_position, "below");
assert_eq!(identity.signature_insert_mode, "silent");
}
#[test]
@@ -324,6 +349,14 @@ mod tests {
assert!(!get_identity(&conn, first.id).unwrap().unwrap().is_default);
}
#[test]
fn rejects_invalid_signature_insert_mode() {
let conn = open_test_db();
let mut value = sample("a@example.com", true);
value.signature_insert_mode = "magic".into();
assert!(create_identity(&conn, &value).is_err());
}
#[test]
fn rejects_invalid_signature_position() {
let conn = open_test_db();
+9 -2
View File
@@ -13,6 +13,7 @@ pub struct MailPreferences {
pub default_sort: String,
pub flagged_on_top: bool,
pub sync_imap_keywords: bool,
pub compact_wide_rows: bool,
pub updated_at: String,
}
@@ -28,6 +29,7 @@ pub struct UpdateMailPreferences {
pub default_sort: Option<String>,
pub flagged_on_top: Option<bool>,
pub sync_imap_keywords: Option<bool>,
pub compact_wide_rows: Option<bool>,
}
pub fn get_preferences(conn: &Connection) -> Result<MailPreferences, String> {
@@ -43,7 +45,8 @@ pub fn get_preferences(conn: &Connection) -> Result<MailPreferences, String> {
default_sort: row.get(7)?,
flagged_on_top: row.get::<_, i32>(8)? != 0,
sync_imap_keywords: row.get::<_, i32>(9)? != 0,
updated_at: row.get(10)?,
compact_wide_rows: row.get::<_, i32>(10)? != 0,
updated_at: row.get(11)?,
})
})
.map_err(|error| format!("Failed to read mail preferences: {error}"))
@@ -121,6 +124,7 @@ pub fn update_preferences(
let sync_imap_keywords = data
.sync_imap_keywords
.unwrap_or(current.sync_imap_keywords);
let compact_wide_rows = data.compact_wide_rows.unwrap_or(current.compact_wide_rows);
conn.execute(
crate::dbq!(db_mail_preferences::Q_003),
@@ -134,7 +138,8 @@ pub fn update_preferences(
show_snippets as i32,
default_sort,
flagged_on_top as i32,
sync_imap_keywords as i32
sync_imap_keywords as i32,
compact_wide_rows as i32
],
)
.map_err(|error| format!("Failed to update mail preferences: {error}"))?;
@@ -167,6 +172,7 @@ mod tests {
default_sort: Some("date_asc".to_string()),
flagged_on_top: Some(true),
sync_imap_keywords: Some(true),
compact_wide_rows: Some(false),
},
)
.unwrap();
@@ -175,5 +181,6 @@ mod tests {
assert_eq!(updated.thread_mode, "flat");
assert!(updated.flagged_on_top);
assert!(updated.sync_imap_keywords);
assert!(!updated.compact_wide_rows);
}
}
+12
View File
@@ -34,6 +34,10 @@ const SQLITE_MIGRATIONS: &[(u32, &str)] = &[
49,
include_str!("../../migrations/sqlite/V049__blob_delete_outbox.sql"),
),
(
50,
include_str!("../../migrations/sqlite/V050__compact_rows_and_signature_mode.sql"),
),
];
const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
(
@@ -48,6 +52,10 @@ const MYSQL_MIGRATIONS: &[(u32, &str)] = &[
49,
include_str!("../../migrations/mysql/V049__blob_delete_outbox.sql"),
),
(
50,
include_str!("../../migrations/mysql/V050__compact_rows_and_signature_mode.sql"),
),
];
const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
(
@@ -62,6 +70,10 @@ const PGSQL_MIGRATIONS: &[(u32, &str)] = &[
49,
include_str!("../../migrations/pgsql/V049__blob_delete_outbox.sql"),
),
(
50,
include_str!("../../migrations/pgsql/V050__compact_rows_and_signature_mode.sql"),
),
];
const TENANT_TABLES: &[&str] = &[
+1
View File
@@ -62,6 +62,7 @@ mod tests {
default_sort: None,
flagged_on_top: None,
sync_imap_keywords: None,
compact_wide_rows: None,
},
)
.unwrap();
+4 -4
View File
@@ -257,8 +257,8 @@ pub mod db_header_sync {
}
pub mod db_identities {
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, signature_insert_mode, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,signature_insert_mode,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)";
pub const Q_002: &str = "DELETE FROM identities WHERE id = ?";
pub const Q_003: &str = "SELECT CAST(COUNT(*) AS SIGNED) FROM identities";
pub const Q_004: &str =
@@ -272,10 +272,10 @@ pub mod db_identities {
}
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str =
"INSERT IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?, mark_read_delay_ms = ?, remote_images = ?, preferred_body = ?, thread_mode = ?, collapse_quotes = ?, show_snippets = ?, default_sort = ?, flagged_on_top = ?, sync_imap_keywords = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?, mark_read_delay_ms = ?, remote_images = ?, preferred_body = ?, thread_mode = ?, collapse_quotes = ?, show_snippets = ?, default_sort = ?, flagged_on_top = ?, sync_imap_keywords = ?, compact_wide_rows = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1";
}
pub mod db_messages {
+4 -4
View File
@@ -258,8 +258,8 @@ pub mod db_header_sync {
}
pub mod db_identities {
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)";
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, signature_insert_mode, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,signature_insert_mode,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)";
pub const Q_002: &str = "DELETE FROM identities WHERE id = $1";
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
pub const Q_004: &str = "UPDATE identities SET is_default = 0, updated_at = CURRENT_TIMESTAMP::text WHERE is_default = 1";
@@ -272,9 +272,9 @@ pub mod db_identities {
}
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str = "INSERT INTO mail_preferences (id, messages_per_page) VALUES (1, 300) ON CONFLICT (id) DO NOTHING";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, updated_at = CURRENT_TIMESTAMP::text WHERE id = 1";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, compact_wide_rows = $11, updated_at = CURRENT_TIMESTAMP::text WHERE id = 1";
}
pub mod db_messages {
+4 -4
View File
@@ -256,8 +256,8 @@ pub mod db_header_sync {
}
pub mod db_identities {
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16)";
pub const IDENTITY_SELECT_COLS: &str = "id, display_name, email, signature_html, is_default, created_at, updated_at, reply_to, bcc, organization, signature_text, signature_new, signature_reply, signature_forward, signature_position, signature_insert_mode, pgp_fingerprint, pgp_sign_default, pgp_encrypt_default, pgp_inline_default";
pub const Q_001: &str = "INSERT INTO identities (display_name,email,signature_html,is_default,reply_to,bcc,organization,signature_text,signature_new,signature_reply,signature_forward,signature_position,signature_insert_mode,pgp_fingerprint,pgp_sign_default,pgp_encrypt_default,pgp_inline_default) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17)";
pub const Q_002: &str = "DELETE FROM identities WHERE id = ?1";
pub const Q_003: &str = "SELECT COUNT(*) FROM identities";
pub const Q_004: &str =
@@ -271,10 +271,10 @@ pub mod db_identities {
}
pub mod db_mail_preferences {
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, compact_wide_rows, updated_at FROM mail_preferences WHERE id = 1";
pub const Q_002: &str =
"INSERT OR IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, updated_at = datetime('now') WHERE id = 1";
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, compact_wide_rows = ?11, updated_at = datetime('now') WHERE id = 1";
}
pub mod db_messages {
+1
View File
@@ -283,6 +283,7 @@ pub async fn login(
signature_reply: true,
signature_forward: true,
signature_position: "above".to_string(),
signature_insert_mode: "silent".to_string(),
pgp_fingerprint: None,
pgp_sign_default: false,
pgp_encrypt_default: false,
+212 -2
View File
@@ -2,6 +2,8 @@ use std::sync::Arc;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use base64::Engine as _;
use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::auth::session::SessionState;
@@ -28,6 +30,8 @@ pub struct SendRequest {
pub in_reply_to: Option<String>,
pub references: Option<String>,
pub compose_action: Option<String>,
pub draft_id: Option<String>,
pub from_identity_id: Option<i64>,
@@ -60,6 +64,181 @@ fn default_priority() -> String {
"normal".to_string()
}
fn signature_enabled(
identity: &db::identities::Identity,
compose_action: Option<&str>,
in_reply_to: Option<&str>,
) -> bool {
let action = compose_action
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| {
if in_reply_to.is_some() {
"reply"
} else {
"new"
}
});
match action {
"reply" | "reply-all" => identity.signature_reply,
"forward" => identity.signature_forward,
_ => identity.signature_new,
}
}
fn body_already_has_signature(
identity: &db::identities::Identity,
text_body: Option<&str>,
html_body: Option<&str>,
) -> bool {
if html_body.is_some_and(|html| {
html.contains("compose-signature") || html.contains("data-rustbox-signature")
}) {
return true;
}
let signature = identity.signature_text.trim_matches(|ch| matches!(ch, '\r' | '\n'));
if signature.trim().is_empty() {
return false;
}
let suffix = format!("-- \n{signature}");
text_body.is_some_and(|body| body.trim_end().ends_with(&suffix))
}
fn append_plain_signature(body: &mut String, signature: &str) {
let signature = signature.trim_matches(|ch| matches!(ch, '\r' | '\n'));
if signature.trim().is_empty() {
return;
}
while body.ends_with('\r') || body.ends_with('\n') {
body.pop();
}
if !body.is_empty() {
body.push_str("\n\n");
}
body.push_str("-- \n");
body.push_str(signature);
body.push('\n');
}
fn signature_html_to_text(html: &str) -> String {
let break_tags = Regex::new(r"(?is)<\s*(?:br\s*/?|/p|/div|/li|/tr|/h[1-6])\s*[^>]*>")
.expect("valid signature break-tag regex");
let tags = Regex::new(r"(?is)<[^>]+>").expect("valid signature tag regex");
let text = break_tags.replace_all(html, "\n");
let text = tags.replace_all(&text, "");
text.replace("&nbsp;", " ")
.replace("&#160;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.trim()
.to_string()
}
fn materialize_signature_images(html: &str, attachments: &mut Vec<AttachmentData>) -> String {
let data_image = Regex::new(
r"(?is)data:(image/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)",
)
.expect("valid signature data-image regex");
let mut image_index = 0usize;
data_image
.replace_all(html, |captures: &regex::Captures<'_>| {
let mime = captures.get(1).map(|m| m.as_str()).unwrap_or("image/png");
let encoded = captures
.get(2)
.map(|m| {
m.as_str()
.chars()
.filter(|ch| !matches!(ch, '\r' | '\n'))
.collect::<String>()
})
.unwrap_or_default();
let Ok(data) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
return captures
.get(0)
.map(|m| m.as_str().to_string())
.unwrap_or_default();
};
image_index += 1;
let extension = match mime.to_ascii_lowercase().as_str() {
"image/jpeg" => "jpg",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "png",
};
let content_id = format!("rustbox-signature-{}", uuid::Uuid::new_v4());
attachments.push(AttachmentData {
filename: format!("signature-{image_index}.{extension}"),
content_type: mime.to_string(),
data,
content_id: Some(content_id.clone()),
});
format!("cid:{content_id}")
})
.into_owned()
}
fn append_html_signature(body: &mut String, signature: &str, position: &str) {
let wrapper = format!(
"<div class=\"rustbox-signature\" data-rustbox-signature=\"true\">{signature}</div>"
);
if position != "below" {
let quote = Regex::new(
r#"(?is)<[a-z0-9]+\b[^>]*\bclass\s*=\s*["'][^"']*\bcompose-quote\b[^"']*["'][^>]*>"#,
)
.expect("valid compose quote regex");
if let Some(found) = quote.find(body) {
body.insert_str(found.start(), &wrapper);
return;
}
}
if !body.trim().is_empty() {
body.push('\n');
}
body.push_str(&wrapper);
}
fn append_identity_signature(
identity: &db::identities::Identity,
text_body: &mut String,
html_body: &mut Option<String>,
attachments: &mut Vec<AttachmentData>,
) {
let html_signature = identity.signature_html.trim();
let text_signature = identity.signature_text.trim();
if let Some(html) = html_body.as_mut()
&& !html_signature.is_empty()
{
let signature = materialize_signature_images(html_signature, attachments);
append_html_signature(html, &signature, &identity.signature_position);
let plain = if text_signature.is_empty() {
signature_html_to_text(html_signature)
} else {
text_signature.to_string()
};
append_plain_signature(text_body, &plain);
return;
}
if !text_signature.is_empty() {
append_plain_signature(text_body, text_signature);
if let Some(html) = html_body.as_mut() {
let escaped = text_signature
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('`', "&#96;")
.replace('\n', "<br>");
append_html_signature(html, &escaped, &identity.signature_position);
}
}
}
fn validate_mailbox(label: &str, value: &str) -> Result<(), AppError> {
value
.parse::<lettre::message::Mailbox>()
@@ -231,6 +410,18 @@ pub async fn send_message_handler(
&req.cc,
&bcc,
)?;
let append_signature = selected_identity.as_ref().is_some_and(|identity| {
identity.signature_insert_mode != "visible"
&& signature_enabled(
identity,
req.compose_action.as_deref(),
req.in_reply_to.as_deref(),
) && !body_already_has_signature(
identity,
req.text_body.as_deref(),
req.html_body.as_deref(),
)
});
drop(conn);
validate_mailbox("From", &from_address)?;
@@ -305,6 +496,25 @@ pub async fn send_message_handler(
});
}
let mut text_body = req.text_body.unwrap_or_default();
let mut html_body = req.html_body;
if append_signature
&& let Some(identity) = selected_identity.as_ref()
{
append_identity_signature(
identity,
&mut text_body,
&mut html_body,
&mut attachments,
);
}
if pgp_inline && pgp_mode != PgpMode::None && !attachments.is_empty() {
return Err(AppError::BadRequest(
"Inline OpenPGP cannot carry attachments or inline signature images; use PGP/MIME or remove images from the signature".to_string(),
));
}
let message = SendableMessage {
from: from_address,
to: req.to,
@@ -313,8 +523,8 @@ pub async fn send_message_handler(
reply_to,
organization,
subject: req.subject,
text_body: req.text_body.unwrap_or_default(),
html_body: req.html_body,
text_body,
html_body,
in_reply_to: req.in_reply_to,
references: req.references,
read_receipt_to: req.request_mdn.then_some(receipt_address),
+6
View File
@@ -214,6 +214,8 @@
}
.message-pane {
container-name: message-pane;
container-type: inline-size;
overflow: hidden;
}
@@ -309,6 +311,10 @@
background: transparent;
}
.pane-resizer[data-temporarily-disabled="true"] {
display: none;
}
.pane-resizer::after {
content: "";
position: absolute;
+91 -1
View File
@@ -1354,4 +1354,94 @@ body.reader-fullscreen-open {
.reader-toolbar .toolbar-menu>summary {
min-width: max-content;
}
}
}
/* Optional wide-pane single-line message rows. The preference is on by default,
but the layout only activates once the message pane has enough horizontal room. */
@container message-pane (min-width: 520px) {
html[data-compact-wide-rows="true"] .message-row {
min-height: min(var(--row-height), 36px);
grid-template-columns: 19px minmax(110px, .34fr) minmax(0, 1fr) auto auto;
grid-template-rows: minmax(24px, auto);
column-gap: 8px;
row-gap: 0;
align-items: center;
padding-top: 4px;
padding-bottom: 4px;
}
html[data-compact-wide-rows="true"] .message-row .message-state {
grid-column: 1;
grid-row: 1;
}
html[data-compact-wide-rows="true"] .message-row .message-main {
display: contents;
}
html[data-compact-wide-rows="true"] .message-row .message-from {
grid-column: 2;
grid-row: 1;
min-width: 0;
}
html[data-compact-wide-rows="true"] .message-row .message-subject-line {
grid-column: 3;
grid-row: 1;
min-width: 0;
}
html[data-compact-wide-rows="true"] .message-row .message-subject {
flex: 0 1 auto;
min-width: 0;
}
html[data-compact-wide-rows="true"] .message-row .message-snippet {
flex: 1 1 120px;
min-width: 0;
}
html[data-compact-wide-rows="true"] .message-row .message-snippet:not(:empty)::before {
content: "· ";
color: var(--subtle);
}
html[data-compact-wide-rows="true"] .message-row .message-date {
grid-column: 4;
grid-row: 1;
}
html[data-compact-wide-rows="true"] .message-row .message-icons {
grid-column: 5;
grid-row: 1;
}
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row {
grid-template-columns: 18px 19px minmax(110px, .34fr) minmax(0, 1fr) auto auto;
}
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-select {
grid-column: 1;
grid-row: 1;
}
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-state {
grid-column: 2;
}
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-from {
grid-column: 3;
}
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-subject-line {
grid-column: 4;
}
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-date {
grid-column: 5;
}
html[data-compact-wide-rows="true"] .message-list.selection-mode .message-row .message-icons {
grid-column: 6;
}
}
+6
View File
@@ -906,6 +906,12 @@
line-height: 1.45;
}
.compose-signature {
margin-top: 10px;
padding-top: 8px;
border-top: 1px dashed var(--line-strong);
}
.compose-signature img {
max-width: 100%;
height: auto;
+5 -1
View File
@@ -639,7 +639,11 @@
"Hide fields": "Ukryj pola",
"Show fields": "Pokaż pola",
"Hide addressing fields": "Ukryj pola adresowe",
"Show addressing fields": "Pokaż pola adresowe"
"Show addressing fields": "Pokaż pola adresowe",
"Use single-line rows when the message list is wide": "Używaj jednowierszowej listy, gdy panel wiadomości jest szeroki",
"Signature insertion": "Wstawianie podpisu",
"Quietly when sending": "Cicho przy wysyłce",
"Show in composer": "Pokaż w edytorze"
},
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję presetu poniżej, aby przy pierwszej zmianie utworzyć jego edytowalną kopię.",
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
+8 -2
View File
@@ -708,10 +708,10 @@
value="58"></label>
<label><span><span data-i18n="folder_width">Folder pane width</span> <output
id="pref-folder-width-value">228 px</output></span><input id="pref-folder-width" type="range"
min="180" max="360" step="4" value="228"></label>
min="180" max="420" step="4" value="228"></label>
<label><span><span data-i18n="message_width">Message pane width</span> <output
id="pref-message-width-value">420 px</output></span><input id="pref-message-width" type="range"
min="300" max="720" step="10" value="420"></label>
min="300" max="760" step="10" value="420"></label>
<label><span><span data-i18n="corner_radius">Corner radius</span> <output id="pref-radius-value">4
px</output></span><input id="pref-radius" type="range" min="0" max="12" step="1" value="4"></label>
<label><span data-i18n="contrast">Contrast</span><select id="pref-contrast">
@@ -782,6 +782,8 @@
<div class="appearance-toggles">
<label class="checkbox-row"><input id="pref-compact-headers" type="checkbox"><span>Compact message
headers</span></label>
<label class="checkbox-row"><input id="pref-compact-wide-rows" type="checkbox"><span>Use single-line
rows when the message list is wide</span></label>
<label class="checkbox-row"><input id="pref-show-borders" type="checkbox"><span>Show panel
borders</span></label>
<label class="checkbox-row"><input id="pref-monochrome" type="checkbox"><span>Monochrome
@@ -1273,6 +1275,10 @@
<option value="above">Above quoted text</option>
<option value="below">Below quoted text</option>
</select></label>
<label><span>Signature insertion</span><select id="identity-signature-mode">
<option value="silent">Quietly when sending</option>
<option value="visible">Show in composer</option>
</select></label>
<label id="identity-pgp-key-row"><span>OpenPGP key</span><select id="identity-pgp-key">
<option value="">Automatic / none</option>
</select></label>
+3 -2
View File
@@ -45,7 +45,7 @@
savedSearches: [],
responseTemplates: [],
notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" },
mailPrefs: { messages_per_page: 300, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false },
mailPrefs: { messages_per_page: 300, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false, compact_wide_rows: true },
currentFolderFlaggedOnTop: false,
markReadTimer: null,
ws: null,
@@ -82,9 +82,10 @@
composeUploading: false,
composeUploadController: null,
composeReplyMeta: null,
composeAction: "new",
composeSignatureGeneration: 0,
composeInsertRange: null,
composeSelectedImage: null,
composeSignatureGeneration: 0,
identitySignatureRange: null,
inlineAttachmentIds: new Set(),
inlineImageCleanupTimer: null,
+71 -5
View File
@@ -20,6 +20,7 @@ let presetPromotionTimer = null;
let presetPromotionInFlight = false;
let presetPromotionNeedsAnotherSave = false;
let presetPromotionActive = false;
const temporarilyDisabledPanePreferences = new Set();
const interfaceStyleProfiles = {
"roundcube-classic": {
@@ -348,9 +349,29 @@ async function loadMailPreferences() {
if (flaggedTop) flaggedTop.checked = Boolean(state.mailPrefs.flagged_on_top);
const syncKeywords = $("#mail-pref-sync-keywords");
if (syncKeywords) syncKeywords.checked = Boolean(state.mailPrefs.sync_imap_keywords);
const compactWideRows = $("#pref-compact-wide-rows");
if (compactWideRows) compactWideRows.checked = state.mailPrefs.compact_wide_rows !== false;
root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true";
} catch (error) { console.warn("Could not load mailbox preferences", error); }
}
async function saveCompactWideRowsAppearance(enabled) {
const previous = state.mailPrefs.compact_wide_rows !== false;
root.dataset.compactWideRows = enabled ? "true" : "false";
try {
state.mailPrefs = await request("/settings/mailbox", {
method: "PUT",
body: JSON.stringify({ compact_wide_rows: Boolean(enabled) }),
});
root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true";
} catch (error) {
const input = $("#pref-compact-wide-rows");
if (input) input.checked = previous;
root.dataset.compactWideRows = previous ? "true" : "false";
toast(error.message, "error");
}
}
async function saveMailPreferences() {
const payload = {
messages_per_page: Number($("#mail-pref-per-page").value),
@@ -367,6 +388,7 @@ async function saveMailPreferences() {
try {
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify(payload) });
state.perPage = Number(state.mailPrefs.messages_per_page || 300);
root.dataset.compactWideRows = state.mailPrefs.compact_wide_rows === false ? "false" : "true";
toast("Mailbox settings saved");
if (state.currentDetail) renderMessageDetail(state.currentDetail);
await loadMessages(true);
@@ -410,6 +432,39 @@ const presetEditablePreferenceKeys = new Set([
"folderBackground", "messageBackground", "readerBackground",
]);
function panePreferenceKeyFromValidationError(error) {
const message = String(error?.message || "");
if (/Invalid\s+message_pane_width\s*:/i.test(message)) return "messagePaneWidth";
if (/Invalid\s+folder_pane_width\s*:/i.test(message)) return "folderPaneWidth";
return null;
}
function temporarilyDisablePanePreference(key, error = null) {
if (!["folderPaneWidth", "messagePaneWidth"].includes(key)) return false;
temporarilyDisabledPanePreferences.add(key);
const handle = $(key === "folderPaneWidth" ? "#folder-resizer" : "#message-resizer");
if (handle) {
handle.dataset.temporarilyDisabled = "true";
handle.setAttribute("aria-disabled", "true");
}
if (error) console.warn(`Pane resize temporarily disabled for ${key}`, error);
return true;
}
function restorePanePreference(key) {
if (!temporarilyDisabledPanePreferences.delete(key)) return;
const handle = $(key === "folderPaneWidth" ? "#folder-resizer" : key === "messagePaneWidth" ? "#message-resizer" : null);
if (!handle) return;
delete handle.dataset.temporarilyDisabled;
handle.removeAttribute("aria-disabled");
}
function suppressPaneWidthValidationError(error) {
const key = panePreferenceKeyFromValidationError(error);
return key ? temporarilyDisablePanePreference(key, error) : false;
}
function presetCssHex(styles, name, fallback) {
const value = styles.getPropertyValue(name).trim();
if (/^#[0-9a-f]{6}$/i.test(value)) return value;
@@ -441,8 +496,10 @@ function schedulePromotedAppearanceSave() {
presetPromotionNeedsAnotherSave = false;
try {
await request("/settings/display", { method: "PUT", body: JSON.stringify(customAppearancePayload(state.prefs)) });
restorePanePreference("folderPaneWidth");
restorePanePreference("messagePaneWidth");
} catch (error) {
toast(error.message, "error");
if (!suppressPaneWidthValidationError(error)) toast(error.message, "error");
} finally {
presetPromotionInFlight = false;
if (presetPromotionNeedsAnotherSave) schedulePromotedAppearanceSave();
@@ -498,16 +555,25 @@ function savePreference(key, value) {
if (!field) return;
const payload = { [field]: state.prefs[key] };
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) payload.color_preset = "custom";
try { await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); }
catch (error) { toast(error.message, "error"); }
try {
await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) });
restorePanePreference(key);
} catch (error) { if (!suppressPaneWidthValidationError(error)) toast(error.message, "error"); }
}, 180);
preferenceTimers.set(key, timer);
}
async function resetAppearance() {
const payload = { theme: root.dataset.defaultTheme || "system", interface_skin: "default", preset_scale_unlocked: false, density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "" };
try { normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) })); applyPreferences(); toast("Appearance reset"); }
catch (error) { toast(error.message, "error"); }
try {
normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }));
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ compact_wide_rows: true }) });
const compactWideRows = $("#pref-compact-wide-rows");
if (compactWideRows) compactWideRows.checked = true;
root.dataset.compactWideRows = "true";
applyPreferences();
toast("Appearance reset");
} catch (error) { toast(error.message, "error"); }
}
+23 -7
View File
@@ -125,6 +125,19 @@ function estimateComposeMessageBytes(extraFiles = []) {
const mode = $("#compose-mode")?.value || "html";
if (mode === "html") bytes += utf8Bytes(editor.innerHTML);
else if (mode === "markdown") bytes += Math.ceil(plainBytes * 1.25);
const identity = typeof selectedIdentity === "function" ? selectedIdentity() : null;
const action = state.composeAction || "new";
const signatureEnabled = identity && (action === "reply"
? identity.signature_reply !== false
: action === "forward"
? identity.signature_forward !== false
: identity.signature_new !== false);
if (signatureEnabled && identity.signature_insert_mode !== "visible") {
const signatureText = identity.signature_text || "";
bytes += utf8Bytes(signatureText) + 8;
if (mode === "html") bytes += utf8Bytes(identity.signature_html || "") + 96;
}
}
for (const attachment of state.composeAttachments) bytes += estimatedMimeAttachmentBytes(attachment.size);
for (const file of extraFiles || []) bytes += estimatedMimeAttachmentBytes(file.size);
@@ -551,6 +564,7 @@ function composeDraftPayload() {
references: state.composeReplyMeta?.references || null,
request_mdn: Boolean($("#compose-mdn")?.checked),
priority: $("#compose-priority")?.value || "normal",
compose_action: state.composeAction || "new",
compose_format: $("#compose-mode")?.value || "html",
pgp_mode: $("#compose-pgp-mode")?.value || "none",
pgp_inline: Boolean($("#compose-pgp-inline")?.checked),
@@ -1414,8 +1428,7 @@ async function uploadInlineImage(files, insertionRange = null) {
async function openImapDraftComposer(folder, uid) {
const draft = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
openCompose("new");
state.composeSignatureGeneration += 1;
openCompose("new", { skipSignature: true });
state.composeDraftId = ensureDraftId();
state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 };
state.composeAttachments = mergeComposeAttachments(draft.attachments);
@@ -1425,6 +1438,7 @@ async function openImapDraftComposer(folder, uid) {
inReplyTo: draft.in_reply_to || null,
references: draft.references || null,
};
state.composeAction = draft.in_reply_to ? "reply" : (/^fwd:/i.test(draft.subject || "") ? "forward" : "new");
$("#compose-to").value = draft.to || "";
$("#compose-cc").value = draft.cc || "";
$("#compose-bcc").value = draft.bcc || "";
@@ -1446,6 +1460,7 @@ async function openImapDraftComposer(folder, uid) {
const editor = $("#compose-editor");
if (mode === "html") editor.innerHTML = hydrateComposeHtml(draft.html_body || "", state.composeAttachments, state.composeDraftId);
else editor.textContent = draft.text_body || "";
insertIdentitySignature(editor, state.composeAction, { preserveExisting: true });
renderComposeAttachments();
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
@@ -1458,7 +1473,6 @@ async function openImapDraftComposer(folder, uid) {
}
function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
state.composeSignatureGeneration += 1;
state.composeDraftId = draft.id || null;
state.composeRemoteDraft = draft.imap_source || null;
state.composeAttachments = mergeComposeAttachments(draft.attachments);
@@ -1468,6 +1482,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
inReplyTo: draft.in_reply_to || null,
references: draft.references || null,
};
state.composeAction = draft.compose_action || (draft.in_reply_to ? "reply" : (/^fwd:/i.test(draft.subject || "") ? "forward" : "new"));
$("#compose-to").value = draft.to || "";
$("#compose-cc").value = draft.cc || "";
$("#compose-bcc").value = draft.bcc || "";
@@ -1485,6 +1500,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
const editor = $("#compose-editor");
if (mode === "html" && draft.html_body) editor.innerHTML = hydrateComposeHtml(draft.html_body, state.composeAttachments, state.composeDraftId);
else editor.textContent = draft.text_body || "";
insertIdentitySignature(editor, state.composeAction, { preserveExisting: true });
renderComposeAttachments();
state.composeDirty = sourceLabel === "Browser recovery";
state.composeNeedsRemoteSync = sourceLabel === "Browser recovery";
@@ -1560,7 +1576,6 @@ async function closeComposer() {
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
}
if ($("#compose-dialog").open) $("#compose-dialog").close();
state.composeSignatureGeneration += 1;
}
function attachmentReminderNeeded() {
@@ -1649,7 +1664,7 @@ function toggleComposeFields() {
if (collapse) $("#compose-editor")?.focus();
}
function openCompose(mode = "new") {
function openCompose(mode = "new", options = {}) {
if (mode === "forward" && state.prefs.forwardMode === "attachment") {
void forwardAsAttachment();
return;
@@ -1662,13 +1677,13 @@ function openCompose(mode = "new") {
state.composeDraftId = null;
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.composeSignatureGeneration += 1;
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
setComposeFieldsCollapsed(false);
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
state.composeReplyMeta = null;
state.composeAction = mode === "reply" || mode === "reply-all" ? "reply" : mode === "forward" ? "forward" : "new";
setComposeUploadProgress(false);
renderComposeAttachments();
updateComposeDraftDeleteAction();
@@ -1728,7 +1743,7 @@ function openCompose(mode = "new") {
}
editor.append(quote);
}
insertIdentitySignature(editor, mode);
if (!options.skipSignature) insertIdentitySignature(editor, state.composeAction);
let fullScreen = $("#compose-dialog").classList.contains("compose-fullscreen");
try { fullScreen = localStorage.getItem("rustbox.composeFullscreen") === "1"; } catch (_) { }
setComposeFullscreen(fullScreen, false);
@@ -1791,6 +1806,7 @@ async function sendMessage(event) {
from_identity_id: $("#compose-from").value ? Number($("#compose-from").value) : null,
request_mdn: Boolean($("#compose-mdn").checked),
priority: $("#compose-priority").value || "normal",
compose_action: state.composeAction || "new",
pgp_mode: $("#compose-pgp-mode").value || "none",
pgp_inline: Boolean($("#compose-pgp-inline").checked),
pgp_passphrase: $("#compose-pgp-passphrase").value || "",
+1
View File
@@ -904,6 +904,7 @@ async function forwardAsAttachment() {
try {
const blob = await fetchEmlBlob(detail);
openCompose("new");
state.composeAction = "forward";
$("#compose-title").textContent = trText("Forward as attachment");
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`;
+75 -20
View File
@@ -371,6 +371,7 @@ function editIdentity(identity = null) {
$("#identity-signature-text").value = identity?.signature_text || "";
$("#identity-signature").innerHTML = sanitizeMessageHtml(identity?.signature_html || "");
$("#identity-signature-position").value = identity?.signature_position || "above";
$("#identity-signature-mode").value = identity?.signature_insert_mode || "silent";
renderIdentityPgpOptions(identity?.pgp_fingerprint || "");
$("#identity-pgp-sign").checked = Boolean(identity?.pgp_sign_default);
$("#identity-pgp-encrypt").checked = Boolean(identity?.pgp_encrypt_default);
@@ -403,6 +404,7 @@ async function saveIdentity(event) {
signature_text: $("#identity-signature-text").value,
signature_html: signatureHtml,
signature_position: $("#identity-signature-position").value,
signature_insert_mode: $("#identity-signature-mode").value,
pgp_fingerprint: $("#identity-pgp-key").value,
pgp_sign_default: $("#identity-pgp-sign").checked,
pgp_encrypt_default: $("#identity-pgp-encrypt").checked,
@@ -440,11 +442,15 @@ function selectedIdentity() {
return state.identities.find((identity) => identity.id === id) || state.identities.find((identity) => identity.is_default) || null;
}
function composeActionFromTitle() {
const title = $("#compose-title").textContent;
if (title === "Reply" || title === "Reply all") return "reply";
if (title === "Forward") return "forward";
return "new";
function signatureEnabledForCompose(identity, action = state.composeAction || "new") {
if (!identity) return false;
if (action === "reply" || action === "reply-all") return identity.signature_reply !== false;
if (action === "forward") return identity.signature_forward !== false;
return identity.signature_new !== false;
}
function identityUsesVisibleSignature(identity = selectedIdentity()) {
return Boolean(identity && identity.signature_insert_mode === "visible");
}
function dataUrlToSignatureFile(src, index = 0) {
@@ -462,10 +468,12 @@ function dataUrlToSignatureFile(src, index = 0) {
}
}
async function materializeSignatureImages(signature, generation) {
async function materializeVisibleSignatureImages(signature, generation) {
const images = [...signature.querySelectorAll('img[src^="data:image/"]')];
if (!images.length) return;
const pairs = images.map((image, index) => ({ image, file: dataUrlToSignatureFile(image.getAttribute("src"), index) })).filter((item) => item.file);
const pairs = images
.map((image, index) => ({ image, file: dataUrlToSignatureFile(image.getAttribute("src"), index) }))
.filter((item) => item.file);
if (!pairs.length) return;
const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce: false });
if (!uploaded.length) return;
@@ -488,29 +496,76 @@ async function materializeSignatureImages(signature, generation) {
scheduleDraftSave();
}
function insertIdentitySignature(editor, action = composeActionFromTitle()) {
function plainIdentitySignatureSuffix(identity) {
const text = String(identity?.signature_text || "").replace(/[\r\n]+$/g, "");
return text.trim() ? `-- \n${text}` : "";
}
function stripLegacyPlainSignature(editor) {
if (!editor || $("#compose-mode")?.value === "html") return false;
let text = editor.innerText || "";
const original = text;
for (const identity of state.identities) {
const suffix = plainIdentitySignatureSuffix(identity);
if (!suffix) continue;
const trimmed = text.replace(/[\r\n]+$/g, "");
if (!trimmed.endsWith(suffix)) continue;
text = trimmed.slice(0, -suffix.length).replace(/[\r\n]+$/g, "");
break;
}
if (text === original) return false;
editor.textContent = text;
return true;
}
function removeVisibleComposeSignature(editor = $("#compose-editor"), options = {}) {
if (!editor) return;
state.composeSignatureGeneration += 1;
const generation = state.composeSignatureGeneration;
editor.querySelectorAll(".compose-signature").forEach((node) => node.remove());
editor.querySelectorAll('.compose-signature, [data-rustbox-signature="true"]').forEach((node) => node.remove());
if (options.stripPlainLegacy !== false) stripLegacyPlainSignature(editor);
scheduleUnusedInlineAttachmentCleanup();
}
function composeHasVisibleSignature(editor, identity) {
if (!editor || !identity) return false;
if (editor.querySelector('.compose-signature, [data-rustbox-signature="true"]')) return true;
if ($("#compose-mode")?.value === "html") return false;
const suffix = plainIdentitySignatureSuffix(identity);
return Boolean(suffix && (editor.innerText || "").replace(/[\r\n]+$/g, "").endsWith(suffix));
}
function insertIdentitySignature(editor = $("#compose-editor"), action = state.composeAction || "new", options = {}) {
if (!editor) return;
const identity = selectedIdentity();
if (!identity) return;
const enabled = action === "reply" ? identity.signature_reply !== false : action === "forward" ? identity.signature_forward !== false : identity.signature_new !== false;
if (!enabled) return;
const format = $("#compose-mode").value;
const signature = el("div", "compose-signature");
const visible = identityUsesVisibleSignature(identity) && signatureEnabledForCompose(identity, action);
if (options.preserveExisting && visible && composeHasVisibleSignature(editor, identity)) return;
removeVisibleComposeSignature(editor);
if (!visible) return;
const format = $("#compose-mode")?.value || "html";
const signature = el("div", "compose-signature rustbox-signature");
signature.dataset.rustboxSignature = "true";
if (format === "html") {
if (!identity.signature_html) return;
signature.innerHTML = sanitizeMessageHtml(identity.signature_html);
const html = String(identity.signature_html || "").trim();
const text = String(identity.signature_text || "").trim();
if (html) signature.innerHTML = sanitizeMessageHtml(html);
else if (text) {
signature.textContent = text;
signature.style.whiteSpace = "pre-wrap";
} else return;
} else {
const text = identity.signature_text || "";
if (!text) return;
const text = String(identity.signature_text || "").replace(/[\r\n]+$/g, "");
if (!text.trim()) return;
signature.textContent = `\n-- \n${text}\n`;
}
const quote = editor.querySelector(".compose-quote");
if (quote && identity.signature_position !== "below") editor.insertBefore(signature, quote);
else editor.append(signature);
if (format === "html") void materializeSignatureImages(signature, generation);
const generation = state.composeSignatureGeneration;
if (format === "html") void materializeVisibleSignatureImages(signature, generation);
}
function readFileAsDataUrl(file) {
+5 -3
View File
@@ -33,6 +33,7 @@ function bindPaneResizer(selector, preferenceKey, minPx, maxPx) {
const handle = $(selector);
if (!handle) return;
handle.addEventListener("pointerdown", (event) => {
if (handle.dataset.temporarilyDisabled === "true" || temporarilyDisabledPanePreferences.has(preferenceKey)) return;
if (innerWidth <= 820 || event.button !== 0) return;
event.preventDefault();
const scaleFactor = Number(getComputedStyle(root).getPropertyValue("--ui-scale-factor")) || 1;
@@ -123,7 +124,7 @@ function bindToolbarMenus() {
function bindEvents() {
bindToolbarMenus();
bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420);
bindPaneResizer("#folder-resizer", "folderPaneWidth", 180, 420);
bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760);
bindRecipientAutocomplete("#compose-to");
bindRecipientAutocomplete("#compose-cc");
@@ -192,11 +193,11 @@ function bindEvents() {
return;
}
setComposeMode(next, true);
insertIdentitySignature(editor);
insertIdentitySignature(editor, state.composeAction);
scheduleDraftSave();
updateComposeMessageSize();
});
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); updateComposeMessageSize(); });
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor"), state.composeAction); updateComposePgpUi(true); updateComposeMessageSize(); scheduleDraftSave(); });
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => {
state.pgpRecipientStatus = [];
updateComposePgpUi();
@@ -429,6 +430,7 @@ function bindEvents() {
$("#pref-unread-badge").addEventListener("change", (event) => savePreference("unreadBadgeStyle", event.target.value));
$("#pref-scrollbar-density").addEventListener("change", (event) => savePreference("scrollbarDensity", event.target.value));
$("#pref-compact-headers").addEventListener("change", (event) => savePreference("compactHeaders", event.target.checked));
$("#pref-compact-wide-rows").addEventListener("change", (event) => saveCompactWideRowsAppearance(event.target.checked));
$("#pref-show-borders").addEventListener("change", (event) => savePreference("showBorders", event.target.checked));
$("#pref-monochrome").addEventListener("change", (event) => savePreference("monochrome", event.target.checked));
[["folder", "folderBackground", "#101310"], ["message", "messageBackground", "#181c19"], ["reader", "readerBackground", "#181c19"]].forEach(([name, key, fallback]) => {