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

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 13:54:18 +01:00
1 parent 5dc9db0b1c
commit 83440f20e2
28 files changed
+1925 -213

No files matched your search

+703 -70
View File
@@ -1,9 +1,10 @@
use std::sync::Arc;
use axum::extract::Path as AxumPath;
use axum::extract::{Path as AxumPath, Query};
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::auth::session::SessionState;
use crate::config::AppConfig;
@@ -13,9 +14,23 @@ use crate::imap::client::{ImapClient, ImapCredentials};
use crate::smtp::client::AttachmentData;
use crate::storage::{self, BlobStorage};
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ImapDraftSource {
pub folder: String,
pub uid: u32,
pub uid_validity: u32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ImapDraftAttachmentRef {
pub id: String,
pub index: usize,
}
#[derive(Debug, Deserialize)]
pub struct UpsertDraftRequest {
pub id: String,
pub from_identity_id: Option<i64>,
#[serde(default)]
pub to: String,
#[serde(default)]
@@ -46,6 +61,10 @@ pub struct UpsertDraftRequest {
#[serde(default)]
pub sync_imap: bool,
pub imap_source: Option<ImapDraftSource>,
#[serde(default)]
pub imap_attachments: Vec<ImapDraftAttachmentRef>,
}
fn default_priority() -> String {
@@ -63,6 +82,78 @@ fn default_pgp_mode() -> String {
struct DraftResponse {
id: String,
status: String,
remote_folder: Option<String>,
remote_uid: Option<u32>,
remote_uid_validity: Option<u32>,
remote_attachments: Vec<ImapDraftAttachmentInfo>,
}
#[derive(Debug, Serialize)]
struct ImapDraftAttachmentInfo {
id: String,
source_index: usize,
source_content_id: Option<String>,
filename: String,
content_type: String,
size: usize,
checksum_sha256: String,
source: &'static str,
}
#[derive(Debug, Serialize)]
struct ImapDraftComposeResponse {
to: String,
cc: String,
bcc: String,
subject: String,
text_body: String,
html_body: Option<String>,
in_reply_to: Option<String>,
references: Option<String>,
request_mdn: bool,
priority: String,
compose_format: String,
from_address: String,
imap_source: ImapDraftSource,
attachments: Vec<ImapDraftAttachmentInfo>,
}
fn imap_attachment_infos(
attachments: &[crate::imap::types::ImapAttachment],
) -> Vec<ImapDraftAttachmentInfo> {
let mut used_ids = std::collections::HashSet::new();
attachments
.iter()
.enumerate()
.map(|(index, attachment)| {
let checksum = format!("{:x}", Sha256::digest(&attachment.data));
let source_content_id = attachment.content_id.clone();
let base_id = source_content_id
.as_deref()
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_else(|| format!("imap-{index}-{}", &checksum[..12]));
let mut id = base_id.clone();
let mut suffix = 1usize;
while !used_ids.insert(id.clone()) {
id = format!("{base_id}-{suffix}");
suffix += 1;
}
ImapDraftAttachmentInfo {
id,
source_index: index,
source_content_id,
filename: attachment
.filename
.clone()
.unwrap_or_else(|| format!("attachment-{}", index + 1)),
content_type: attachment.content_type.clone(),
size: attachment.size,
checksum_sha256: checksum,
source: "imap",
}
})
.collect()
}
#[derive(Debug, Serialize)]
@@ -109,6 +200,39 @@ struct DraftListItem {
#[derive(Debug, Serialize)]
struct DeleteResponse {
status: String,
remote_deleted: bool,
attachments_deleted: usize,
}
#[derive(Debug, Default, Deserialize)]
pub struct DeleteDraftQuery {
pub imap_folder: Option<String>,
pub imap_uid: Option<u32>,
pub imap_uid_validity: Option<u32>,
}
impl DeleteDraftQuery {
fn imap_source(&self) -> Result<Option<ImapDraftSource>, AppError> {
match (
self.imap_folder.as_deref(),
self.imap_uid,
self.imap_uid_validity,
) {
(None, None, None) => Ok(None),
(Some(folder), Some(uid), Some(uid_validity))
if !folder.trim().is_empty() && uid > 0 && uid_validity > 0 =>
{
Ok(Some(ImapDraftSource {
folder: folder.to_string(),
uid,
uid_validity,
}))
}
_ => Err(AppError::BadRequest(
"Incomplete IMAP draft deletion coordinates".to_string(),
)),
}
}
}
pub async fn upsert_draft_handler(
@@ -165,69 +289,188 @@ pub async fn upsert_draft_handler(
)
.map_err(AppError::InternalError)?;
if req.sync_imap && !session.imap_host.trim().is_empty() {
let mut remote_folder = None;
let mut remote_uid = None;
let mut remote_uid_validity = None;
let mut remote_attachments = Vec::new();
if req.sync_imap {
if session.imap_host.trim().is_empty() {
return Err(AppError::ServiceUnavailable(
"IMAP is not available for saving this draft".to_string(),
));
}
let imap_creds = session_imap_credentials(&session);
let drafts_folder = db::folders::find_folder_by_attribute(&conn, "\\Drafts")
.ok()
.flatten()
.unwrap_or_else(|| "Drafts".to_string());
let drafts_folder =
resolve_drafts_folder(&conn, &config).map_err(AppError::InternalError)?;
tracing::debug!(folder = %drafts_folder, draft_id = %req.id, "Resolved IMAP drafts folder");
let previous = db::drafts::get_draft(&conn, &req.id).map_err(AppError::InternalError)?;
let mut snapshot_attachments = load_snapshot_attachments(&conn, &req.id, storage.as_ref())
.await
.map_err(|e| {
tracing::warn!(error = %e, draft_id = %req.id, "Failed to load attachments for IMAP draft snapshot");
AppError::InternalError(e)
})?;
if let Some(source) = req.imap_source.as_ref() {
let mut remote_attachments = load_imap_source_attachments(
imap_client.as_ref(),
&imap_creds,
source,
&req.imap_attachments,
)
.await?;
snapshot_attachments.append(&mut remote_attachments);
}
let selected_identity = if let Some(identity_id) = req.from_identity_id {
Some(
db::identities::get_identity(&conn, identity_id)
.map_err(AppError::InternalError)?
.ok_or_else(|| AppError::NotFound("Identity not found".to_string()))?,
)
} else {
db::identities::get_default_identity(&conn).map_err(AppError::InternalError)?
};
let from_email = selected_identity
.as_ref()
.map(|identity| identity.email.as_str())
.unwrap_or(session.email.as_str());
let from_header = selected_identity
.as_ref()
.map(|identity| {
if identity.display_name.trim().is_empty() {
identity.email.clone()
} else {
let name = identity
.display_name
.replace('\\', "\\\\")
.replace('"', "\\\"");
format!("\"{name}\" <{}>", identity.email)
}
})
.unwrap_or_else(|| session.email.clone());
let rfc822_bytes = build_draft_rfc822(
&req,
&from_header,
from_email,
&snapshot_attachments,
)
.map_err(|e| {
tracing::warn!(error = %e, draft_id = %req.id, "Failed to build RFC822 draft snapshot");
AppError::InternalError(format!("Failed to build IMAP draft: {e}"))
})?;
let (new_uid, uid_validity) = imap_client
.append_message_resolved_uid(
&imap_creds,
&drafts_folder,
&rfc822_bytes,
&["\\Draft", "\\Seen"],
"X-RustBox-Draft-ID",
&req.id,
)
.await
.map_err(|e| {
tracing::warn!(error = %e, folder = %drafts_folder, draft_id = %req.id, "Failed to save draft snapshot to IMAP");
AppError::ServiceUnavailable(format!("Failed to save draft to IMAP: {e}"))
})?;
let snapshot_attachments =
load_snapshot_attachments(&conn, &req.id, storage.as_ref()).await;
match snapshot_attachments
.and_then(|attachments| build_draft_rfc822(&req, &session.email, &attachments))
db::drafts::set_remote_location(&conn, &req.id, &drafts_folder, new_uid, uid_validity)
.map_err(AppError::InternalError)?;
remote_folder = Some(drafts_folder.clone());
remote_uid = Some(new_uid);
remote_uid_validity = Some(uid_validity);
match imap_client
.fetch_body(&imap_creds, &drafts_folder, new_uid)
.await
{
Ok(rfc822_bytes) => match imap_client
.append_message_resolved_uid(
&imap_creds,
&drafts_folder,
&rfc822_bytes,
&["\\Draft", "\\Seen"],
"X-RustBox-Draft-ID",
&req.id,
)
.await
{
Ok((new_uid, uid_validity)) => {
db::drafts::set_remote_location(
&conn,
&req.id,
&drafts_folder,
new_uid,
uid_validity,
)
.map_err(AppError::InternalError)?;
Ok(body) => remote_attachments = imap_attachment_infos(&body.attachments),
Err(error) => tracing::warn!(
error = %error,
folder = %drafts_folder,
uid = new_uid,
"Saved IMAP draft but could not refresh its attachment map"
),
}
if let Some(previous) = previous {
remove_previous_remote_snapshot(
imap_client.as_ref(),
&imap_creds,
&previous,
&drafts_folder,
new_uid,
uid_validity,
)
.await;
}
}
Err(e) => {
tracing::warn!(error = %e, folder = %drafts_folder, draft_id = %req.id, "Failed to resolve IMAP UID for saved draft snapshot; keeping previous remote pointer");
}
},
Err(e) => {
tracing::warn!(error = %e, draft_id = %req.id, "Failed to build RFC822 draft snapshot")
}
let previous_matches_source = previous.as_ref().is_some_and(|draft| {
req.imap_source.as_ref().is_some_and(|source| {
draft.remote_folder.as_deref() == Some(source.folder.as_str())
&& draft.remote_uid == Some(source.uid)
&& draft.remote_uid_validity == Some(source.uid_validity)
})
});
if let Some(source) = req.imap_source.as_ref() {
remove_imap_source_snapshot(
imap_client.as_ref(),
&imap_creds,
source,
&drafts_folder,
new_uid,
)
.await;
}
if let Some(previous) = previous.filter(|_| !previous_matches_source) {
remove_previous_remote_snapshot(
imap_client.as_ref(),
&imap_creds,
&previous,
&drafts_folder,
new_uid,
uid_validity,
)
.await;
}
}
Ok(Json(DraftResponse {
id: req.id,
status: "saved".to_string(),
remote_folder,
remote_uid,
remote_uid_validity,
remote_attachments,
})
.into_response())
}
fn resolve_drafts_folder(
conn: &crate::db::pool::Connection,
config: &AppConfig,
) -> Result<String, String> {
if let Some(folder) = db::special_folders::get_role(conn, "drafts")? {
return Ok(folder);
}
if let Some(folder) = config
.default_drafts_folder
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
&& db::folders::get_folder(conn, folder)?.is_some()
{
return Ok(folder.to_string());
}
if let Some(folder) = db::folders::find_folder_by_attribute(conn, "\\Drafts")? {
return Ok(folder);
}
let folders = db::folders::get_all_folders(conn)?;
for common_name in ["Drafts", "Draft"] {
if let Some(folder) = folders
.iter()
.find(|folder| folder.name.eq_ignore_ascii_case(common_name))
{
return Ok(folder.name.clone());
}
}
Ok("Drafts".to_string())
}
fn session_imap_credentials(session: &SessionState) -> ImapCredentials {
ImapCredentials {
host: session.imap_host.clone(),
@@ -239,6 +482,100 @@ fn session_imap_credentials(session: &SessionState) -> ImapCredentials {
}
}
pub(crate) async fn load_imap_source_attachments(
imap_client: &dyn ImapClient,
creds: &ImapCredentials,
source: &ImapDraftSource,
refs: &[ImapDraftAttachmentRef],
) -> Result<Vec<AttachmentData>, AppError> {
if refs.is_empty() {
return Ok(Vec::new());
}
let status = imap_client
.folder_status(creds, &source.folder)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
if status.uid_validity != source.uid_validity {
return Err(AppError::BadRequest(
"The source draft changed on the IMAP server; reopen it before saving".to_string(),
));
}
let body = imap_client
.fetch_body(creds, &source.folder, source.uid)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
let mut seen = std::collections::HashSet::new();
let mut attachments = Vec::with_capacity(refs.len());
for reference in refs {
if reference.id.is_empty()
|| reference.id.len() > 255
|| reference.id.chars().any(|ch| matches!(ch, '\r' | '\n'))
|| !seen.insert(reference.index)
{
return Err(AppError::BadRequest(
"Invalid IMAP draft attachment reference".to_string(),
));
}
let attachment = body.attachments.get(reference.index).ok_or_else(|| {
AppError::BadRequest(
"An attachment in the source draft changed; reopen the draft before saving"
.to_string(),
)
})?;
attachments.push(AttachmentData {
filename: attachment
.filename
.clone()
.unwrap_or_else(|| format!("attachment-{}", reference.index + 1)),
content_type: attachment.content_type.clone(),
data: attachment.data.clone(),
content_id: Some(reference.id.clone()),
});
}
Ok(attachments)
}
pub(crate) async fn remove_imap_source_snapshot(
imap_client: &dyn ImapClient,
creds: &ImapCredentials,
source: &ImapDraftSource,
new_folder: &str,
new_uid: u32,
) {
if source.folder == new_folder && source.uid == new_uid {
return;
}
match imap_client.folder_status(creds, &source.folder).await {
Ok(status) if status.uid_validity == source.uid_validity => {
if let Err(error) = imap_client
.expunge_message(creds, &source.folder, source.uid)
.await
{
tracing::warn!(
error = %error,
folder = %source.folder,
uid = source.uid,
"Failed to remove superseded IMAP draft"
);
}
}
Ok(status) => tracing::info!(
folder = %source.folder,
uid = source.uid,
stored_uid_validity = source.uid_validity,
current_uid_validity = status.uid_validity,
"Skipping stale IMAP draft cleanup after UIDVALIDITY change"
),
Err(error) => tracing::warn!(
error = %error,
folder = %source.folder,
uid = source.uid,
"Could not verify source IMAP draft before cleanup"
),
}
}
async fn remove_previous_remote_snapshot(
imap_client: &dyn ImapClient,
creds: &ImapCredentials,
@@ -317,6 +654,51 @@ pub(crate) async fn remove_remote_draft_snapshot(
}
}
async fn delete_imap_draft_source(
imap_client: &dyn ImapClient,
creds: &ImapCredentials,
configured_drafts_folder: &str,
source: &ImapDraftSource,
) -> Result<bool, AppError> {
let status = imap_client
.folder_status(creds, &source.folder)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
if status.uid_validity != source.uid_validity {
return Err(AppError::BadRequest(
"The draft changed on the IMAP server; reopen it before deleting".to_string(),
));
}
let headers = imap_client
.fetch_headers(creds, &source.folder, &source.uid.to_string())
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
let Some(header) = headers.into_iter().find(|item| item.uid == source.uid) else {
// It was already removed by another client. Treat that as a successful
// remote deletion so local recovery data can still be cleaned up.
return Ok(false);
};
let is_draft = header
.flags
.iter()
.any(|flag| flag.eq_ignore_ascii_case("\\Draft"))
|| source.folder.as_str() == configured_drafts_folder;
if !is_draft {
return Err(AppError::BadRequest(
"The IMAP message is no longer a draft; reopen the mailbox before deleting".to_string(),
));
}
imap_client
.expunge_message(creds, &source.folder, source.uid)
.await
.map_err(|e| {
AppError::ServiceUnavailable(format!("Failed to delete draft from IMAP: {e}"))
})?;
Ok(true)
}
async fn load_snapshot_attachments(
conn: &crate::db::pool::Connection,
draft_id: &str,
@@ -343,17 +725,69 @@ async fn load_snapshot_attachments(
Ok(attachments)
}
fn split_mailbox_list(value: &str) -> Vec<String> {
let mut values = Vec::new();
let mut current = String::new();
let mut quoted = false;
let mut escaped = false;
let mut angle_depth = 0usize;
for ch in value.chars() {
if escaped {
current.push(ch);
escaped = false;
continue;
}
if ch == '\\' && quoted {
current.push(ch);
escaped = true;
continue;
}
if ch == '"' {
quoted = !quoted;
current.push(ch);
continue;
}
if !quoted && ch == '<' {
angle_depth += 1;
} else if !quoted && ch == '>' && angle_depth > 0 {
angle_depth -= 1;
}
if ch == ',' && !quoted && angle_depth == 0 {
let token = current.trim();
if !token.is_empty() {
values.push(token.to_string());
}
current.clear();
continue;
}
current.push(ch);
}
let token = current.trim();
if !token.is_empty() {
values.push(token.to_string());
}
values
}
fn build_draft_rfc822(
req: &UpsertDraftRequest,
from_header: &str,
from_email: &str,
attachments: &[AttachmentData],
) -> Result<Vec<u8>, String> {
use lettre::message::{
Attachment, Mailbox, MultiPart, SinglePart,
header::{ContentType, HeaderName, HeaderValue},
use lettre::{
Address,
address::Envelope,
message::{
Attachment, Mailbox, MultiPart, SinglePart,
header::{ContentType, HeaderName, HeaderValue},
},
};
let from_mailbox: Mailbox = from_email
let from_mailbox: Mailbox = from_header
.parse()
.map_err(|e: lettre::address::AddressError| e.to_string())?;
let from_address: Address = from_email
.parse()
.map_err(|e: lettre::address::AddressError| e.to_string())?;
@@ -361,26 +795,41 @@ fn build_draft_rfc822(
let mut builder = lettre::Message::builder()
.from(from_mailbox)
.subject(&req.subject)
.keep_bcc()
.raw_header(HeaderValue::new(
HeaderName::new_from_ascii_str("X-RustBox-Draft-ID"),
draft_id,
));
for addr in req.to.split(',').map(str::trim).filter(|s| !s.is_empty()) {
let mut valid_recipients = 0usize;
for addr in split_mailbox_list(&req.to) {
if let Ok(mailbox) = addr.parse::<Mailbox>() {
builder = builder.to(mailbox);
valid_recipients += 1;
}
}
for addr in req.cc.split(',').map(str::trim).filter(|s| !s.is_empty()) {
for addr in split_mailbox_list(&req.cc) {
if let Ok(mailbox) = addr.parse::<Mailbox>() {
builder = builder.cc(mailbox);
valid_recipients += 1;
}
}
for addr in req.bcc.split(',').map(str::trim).filter(|s| !s.is_empty()) {
for addr in split_mailbox_list(&req.bcc) {
if let Ok(mailbox) = addr.parse::<Mailbox>() {
builder = builder.bcc(mailbox);
valid_recipients += 1;
}
}
// Lettre requires at least one envelope destination even when merely serializing
// an RFC822 message. Drafts, however, are valid before the user chooses any
// recipient. Force a private build-only envelope in that case; it is not part
// of the formatted RFC822 headers and therefore does not alter the saved draft.
if valid_recipients == 0 {
let envelope = Envelope::new(Some(from_address.clone()), vec![from_address])
.map_err(|e| e.to_string())?;
builder = builder.envelope(envelope);
}
if let Some(ref irt) = req.in_reply_to {
builder = builder.in_reply_to(irt.clone());
}
@@ -425,7 +874,8 @@ fn build_draft_rfc822(
att.content_type.parse().unwrap_or(ContentType::TEXT_PLAIN);
let cid = att.content_id.as_deref().unwrap_or("unknown");
related = related.singlepart(
Attachment::new_inline(cid.to_string()).body(att.data.clone(), content_type),
Attachment::new_inline_with_name(cid.to_string(), att.filename.clone())
.body(att.data.clone(), content_type),
);
}
MultiPart::alternative()
@@ -461,6 +911,160 @@ fn build_draft_rfc822(
Ok(email.formatted())
}
fn unfolded_header_value(raw_headers: &str, name: &str) -> Option<String> {
let wanted = name.trim().to_ascii_lowercase();
let mut current_name = String::new();
let mut current_value = String::new();
let flush = |header_name: &str, value: &str| -> Option<String> {
if header_name.eq_ignore_ascii_case(&wanted) {
let value = value.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
None
};
for line in raw_headers.lines() {
if line.starts_with(' ') || line.starts_with('\t') {
if !current_value.is_empty() {
current_value.push(' ');
}
current_value.push_str(line.trim());
continue;
}
if let Some(value) = flush(&current_name, &current_value) {
return Some(value);
}
current_name.clear();
current_value.clear();
if let Some((header_name, value)) = line.split_once(':') {
current_name.push_str(header_name.trim());
current_value.push_str(value.trim());
}
}
flush(&current_name, &current_value)
}
fn format_imap_addresses(addresses: &[crate::imap::types::EmailAddress]) -> String {
addresses
.iter()
.filter_map(|entry| {
let address = entry.address.trim();
if address.is_empty() {
return None;
}
let name = entry.name.as_deref().unwrap_or("").trim();
if name.is_empty() {
Some(address.to_string())
} else {
let escaped = name.replace('\\', "\\\\").replace('"', "\\\"");
Some(format!("\"{escaped}\" <{address}>"))
}
})
.collect::<Vec<_>>()
.join(", ")
}
fn draft_priority(raw_headers: &str) -> String {
let value = unfolded_header_value(raw_headers, "X-Priority")
.or_else(|| unfolded_header_value(raw_headers, "Priority"))
.unwrap_or_default()
.to_ascii_lowercase();
if value.starts_with('1') || value.starts_with('2') || value.contains("high") {
"high".to_string()
} else if value.starts_with('4') || value.starts_with('5') || value.contains("low") {
"low".to_string()
} else {
"normal".to_string()
}
}
pub async fn get_imap_draft_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
AxumPath((folder, uid)): AxumPath<(String, u32)>,
) -> Result<Response, AppError> {
if session.imap_host.trim().is_empty() {
return Err(AppError::ServiceUnavailable(
"IMAP is not available for opening this draft".to_string(),
));
}
let creds = session_imap_credentials(&session);
let headers = imap_client
.fetch_headers(&creds, &folder, &uid.to_string())
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
let header = headers
.into_iter()
.find(|item| item.uid == uid)
.ok_or_else(|| AppError::NotFound(format!("Draft UID {uid} not found in {folder}")))?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let configured_drafts =
resolve_drafts_folder(&conn, &config).unwrap_or_else(|_| "Drafts".to_string());
let is_draft = header
.flags
.iter()
.any(|flag| flag.eq_ignore_ascii_case("\\Draft"))
|| folder == configured_drafts;
if !is_draft {
return Err(AppError::BadRequest(
"This IMAP message is not marked as a draft".to_string(),
));
}
let status = imap_client
.folder_status(&creds, &folder)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
let body = imap_client
.fetch_body(&creds, &folder, uid)
.await
.map_err(|e| AppError::ServiceUnavailable(format!("IMAP error: {e}")))?;
let attachments = imap_attachment_infos(&body.attachments);
let from_address = header
.from
.first()
.map(|entry| entry.address.clone())
.unwrap_or_else(|| session.email.clone());
let request_mdn = unfolded_header_value(&body.raw_headers, "Disposition-Notification-To")
.is_some()
|| unfolded_header_value(&body.raw_headers, "Return-Receipt-To").is_some();
let compose_format = if body.text_html.is_some() {
"html"
} else {
"text"
}
.to_string();
Ok(Json(ImapDraftComposeResponse {
to: format_imap_addresses(&header.to),
cc: format_imap_addresses(&header.cc),
bcc: unfolded_header_value(&body.raw_headers, "Bcc").unwrap_or_default(),
subject: header.subject.unwrap_or_default(),
text_body: body.text_plain.unwrap_or_default(),
html_body: body.text_html,
in_reply_to: header.in_reply_to,
references: header.references,
request_mdn,
priority: draft_priority(&body.raw_headers),
compose_format,
from_address,
imap_source: ImapDraftSource {
folder,
uid,
uid_validity: status.uid_validity,
},
attachments,
})
.into_response())
}
pub async fn list_drafts_handler(
Extension(session): Extension<SessionState>,
Extension(config): Extension<Arc<AppConfig>>,
@@ -541,38 +1145,67 @@ pub async fn delete_draft_handler(
Extension(imap_client): Extension<Arc<dyn ImapClient>>,
Extension(storage): Extension<Arc<dyn BlobStorage>>,
AxumPath(id): AxumPath<String>,
Query(query): Query<DeleteDraftQuery>,
) -> Result<Response, AppError> {
db::drafts::validate_draft_id(&id).map_err(AppError::BadRequest)?;
let conn = db::pool::open_user_db(&config.data_dir, &session.user_hash)
.map_err(|e| AppError::InternalError(format!("Failed to open database: {e}")))?;
let existing = db::drafts::get_draft(&conn, &id).map_err(AppError::InternalError)?;
let Some(existing) = existing else {
let explicit_source = query.imap_source()?;
if existing.is_none() && explicit_source.is_none() {
return Err(AppError::NotFound("Draft not found".to_string()));
};
if !session.imap_host.trim().is_empty() {
let creds = session_imap_credentials(&session);
remove_remote_draft_snapshot(imap_client.as_ref(), &creds, &existing).await;
}
let attachments =
db::drafts::get_draft_attachments(&conn, &id).map_err(AppError::InternalError)?;
let deleted = db::drafts::delete_draft(&conn, &id).map_err(AppError::InternalError)?;
if !deleted {
return Err(AppError::NotFound("Draft not found".to_string()));
let stored_source = existing.as_ref().and_then(|draft| {
Some(ImapDraftSource {
folder: draft.remote_folder.clone()?,
uid: draft.remote_uid?,
uid_validity: draft.remote_uid_validity?,
})
});
let remote_source = explicit_source.or(stored_source);
let mut remote_deleted = false;
if let Some(source) = remote_source.as_ref() {
if session.imap_host.trim().is_empty() {
return Err(AppError::ServiceUnavailable(
"IMAP is not available for deleting this draft".to_string(),
));
}
let creds = session_imap_credentials(&session);
let drafts_folder =
resolve_drafts_folder(&conn, &config).map_err(AppError::InternalError)?;
remote_deleted =
delete_imap_draft_source(imap_client.as_ref(), &creds, &drafts_folder, source).await?;
}
let attachments = if existing.is_some() {
db::drafts::get_draft_attachments(&conn, &id).map_err(AppError::InternalError)?
} else {
Vec::new()
};
if existing.is_some() {
let deleted = db::drafts::delete_draft(&conn, &id).map_err(AppError::InternalError)?;
if !deleted {
return Err(AppError::NotFound("Draft not found".to_string()));
}
}
let mut attachments_deleted = 0usize;
for attachment in &attachments {
if let Err(error) = storage.delete(&attachment.storage_key).await {
tracing::warn!(
match storage.delete(&attachment.storage_key).await {
Ok(()) => attachments_deleted += 1,
Err(error) => tracing::warn!(
error = %error,
storage_key = %attachment.storage_key,
"Failed to remove deleted draft attachment blob"
);
),
}
}
Ok(Json(DeleteResponse {
status: "deleted".to_string(),
remote_deleted,
attachments_deleted,
})
.into_response())
}
+4
View File
@@ -309,6 +309,10 @@ pub fn create_router_with_storage(config: Arc<AppConfig>, services: RouterServic
"/messages/{folder}/{uid}/source",
get(messages::download_eml_source),
)
.route(
"/messages/{folder}/{uid}/draft-compose",
get(drafts::get_imap_draft_handler),
)
.route("/messages/move", post(messages::move_message_handler))
.route("/messages/copy", post(messages::copy_message_handler))
.route("/messages/send", post(send::send_message_handler))
+41
View File
@@ -9,6 +9,7 @@ use crate::config::AppConfig;
use crate::db;
use crate::error::AppError;
use crate::imap::client::{ImapClient, ImapCredentials};
use crate::routes::drafts::{ImapDraftAttachmentRef, ImapDraftSource};
use crate::smtp::client::{AttachmentData, SendableMessage, SmtpClient, SmtpCredentials};
use crate::smtp::types::{PgpMode, PgpSendConfig};
use crate::storage::{self, BlobStorage};
@@ -48,6 +49,11 @@ pub struct SendRequest {
#[serde(default)]
pub pgp_attach_recipient_keys: bool,
pub imap_source: Option<ImapDraftSource>,
#[serde(default)]
pub imap_attachments: Vec<ImapDraftAttachmentRef>,
}
fn default_priority() -> String {
@@ -75,6 +81,8 @@ pub async fn send_message_handler(
Extension(storage): Extension<Arc<dyn BlobStorage>>,
Json(req): Json<SendRequest>,
) -> Result<Response, AppError> {
let imap_source = req.imap_source.clone();
let imap_attachment_refs = req.imap_attachments.clone();
if let Some(ref draft_id) = req.draft_id {
db::drafts::validate_draft_id(draft_id).map_err(AppError::BadRequest)?;
}
@@ -253,6 +261,23 @@ pub async fn send_message_handler(
} else {
vec![]
};
if let Some(source) = imap_source.as_ref() {
let mut remote_attachments = crate::routes::drafts::load_imap_source_attachments(
imap_client.as_ref(),
&ImapCredentials {
host: session.imap_host.clone(),
port: session.imap_port,
tls: session.imap_tls,
tls_verify: session.imap_tls_verify,
email: session.email.clone(),
password: session.password.clone(),
},
source,
&imap_attachment_refs,
)
.await?;
attachments.append(&mut remote_attachments);
}
if let Some((key_id, armor)) = attached_public_key {
attachments.push(AttachmentData {
filename: format!("openpgp-{key_id}-public.asc"),
@@ -356,10 +381,16 @@ pub async fn send_message_handler(
}
}
let mut source_removed_with_local_draft = false;
if let Some(ref draft_id) = req.draft_id {
if let Ok(conn) = db::pool::open_user_db(&config.data_dir, &session.user_hash)
&& let Ok(Some(draft)) = db::drafts::get_draft(&conn, draft_id)
{
source_removed_with_local_draft = imap_source.as_ref().is_some_and(|source| {
draft.remote_folder.as_deref() == Some(source.folder.as_str())
&& draft.remote_uid == Some(source.uid)
&& draft.remote_uid_validity == Some(source.uid_validity)
});
crate::routes::drafts::remove_remote_draft_snapshot(
imap_client.as_ref(),
&imap_creds,
@@ -375,6 +406,16 @@ pub async fn send_message_handler(
)
.await;
}
if !source_removed_with_local_draft && let Some(source) = imap_source.as_ref() {
crate::routes::drafts::remove_imap_source_snapshot(
imap_client.as_ref(),
&imap_creds,
source,
"",
0,
)
.await;
}
Ok(Json(SendResponse {
status: "sent".to_string(),
+1 -1
View File
@@ -185,7 +185,7 @@ fn build_clear_content(message: &SendableMessage) -> Result<MultiPart, String> {
att.content_type.parse().unwrap_or(ContentType::TEXT_PLAIN);
let content_id = att.content_id.as_deref().unwrap_or("unknown");
related = related.singlepart(
Attachment::new_inline(content_id.to_string())
Attachment::new_inline_with_name(content_id.to_string(), att.filename.clone())
.body(att.data.clone(), content_type),
);
}
+21 -1
View File
@@ -241,8 +241,23 @@
height: 12px;
}
.message-load-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
padding: 7px;
box-sizing: border-box;
}
.message-load-actions.hidden {
display: none;
}
.load-more {
margin: 7px;
width: 100%;
min-width: 0;
margin: 0;
}
.reader-pane {
@@ -428,6 +443,11 @@
grid-template-columns: 12px 5px;
}
.recipient-more>span {
color: var(--muted);
font-weight: 650;
}
.sender span,
.message-meta time {
color: var(--muted);
+131 -5
View File
@@ -52,6 +52,7 @@
display: grid;
grid-template-rows: auto auto auto minmax(240px, 1fr) auto auto auto auto;
overflow: hidden;
position: relative;
container-name: compose-card;
container-type: inline-size;
}
@@ -66,6 +67,18 @@
overflow: auto;
}
.compose-dialog.compose-fields-collapsed .compose-card {
grid-template-rows: auto auto minmax(240px, 1fr) auto auto auto auto;
}
.compose-dialog.compose-fields-collapsed .compose-addresses {
display: none;
}
#compose-fields-toggle {
white-space: nowrap;
}
.compose-addresses>label {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
@@ -283,17 +296,55 @@
background: var(--field);
}
.compose-attachment-kind {
flex: 0 0 auto;
padding: 1px 5px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: var(--panel-2);
font-size: 9px;
font-weight: 700;
line-height: 1.4;
}
.compose-attachment-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compose-attachment button {
width: 20px;
height: 20px;
display: grid;
place-items: center;
padding: 0;
min-width: 20px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 4px;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--muted);
}
.compose-attachment button:hover,
.compose-attachment button:focus-visible {
background: var(--hover);
color: var(--text);
}
.compose-attachment button svg {
width: 13px;
height: 13px;
}
.compose-attachment-remove {
width: 22px;
padding: 0 !important;
}
.compose-mode-control {
flex: 0 0 auto;
justify-self: start;
@@ -441,6 +492,81 @@
outline-offset: 2px;
}
.compose-image-actions {
position: fixed;
z-index: 140;
display: flex;
align-items: center;
flex-wrap: wrap;
width: max-content;
max-width: min(560px, calc(100vw - 16px));
gap: 3px;
padding: 5px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-panel);
background: var(--panel);
box-shadow: 0 8px 26px var(--shadow);
}
.compose-image-actions.hidden {
display: none;
}
.compose-image-resize {
position: fixed;
z-index: 139;
pointer-events: none;
border: 1px solid var(--accent);
box-sizing: border-box;
}
.compose-image-resize.hidden {
display: none;
}
.compose-image-resize-handle {
position: absolute;
width: 11px;
height: 11px;
border: 2px solid var(--panel);
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
pointer-events: auto;
touch-action: none;
}
.compose-image-resize-handle.nw { left: -6px; top: -6px; cursor: nwse-resize; }
.compose-image-resize-handle.ne { right: -6px; top: -6px; cursor: nesw-resize; }
.compose-image-resize-handle.sw { left: -6px; bottom: -6px; cursor: nesw-resize; }
.compose-image-resize-handle.se { right: -6px; bottom: -6px; cursor: nwse-resize; }
.compose-image-resizing,
.compose-image-resizing * {
user-select: none !important;
}
.compose-image-actions-label {
max-width: 180px;
margin-right: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 10px;
font-weight: 700;
}
.compose-image-actions .button.active {
border-color: var(--accent);
background: var(--selected);
color: var(--text);
}
.compose-image-actions .toolbar-separator {
height: 20px;
}
.image-edit-dialog {
width: min(840px, calc(100vw - 28px));
max-width: none;
+1 -1
View File
@@ -52,7 +52,7 @@
"reply_all": "Reply all",
"forward": "Forward",
"headers": "Headers",
"message_details": "Message details",
"message_details": "Details",
"move_copy": "Move / copy",
"move_to": "Move to…",
"copy_to": "Copy to…",
+11 -1
View File
@@ -599,6 +599,12 @@
"Copy address": "Kopiuj adres",
"Search messages": "Szukaj wiadomości",
"Draft saved": "Szkic zapisany",
"Save draft": "Zapisz szkic",
"Draft saved to IMAP": "Szkic zapisany na IMAP",
"Delete draft": "Usuń szkic",
"Draft deleted": "Szkic usunięty",
"Delete this draft? The IMAP draft and all locally uploaded draft attachments will be permanently removed.": "Usunąć ten szkic? Szkic z IMAP oraz wszystkie lokalnie przesłane załączniki szkicu zostaną trwale usunięte.",
"Wait for attachment upload to finish": "Poczekaj na zakończenie wysyłania załącznika",
"Uploading files…": "Wysyłanie plików…",
"Upload complete": "Wysyłanie zakończone",
"Allow editing predefined style": "Zezwól na edycję gotowego stylu",
@@ -629,7 +635,11 @@
"Discord inspired": "Inspirowany Discord",
"Slack inspired": "Inspirowany Slack",
"Reddit inspired": "Inspirowany Reddit",
"Notion inspired": "Inspirowany Notion"
"Notion inspired": "Inspirowany Notion",
"Hide fields": "Ukryj pola",
"Show fields": "Pokaż pola",
"Hide addressing fields": "Ukryj pola adresowe",
"Show addressing fields": "Pokaż pola adresowe"
},
"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.",
+31 -4
View File
@@ -180,7 +180,10 @@
<button id="selection-done" class="button quiet" type="button">Done</button>
</div>
<div id="message-list" class="message-list" aria-live="polite"></div>
<button id="load-more" class="button quiet load-more hidden" type="button">Load more</button>
<div id="message-load-actions" class="message-load-actions hidden">
<button id="load-more" class="button quiet load-more" type="button">Load more</button>
<button id="load-all" class="button quiet load-more" type="button">Load all</button>
</div>
<div id="message-resizer" class="pane-resizer" role="separator" aria-orientation="vertical"
aria-label="Resize message list"></div>
</section>
@@ -221,7 +224,7 @@
<details id="message-details-menu" class="toolbar-menu">
<summary class="button quiet"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg><span data-i18n="message_details">Message details</span></summary>
</svg><span data-i18n="message_details">Details</span></summary>
<div class="toolbar-menu-popover" role="menu">
<button id="headers-btn" type="button" role="menuitem" data-i18n="headers">Headers</button>
<button id="source-btn" type="button" role="menuitem" data-i18n="source">Source</button>
@@ -307,7 +310,8 @@
<dialog id="compose-dialog" class="dialog compose-dialog" data-i18n-scope>
<form id="compose-form" class="dialog-card compose-card" method="dialog">
<header class="dialog-header"><strong id="compose-title">New message</strong>
<div class="compose-header-actions"><button type="button" id="compose-recover" class="button quiet small"
<div class="compose-header-actions"><button type="button" id="compose-fields-toggle" class="button quiet small"
aria-expanded="true" title="Hide addressing fields">Hide fields</button><button type="button" id="compose-recover" class="button quiet small"
title="Recover a saved draft">Recover draft</button><button type="button" id="compose-fullscreen"
class="icon-button compose-fullscreen-toggle" aria-label="Toggle full screen composer" aria-pressed="false"
title="Full screen">⛶</button><button type="button" class="icon-button close-dialog"
@@ -440,6 +444,27 @@
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg></button>
</div>
<div id="compose-image-actions" class="compose-image-actions hidden" role="toolbar" aria-label="Inline image actions">
<span class="compose-image-actions-label">Inline image</span>
<button type="button" class="button quiet small" data-image-width="25">25%</button>
<button type="button" class="button quiet small" data-image-width="50">50%</button>
<button type="button" class="button quiet small" data-image-width="75">75%</button>
<button type="button" class="button quiet small" data-image-width="100">100%</button>
<span class="toolbar-separator"></span>
<button type="button" class="button quiet small" data-image-align="inline">Inline</button>
<button type="button" class="button quiet small" data-image-align="left">Left</button>
<button type="button" class="button quiet small" data-image-align="center">Center</button>
<button type="button" class="button quiet small" data-image-align="right">Right</button>
<span class="toolbar-separator"></span>
<button type="button" id="compose-image-more" class="button quiet small">More…</button>
<button type="button" id="compose-image-detach" class="button quiet small">Keep as attachment</button>
</div>
<div id="compose-image-resize" class="compose-image-resize hidden" aria-hidden="true">
<span class="compose-image-resize-handle nw" data-image-resize="nw"></span>
<span class="compose-image-resize-handle ne" data-image-resize="ne"></span>
<span class="compose-image-resize-handle sw" data-image-resize="sw"></span>
<span class="compose-image-resize-handle se" data-image-resize="se"></span>
</div>
<div id="compose-editor" class="compose-editor" contenteditable="true" role="textbox" aria-multiline="true"
data-placeholder="Write a message"></div>
<div id="compose-upload-progress" class="compose-upload-progress hidden" role="status" aria-live="polite">
@@ -457,7 +482,9 @@
<span id="compose-status" class="muted"></span><span id="compose-message-size"
class="muted compose-message-size" title="Estimated final message size">~0 / __RUSTBOX_MAX_MESSAGE_MB__
MiB</span>
<div class="compose-submit-actions"><button type="button"
<div class="compose-submit-actions"><button id="compose-delete-draft" type="button"
class="button quiet danger hidden">Delete draft</button><button id="compose-save-draft" type="button"
class="button quiet">Save draft</button><button type="button"
class="button quiet close-dialog">Cancel</button><button id="send-btn" type="submit"
class="button primary"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use>
+1
View File
@@ -75,6 +75,7 @@
imapSyncInventoryPending: 0,
calendarReminderTimer: null,
composeDraftId: null,
composeRemoteDraft: null,
composeAttachments: [],
composeDirty: false,
composeNeedsRemoteSync: false,
+88 -24
View File
@@ -65,6 +65,31 @@ async function loadMessages(reset = false, options = {}) {
}
}
async function loadAllMessages() {
if (state.searchContext || state.messages.length >= state.total) return;
const loadAll = $("#load-all");
const loadMore = $("#load-more");
setBusy(loadAll, true);
if (loadMore) loadMore.disabled = true;
try {
let guard = 0;
while (state.messages.length < state.total && guard < 10000) {
const before = state.messages.length;
state.page += 1;
await loadMessages(false, { background: true });
guard += 1;
if (state.messages.length <= before) {
state.page = Math.max(0, state.page - 1);
break;
}
}
saveMessageListState();
} finally {
setBusy(loadAll, false);
if (loadMore) loadMore.disabled = false;
}
}
function normalizedMessageFlags(value) {
const source = Array.isArray(value) ? value : String(value || "").split(/[,\s]+/).filter(Boolean);
return [...new Set(source.map((flag) => String(flag).trim()).filter(Boolean))].sort();
@@ -276,7 +301,8 @@ function renderMessages() {
row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); });
list.append(row);
}
$("#load-more").classList.toggle("hidden", Boolean(state.searchContext) || state.messages.length >= state.total);
const canLoadMore = !state.searchContext && state.messages.length < state.total;
$("#message-load-actions")?.classList.toggle("hidden", !canLoadMore);
renderBulkToolbar();
}
@@ -387,7 +413,26 @@ async function openMessageTarget(accountId, folder, uid, fullscreen = false) {
if (fullscreen && state.currentDetail) setReaderFullscreen(true, { pushHistory: false });
}
function messageLooksLikeImapDraft(folder, uid) {
if (state.specialFolders?.drafts && state.specialFolders.drafts === folder) return true;
const folderMeta = state.folders.find((item) => item.name === folder);
const attrs = Array.isArray(folderMeta?.attributes) ? folderMeta.attributes : String(folderMeta?.attributes || "").split(/\s+/);
if (attrs.some((value) => String(value).toLowerCase() === "\\drafts")) return true;
const message = state.messages.find((item) => item.folder === folder && Number(item.uid) === Number(uid))
|| state.messages.find((item) => Number(item.uid) === Number(uid));
const flags = Array.isArray(message?.flags) ? message.flags : String(message?.flags || "").split(",");
return flags.some((value) => String(value).trim().toLowerCase() === "\\draft");
}
async function openMessage(folder, uid) {
if (messageLooksLikeImapDraft(folder, uid)) {
try {
await openImapDraftComposer(folder, uid);
return;
} catch (error) {
toast(error.message || "Could not open IMAP draft", "error");
}
}
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
state.selected = { folder, uid };
@@ -460,40 +505,59 @@ function addressLabel(entry) {
return entry.name ? `${entry.name} <${entry.address}>` : entry.address;
}
function readerRecipientDisplayLimit(host, total) {
if (total <= 3) return total;
const width = host?.closest(".sender")?.getBoundingClientRect().width || host?.clientWidth || 0;
if (width >= 560) return 3;
if (width >= 320) return 2;
return 1;
}
function appendReaderRecipientRow(host, prefix, entry) {
const row = el("div", "recipient-line");
row.append(el("span", "", `${prefix} ${addressLabel(entry)}`));
const add = el("button", "address-contact-button");
add.type = "button";
add.title = `Add ${entry.address} to contacts`;
add.setAttribute("aria-label", add.title);
add.innerHTML = svgIcon("user-round");
add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); });
row.append(add);
host.append(row);
}
function renderReaderRecipients(detail) {
const host = $("#reader-to");
host.replaceChildren();
const groups = [
["to", detail.to_addresses || []],
["cc", detail.cc_addresses || []],
];
let added = 0;
for (const [prefix, entries] of groups) {
for (const entry of entries) {
if (!entry?.address) continue;
const row = el("div", "recipient-line");
row.append(el("span", "", `${prefix} ${addressLabel(entry)}`));
const add = el("button", "address-contact-button");
add.type = "button";
add.title = `Add ${entry.address} to contacts`;
add.setAttribute("aria-label", add.title);
add.innerHTML = svgIcon("user-round");
add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); });
row.append(add);
host.append(row);
added += 1;
}
const recipients = [];
for (const [prefix, entries] of [["to", detail.to_addresses || []], ["cc", detail.cc_addresses || []]]) {
for (const entry of entries) if (entry?.address) recipients.push({ prefix, entry });
}
if (!recipients.length) {
host.append(el("div", "recipient-line", "to me"));
return;
}
const expanded = host.getAttribute("aria-expanded") === "true";
const limit = expanded ? recipients.length : readerRecipientDisplayLimit(host, recipients.length);
recipients.slice(0, limit).forEach(({ prefix, entry }) => appendReaderRecipientRow(host, prefix, entry));
const hidden = recipients.length - limit;
if (hidden > 0) {
const more = el("div", "recipient-line recipient-more");
more.append(el("span", "", `+${hidden}`));
host.append(more);
host.title = tr("show_full_addresses", "Show full addresses");
}
if (!added) host.append(el("div", "recipient-line", "to me"));
}
function setReaderAddressExpansion(expanded) {
function setReaderAddressExpansion(expanded, rerender = true) {
const host = $("#reader-to");
const meta = host?.closest(".message-meta");
if (!host || !meta) return;
meta.classList.toggle("addresses-expanded", Boolean(expanded));
host.setAttribute("aria-expanded", String(Boolean(expanded)));
host.title = expanded ? tr("collapse_addresses", "Collapse addresses") : tr("show_full_addresses", "Show full addresses");
if (rerender && state.currentDetail) renderReaderRecipients(state.currentDetail);
}
function toggleReaderAddressExpansion() {
@@ -508,7 +572,7 @@ function renderMessageDetail(detail) {
$("#reader-from").tabIndex = detail.from_address ? 0 : -1;
if (detail.from_address) $("#reader-from").setAttribute("role", "button");
else $("#reader-from").removeAttribute("role");
setReaderAddressExpansion(false);
setReaderAddressExpansion(false, false);
renderReaderRecipients(detail);
$("#reader-add-sender-contact").classList.toggle("hidden", !detail.from_address);
$("#reader-date").textContent = formatDate(detail.date, true);
+667 -65
View File
@@ -1,6 +1,25 @@
function parseRecipients(raw) {
return raw.split(",").map((value) => value.trim()).filter(Boolean);
const values = [];
let current = "";
let quoted = false;
let escaped = false;
let angleDepth = 0;
for (const char of String(raw || "")) {
if (escaped) { current += char; escaped = false; continue; }
if (char === "\\" && quoted) { current += char; escaped = true; continue; }
if (char === '"') { quoted = !quoted; current += char; continue; }
if (!quoted && char === "<") angleDepth += 1;
if (!quoted && char === ">" && angleDepth > 0) angleDepth -= 1;
if (char === "," && !quoted && angleDepth === 0) {
if (current.trim()) values.push(current.trim());
current = "";
continue;
}
current += char;
}
if (current.trim()) values.push(current.trim());
return values;
}
function recipientToken(input) {
@@ -137,20 +156,86 @@ function updateComposeMessageSize(extraFiles = []) {
return estimated;
}
function mergeComposeAttachments(...lists) {
const merged = new Map();
for (const list of lists) {
for (const attachment of Array.isArray(list) ? list : []) {
const id = String(attachment?.id || "");
if (!id) continue;
merged.set(id, { ...(merged.get(id) || {}), ...attachment, id: attachment.id || id });
}
}
return [...merged.values()];
}
async function refreshComposeAttachmentsFromServer(draftId = state.composeDraftId) {
if (!draftId) return state.composeAttachments;
try {
const draft = await request(`/drafts/${encodeURIComponent(draftId)}`, { method: "GET" });
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, draft.attachments);
renderComposeAttachments();
} catch (_) {
// Keep the local list if the draft endpoint is temporarily unavailable.
}
return state.composeAttachments;
}
function detachComposeAttachmentFromBody(id) {
const cid = String(id || "");
if (!cid) return false;
let changed = false;
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
if (String(node.dataset.rustboxCid || "") !== cid) return;
if (node === state.composeSelectedImage) setSelectedInlineImage(null);
node.remove();
changed = true;
});
if (!changed) return false;
syncComposeInlineAttachments();
scheduleDraftSave();
return true;
}
function renderComposeAttachments() {
const box = $("#compose-attachments");
box.replaceChildren();
const visibleAttachments = state.composeAttachments.filter((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)));
box.classList.toggle("hidden", !visibleAttachments.length);
for (const attachment of visibleAttachments) {
const attachments = mergeComposeAttachments(state.composeAttachments);
state.composeAttachments = attachments;
box.classList.toggle("hidden", !attachments.length);
for (const attachment of attachments) {
const id = String(attachment.id || "");
const filename = attachment.filename || "attachment";
const isImage = composeAttachmentIsImage(attachment);
const isInline = state.inlineAttachmentIds.has(id);
const item = el("div", "compose-attachment");
item.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
const remove = el("button", "");
item.dataset.attachmentId = id;
const kind = isInline ? (isImage ? "Inline image" : "Inline link") : (isImage ? "Image attachment" : "Attachment");
item.innerHTML = `${svgIcon("paperclip")}<span class="compose-attachment-kind">${kind}</span><span class="compose-attachment-label">${escapeHtml(filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
const embed = el("button", "compose-attachment-action");
embed.type = "button";
if (isInline) {
embed.title = `Keep ${filename} as attachment only`;
embed.setAttribute("aria-label", embed.title);
embed.innerHTML = svgIcon("paperclip");
embed.addEventListener("click", () => detachComposeAttachmentFromBody(id));
} else {
embed.title = isImage ? `Embed ${filename} in message` : `Insert a link to ${filename} in message`;
embed.setAttribute("aria-label", embed.title);
embed.innerHTML = svgIcon(isImage ? "image" : "link");
embed.addEventListener("mousedown", () => { state.composeInsertRange = captureEditorRange(); });
embed.addEventListener("click", () => {
insertComposeAttachment(attachment, state.composeInsertRange);
state.composeInsertRange = null;
});
}
const remove = el("button", "compose-attachment-remove");
remove.innerHTML = svgIcon("x");
remove.type = "button";
remove.setAttribute("aria-label", `Remove ${attachment.filename}`);
remove.setAttribute("aria-label", `Remove ${filename}`);
remove.addEventListener("click", () => deleteComposeAttachment(attachment.id));
item.append(remove);
item.append(embed, remove);
box.append(item);
}
if ($("#compose-pgp-mode")) updateComposePgpUi();
@@ -173,6 +258,120 @@ function composeAttachmentContentUrl(draftId, attachmentId) {
return `${apiPath(`/drafts/${encodeURIComponent(draftId)}/attachments/${encodeURIComponent(attachmentId)}/content`)}${query}`;
}
function composeImapAttachmentContentUrl(attachment, source = state.composeRemoteDraft) {
if (!source || attachment?.source !== "imap") return "";
const index = Number(attachment.source_index);
if (!Number.isInteger(index) || index < 0) return "";
const query = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : "";
return `${apiPath(`/messages/${encodeURIComponent(source.folder)}/${source.uid}/attachments/${index}`)}${query}`;
}
function composeAttachmentUrl(attachment, draftId = state.composeDraftId) {
if (attachment?.source === "imap") return composeImapAttachmentContentUrl(attachment);
return draftId ? composeAttachmentContentUrl(draftId, attachment.id) : "";
}
function composeAttachmentIsImage(attachment) {
const type = String(attachment?.content_type || attachment?.contentType || "").toLowerCase();
const filename = String(attachment?.filename || "").toLowerCase();
return /^image\/(png|jpeg|gif|webp)$/.test(type) || /\.(png|jpe?g|gif|webp)$/.test(filename);
}
function composeInlineAttachmentIds(editor = $("#compose-editor")) {
if (!editor) return new Set();
return new Set(
[...editor.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]")]
.map((node) => String(node.dataset.rustboxCid || ""))
.filter(Boolean),
);
}
function syncComposeInlineAttachments() {
const next = composeInlineAttachmentIds();
const changed = next.size !== state.inlineAttachmentIds.size || [...next].some((id) => !state.inlineAttachmentIds.has(id));
state.inlineAttachmentIds = next;
if (changed) renderComposeAttachments();
}
function composeAttachmentInsertionRange(range, editor = $("#compose-editor")) {
const target = range?.cloneRange?.() || document.createRange();
try {
if (range) {
const container = target.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
? target.commonAncestorContainer
: target.commonAncestorContainer.parentElement;
if (container !== editor && !editor.contains(container)) throw new Error("stale compose range");
// Never replace selected text or a previously selected inline image when adding another file.
target.collapse(false);
} else {
target.selectNodeContents(editor);
target.collapse(false);
}
} catch (_) {
target.selectNodeContents(editor);
target.collapse(false);
}
return target;
}
function insertComposeAttachment(attachment, insertionRange = null) {
if (!attachment?.id) return false;
if ($("#compose-mode")?.value !== "html") {
toast("Switch to HTML mode to embed attachments in the message", "warning");
return false;
}
const editor = $("#compose-editor");
const id = String(attachment.id);
const filename = attachment.filename || "attachment";
const contentUrl = composeAttachmentUrl(attachment, ensureDraftId());
if (!contentUrl) return false;
let inserted;
try {
editor.focus();
const range = composeAttachmentInsertionRange(insertionRange, editor);
if (composeAttachmentIsImage(attachment)) {
inserted = document.createElement("img");
inserted.src = contentUrl;
inserted.dataset.rustboxCid = id;
inserted.dataset.rustboxAlign = "inline";
inserted.alt = filename;
inserted.draggable = true;
inserted.style.maxWidth = "100%";
inserted.style.height = "auto";
} else {
inserted = document.createElement("a");
inserted.href = contentUrl;
inserted.dataset.rustboxCid = id;
inserted.dataset.rustboxAttachmentLink = "true";
inserted.textContent = filename;
}
range.insertNode(inserted);
const spacer = document.createTextNode("\u00a0");
inserted.after(spacer);
const after = document.createRange();
after.setStartAfter(spacer);
after.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(after);
state.inlineAttachmentIds.add(id);
if (composeAttachmentIsImage(attachment)) setSelectedInlineImage(inserted);
else setSelectedInlineImage(null);
} catch (error) {
state.inlineAttachmentIds.delete(id);
renderComposeAttachments();
toast(error?.message || `Could not embed ${filename}`, "error");
return false;
}
syncComposeInlineAttachments();
scheduleDraftSave();
return true;
}
function markdownInlineToSafeHtml(value) {
const source = String(value ?? "");
const code = [];
@@ -277,9 +476,16 @@ function serializeComposeHtml(editor = $("#compose-editor")) {
if (cid) image.setAttribute("src", `cid:${cid}`);
image.removeAttribute("data-rustbox-cid");
image.removeAttribute("data-rustbox-align");
image.removeAttribute("draggable");
image.classList.remove("compose-inline-image-selected");
if (!image.className) image.removeAttribute("class");
});
clone.querySelectorAll("a[data-rustbox-cid]").forEach((link) => {
const cid = link.dataset.rustboxCid || "";
if (cid) link.setAttribute("href", `cid:${cid}`);
link.removeAttribute("data-rustbox-cid");
link.removeAttribute("data-rustbox-attachment-link");
});
const html = clone.innerHTML.trim();
return html ? wrapComposeHtmlFont(html) : null;
}
@@ -290,24 +496,51 @@ function hydrateComposeHtml(html, attachments = state.composeAttachments, draftI
if (host.children.length === 1 && host.firstElementChild?.classList.contains("rustbox-compose-font")) {
host.innerHTML = host.firstElementChild.innerHTML;
}
if (!draftId) return host.innerHTML;
const ids = new Set((attachments || []).map((attachment) => String(attachment.id || "")).filter(Boolean));
const attachmentList = Array.isArray(attachments) ? attachments : [];
const byCid = new Map();
for (const attachment of attachmentList) {
const id = String(attachment?.id || "");
if (!id) continue;
byCid.set(id, attachment);
const sourceCid = String(attachment?.source_content_id || "");
if (sourceCid) byCid.set(sourceCid, attachment);
}
host.querySelectorAll("img[src]").forEach((image) => {
const src = image.getAttribute("src") || "";
if (!src.toLowerCase().startsWith("cid:")) return;
const cid = src.slice(4).replace(/^<|>$/g, "");
if (!ids.has(cid)) return;
state.inlineAttachmentIds.add(cid);
image.dataset.rustboxCid = cid;
image.setAttribute("src", composeAttachmentContentUrl(draftId, cid));
const attachment = byCid.get(cid);
if (!attachment) return;
const id = String(attachment.id);
const contentUrl = composeAttachmentUrl(attachment, draftId);
if (!contentUrl) return;
state.inlineAttachmentIds.add(id);
image.dataset.rustboxCid = id;
image.draggable = true;
image.setAttribute("src", contentUrl);
});
host.querySelectorAll("a[href]").forEach((link) => {
const href = link.getAttribute("href") || "";
if (!href.toLowerCase().startsWith("cid:")) return;
const cid = href.slice(4).replace(/^<|>$/g, "");
const attachment = byCid.get(cid);
if (!attachment) return;
const id = String(attachment.id);
const contentUrl = composeAttachmentUrl(attachment, draftId);
if (!contentUrl) return;
state.inlineAttachmentIds.add(id);
link.dataset.rustboxCid = id;
link.dataset.rustboxAttachmentLink = "true";
link.setAttribute("href", contentUrl);
});
return host.innerHTML;
}
function composeDraftPayload() {
const editor = $("#compose-editor");
return {
const payload = {
id: ensureDraftId(),
from_identity_id: $("#compose-from")?.value ? Number($("#compose-from").value) : null,
to: $("#compose-to").value,
cc: $("#compose-cc").value,
bcc: $("#compose-bcc").value,
@@ -324,6 +557,14 @@ function composeDraftPayload() {
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key")?.checked),
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys")?.checked),
};
if (state.composeRemoteDraft) {
payload.imap_source = { ...state.composeRemoteDraft };
payload.imap_attachments = state.composeAttachments
.filter((attachment) => attachment?.source === "imap")
.map((attachment) => ({ id: String(attachment.id), index: Number(attachment.source_index) }))
.filter((attachment) => attachment.id && Number.isInteger(attachment.index) && attachment.index >= 0);
}
return payload;
}
function readLocalDraftSnapshots() {
@@ -363,24 +604,159 @@ function clearLocalDraftSnapshot(draftId = state.composeDraftId) {
writeLocalDraftSnapshots(readLocalDraftSnapshots().filter((draft) => draft.id !== draftId));
}
async function refreshRemoteDraftAttachmentSources(folder, uid, uidValidity, freshAttachments = null) {
const remoteAttachments = state.composeAttachments.filter((attachment) => attachment?.source === "imap");
state.composeRemoteDraft = { folder, uid: Number(uid), uid_validity: Number(uidValidity) };
if (!remoteAttachments.length) return;
try {
let candidates = Array.isArray(freshAttachments) ? freshAttachments : null;
if (!candidates?.length) {
const fresh = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
candidates = Array.isArray(fresh.attachments) ? fresh.attachments : [];
}
const used = new Set();
for (const attachment of remoteAttachments) {
let index = candidates.findIndex((candidate, candidateIndex) =>
!used.has(candidateIndex) && String(candidate.source_content_id || "") === String(attachment.id || ""),
);
if (index < 0) {
index = candidates.findIndex((candidate, candidateIndex) =>
!used.has(candidateIndex)
&& candidate.checksum_sha256 === attachment.checksum_sha256
&& candidate.filename === attachment.filename
&& Number(candidate.size) === Number(attachment.size),
);
}
if (index < 0) continue;
used.add(index);
const candidate = candidates[index];
attachment.source_index = candidate.source_index;
attachment.source_content_id = candidate.source_content_id;
attachment.checksum_sha256 = candidate.checksum_sha256;
}
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
const id = String(node.dataset.rustboxCid || "");
const attachment = state.composeAttachments.find((item) => String(item.id || "") === id && item.source === "imap");
if (!attachment) return;
const url = composeAttachmentUrl(attachment);
if (!url) return;
if (node.tagName === "IMG") node.setAttribute("src", url);
else node.setAttribute("href", url);
});
} catch (error) {
console.warn("Could not refresh IMAP draft attachment sources", error);
}
}
async function saveCurrentDraft(silent = false, force = false, syncImap = false) {
if (!force && !$("#compose-dialog").open) return false;
if (!force && !state.composeDirty) return true;
if (!draftHasContent()) { state.composeDirty = false; clearLocalDraftSnapshot(); return true; }
const payload = composeDraftPayload();
payload.sync_imap = Boolean(syncImap);
const draftsFolder = syncImap
? (state.composeRemoteDraft?.folder || state.specialFolders?.drafts || (typeof detectSpecialFolder === "function" ? detectSpecialFolder("drafts") : null))
: null;
const releaseRealtimeSuppression = syncImap && draftsFolder && typeof suppressRealtimeNotificationsForFolder === "function"
? suppressRealtimeNotificationsForFolder(draftsFolder)
: () => { };
try {
await request("/drafts", { method: "POST", body: JSON.stringify(payload) });
const result = await request("/drafts", { method: "POST", body: JSON.stringify(payload) });
state.composeDirty = false;
if (syncImap) state.composeNeedsRemoteSync = false;
clearLocalDraftSnapshot();
$("#compose-status").textContent = `${trText("Draft saved")} ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
if (!silent) toast("Draft saved", "success");
if (syncImap && result?.remote_folder && result?.remote_uid && result?.remote_uid_validity) {
await refreshRemoteDraftAttachmentSources(result.remote_folder, result.remote_uid, result.remote_uid_validity, result.remote_attachments);
}
if (!syncImap && state.composeRemoteDraft) storeLocalDraftSnapshot();
else clearLocalDraftSnapshot();
updateComposeDraftDeleteAction();
const savedLabel = syncImap ? "Draft saved to IMAP" : "Draft saved";
$("#compose-status").textContent = `${trText(savedLabel)} ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
if (!silent) toast(savedLabel, "success");
return true;
} catch (error) {
storeLocalDraftSnapshot();
if (!silent) toast(error.message, "error");
return false;
} finally {
releaseRealtimeSuppression();
}
}
function updateComposeDraftDeleteAction() {
const button = $("#compose-delete-draft");
if (!button) return;
const canDelete = Boolean(state.composeDraftId || state.composeRemoteDraft);
button.classList.toggle("hidden", !canDelete);
button.disabled = Boolean(state.composeUploading);
}
function composeDraftDeleteUrl() {
if (!state.composeDraftId) return null;
const base = `/drafts/${encodeURIComponent(state.composeDraftId)}`;
const source = state.composeRemoteDraft;
if (!source) return base;
const params = new URLSearchParams({
imap_folder: String(source.folder || ""),
imap_uid: String(Number(source.uid) || 0),
imap_uid_validity: String(Number(source.uid_validity) || 0),
});
return `${base}?${params.toString()}`;
}
async function deleteComposeDraft() {
if (state.composeUploadController || state.composeUploading) {
toast("Wait for attachment upload to finish", "warning");
return;
}
const deleteUrl = composeDraftDeleteUrl();
if (!deleteUrl) return;
const confirmed = await appConfirm(
"Delete this draft? The IMAP draft and all locally uploaded draft attachments will be permanently removed.",
{ title: "Delete draft", confirmLabel: "Delete draft", danger: true },
);
if (!confirmed) return;
const button = $("#compose-delete-draft");
clearTimeout(state.draftSaveTimer);
clearTimeout(state.inlineImageCleanupTimer);
setBusy(button, true);
try {
await request(deleteUrl, { method: "DELETE" });
const deletedDraftId = state.composeDraftId;
const deletedRemoteDraft = state.composeRemoteDraft ? { ...state.composeRemoteDraft } : null;
clearLocalDraftSnapshot(deletedDraftId);
state.composeDraftId = null;
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = null;
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
state.pgpRecipientStatus = [];
$("#compose-pgp-passphrase").value = "";
renderComposeAttachments();
updateComposeMessageSize();
updateComposeDraftDeleteAction();
if ($("#compose-dialog").open) $("#compose-dialog").close();
if (deletedRemoteDraft) {
const before = state.messages.length;
state.messages = state.messages.filter((message) => !(
String(message.folder || "") === String(deletedRemoteDraft.folder || "")
&& Number(message.uid) === Number(deletedRemoteDraft.uid)
));
if (state.messages.length !== before) {
state.total = Math.max(0, Number(state.total || 0) - 1);
renderMessages();
}
}
toast("Draft deleted", "success");
await loadFolders();
} catch (error) {
toast(error.message, "error");
} finally {
setBusy(button, false);
}
}
@@ -392,6 +768,22 @@ function scheduleDraftSave() {
state.draftSaveTimer = setTimeout(() => saveCurrentDraft(true), Math.max(1000, Number(state.prefs.composeAutosaveMs || 3000)));
}
async function saveComposeDraftToImap() {
const button = $("#compose-save-draft");
if (state.composeUploadController) {
toast("Wait for attachment upload to finish", "warning");
return;
}
clearTimeout(state.draftSaveTimer);
await cleanupUnusedInlineAttachments();
setBusy(button, true);
try {
await saveCurrentDraft(false, true, true);
} finally {
setBusy(button, false);
}
}
function setComposeUploadProgress(visible, percent = 0, label = trText("Uploading…")) {
const box = $("#compose-upload-progress");
const bar = $("#compose-upload-bar");
@@ -407,14 +799,8 @@ async function uploadComposeFiles(files, options = {}) {
const incoming = [...(files || [])];
if (!incoming.length) return [];
if (state.composeUploading) return [];
const existing = new Set(state.composeAttachments.map((attachment) => `${String(attachment.filename || "")}\u0000${Number(attachment.size || 0)}`));
const selected = new Set();
const list = incoming.filter((file) => {
const key = `${String(file.name || "")}\u0000${Number(file.size || 0)}`;
if (existing.has(key) || selected.has(key)) return false;
selected.add(key);
return true;
});
// Treat every picker/drop entry as a separate attachment. Files may legitimately share a name and size.
const list = incoming;
if (!list.length) return [];
const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024));
const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024)));
@@ -447,14 +833,16 @@ async function uploadComposeFiles(files, options = {}) {
const overall = Math.round(((index + (percent / 100)) / list.length) * 100);
setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`);
}, controller.signal);
const uploaded = data.attachments || [];
const uploaded = Array.isArray(data.attachments) ? data.attachments : [];
added.push(...uploaded);
state.composeAttachments.push(...uploaded);
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, uploaded);
renderComposeAttachments();
state.composeDirty = true;
state.composeNeedsRemoteSync = true;
storeLocalDraftSnapshot();
}
await refreshComposeAttachmentsFromServer(id);
storeLocalDraftSnapshot();
setComposeUploadProgress(true, 100, trText("Upload complete"));
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450);
return added;
@@ -489,6 +877,7 @@ function setComposeMode(mode, persist = false) {
$$(".editor-toolbar [data-command], #editor-link, #editor-image, #editor-font, #editor-size, #editor-color").forEach((control) => { control.disabled = value !== "html"; });
const imageEdit = $("#editor-image-edit");
if (imageEdit) imageEdit.disabled = value !== "html" || !state.composeSelectedImage;
updateInlineImageActions();
$("#compose-markdown-preview")?.classList.toggle("hidden", value !== "markdown");
updateComposeMessageSize();
if (persist) savePreference("composeFormat", value);
@@ -632,14 +1021,151 @@ function captureEditorRange(editor = $("#compose-editor")) {
function restoreEditorRange(range, editor = $("#compose-editor")) {
editor.focus();
if (!range) return;
try {
const selection = window.getSelection();
const target = range?.cloneRange?.() || document.createRange();
if (!range) {
target.selectNodeContents(editor);
target.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
selection.addRange(target);
} catch (_) { }
}
function positionInlineImageActions() {
const image = state.composeSelectedImage;
const actions = $("#compose-image-actions");
const editor = $("#compose-editor");
if (!image?.isConnected || !actions || actions.classList.contains("hidden") || !editor) return;
const imageRect = image.getBoundingClientRect();
const editorRect = editor.getBoundingClientRect();
if (imageRect.bottom < editorRect.top || imageRect.top > editorRect.bottom) {
actions.style.visibility = "hidden";
return;
}
actions.style.visibility = "visible";
const actionsRect = actions.getBoundingClientRect();
const gap = 8;
const viewportWidth = window.visualViewport?.width || window.innerWidth;
const viewportHeight = window.visualViewport?.height || window.innerHeight;
let left = imageRect.left;
left = Math.max(gap, Math.min(left, viewportWidth - actionsRect.width - gap));
let top = imageRect.top - actionsRect.height - gap;
if (top < Math.max(gap, editorRect.top + gap)) top = imageRect.bottom + gap;
top = Math.max(gap, Math.min(top, viewportHeight - actionsRect.height - gap));
actions.style.left = `${Math.round(left)}px`;
actions.style.top = `${Math.round(top)}px`;
}
function positionInlineImageResizeHandles() {
const image = state.composeSelectedImage;
const overlay = $("#compose-image-resize");
const editor = $("#compose-editor");
if (!overlay || !editor || !image?.isConnected || $("#compose-mode")?.value !== "html") {
overlay?.classList.add("hidden");
return;
}
const imageRect = image.getBoundingClientRect();
const editorRect = editor.getBoundingClientRect();
if (imageRect.bottom < editorRect.top || imageRect.top > editorRect.bottom || imageRect.right < editorRect.left || imageRect.left > editorRect.right) {
overlay.classList.add("hidden");
return;
}
overlay.classList.remove("hidden");
overlay.style.left = `${Math.round(imageRect.left)}px`;
overlay.style.top = `${Math.round(imageRect.top)}px`;
overlay.style.width = `${Math.max(1, Math.round(imageRect.width))}px`;
overlay.style.height = `${Math.max(1, Math.round(imageRect.height))}px`;
}
function positionInlineImageUi() {
positionInlineImageActions();
positionInlineImageResizeHandles();
}
function beginInlineImageResize(event) {
const handle = event.target.closest?.("[data-image-resize]");
const image = state.composeSelectedImage;
const editor = $("#compose-editor");
if (!handle || !image?.isConnected || !editor) return;
event.preventDefault();
event.stopPropagation();
const direction = handle.dataset.imageResize || "se";
const sign = direction.includes("w") ? -1 : 1;
const startX = event.clientX;
const startWidth = image.getBoundingClientRect().width;
const editorWidth = Math.max(1, editor.clientWidth);
const alignment = inlineImageAlignment(image);
document.body.classList.add("compose-image-resizing");
const move = (moveEvent) => {
const delta = (moveEvent.clientX - startX) * sign;
const widthPx = Math.max(editorWidth * 0.10, Math.min(editorWidth, startWidth + delta));
const widthPercent = Math.max(10, Math.min(100, (widthPx / editorWidth) * 100));
applyInlineImageDisplay(image, Math.round(widthPercent * 10) / 10, alignment);
positionInlineImageUi();
};
const finish = () => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", finish);
window.removeEventListener("pointercancel", finish);
document.body.classList.remove("compose-image-resizing");
scheduleDraftSave();
positionInlineImageUi();
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", finish, { once: true });
window.addEventListener("pointercancel", finish, { once: true });
}
function moveInlineImageToRange(image, insertionRange) {
const editor = $("#compose-editor");
if (!image?.isConnected || !editor || !insertionRange) return false;
try {
const range = composeAttachmentInsertionRange(insertionRange, editor);
range.insertNode(image);
const spacer = document.createTextNode("\u00a0");
image.after(spacer);
const after = document.createRange();
after.setStartAfter(spacer);
after.collapse(true);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(after);
setSelectedInlineImage(image);
scheduleDraftSave();
requestAnimationFrame(positionInlineImageUi);
return true;
} catch (_) {
return false;
}
}
function updateInlineImageActions() {
const image = state.composeSelectedImage;
const actions = $("#compose-image-actions");
const visible = Boolean(image?.isConnected) && $("#compose-mode")?.value === "html";
actions?.classList.toggle("hidden", !visible);
const resize = $("#compose-image-resize");
resize?.classList.toggle("hidden", !visible);
if (!visible) return;
const label = actions?.querySelector(".compose-image-actions-label");
if (label) {
const attachment = state.composeAttachments.find((item) => String(item.id) === String(image.dataset.rustboxCid || ""));
label.textContent = attachment?.filename ? `Inline image · ${attachment.filename}` : "Inline image";
label.title = label.textContent;
}
const width = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : 100;
const alignment = inlineImageAlignment(image);
actions.querySelectorAll("[data-image-width]").forEach((button) => button.classList.toggle("active", Number(button.dataset.imageWidth) === Math.round(width)));
actions.querySelectorAll("[data-image-align]").forEach((button) => button.classList.toggle("active", button.dataset.imageAlign === alignment));
requestAnimationFrame(positionInlineImageUi);
}
function setSelectedInlineImage(image) {
if (state.composeSelectedImage && state.composeSelectedImage !== image) {
state.composeSelectedImage.classList?.remove("compose-inline-image-selected");
@@ -648,6 +1174,7 @@ function setSelectedInlineImage(image) {
state.composeSelectedImage?.classList.add("compose-inline-image-selected");
const edit = $("#editor-image-edit");
if (edit) edit.disabled = $("#compose-mode")?.value !== "html" || !state.composeSelectedImage;
updateInlineImageActions();
}
function inlineImageAlignment(image) {
@@ -688,6 +1215,7 @@ function applyInlineImageDisplay(image, widthPercent, alignment) {
image.style.marginLeft = "0";
image.style.marginRight = "0";
}
updateInlineImageActions();
}
function imageCropValues() {
@@ -843,26 +1371,11 @@ async function applyInlineImageEditor(event) {
function scheduleUnusedInlineAttachmentCleanup() {
clearTimeout(state.inlineImageCleanupTimer);
state.inlineImageCleanupTimer = setTimeout(cleanupUnusedInlineAttachments, 3500);
state.inlineImageCleanupTimer = setTimeout(syncComposeInlineAttachments, 250);
}
async function cleanupUnusedInlineAttachments() {
if (!state.composeDraftId || !state.inlineAttachmentIds.size) return;
const used = new Set($$("#compose-editor img[data-rustbox-cid]").map((image) => String(image.dataset.rustboxCid || "")).filter(Boolean));
const unused = [...state.inlineAttachmentIds].filter((id) => !used.has(String(id)));
if (!unused.length) return;
let changed = false;
for (const id of unused) {
try {
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" });
state.composeAttachments = state.composeAttachments.filter((attachment) => String(attachment.id) !== String(id));
state.inlineAttachmentIds.delete(String(id));
changed = true;
} catch (error) {
console.warn("Could not remove unused inline attachment", id, error);
}
}
if (changed) renderComposeAttachments();
syncComposeInlineAttachments();
}
function editorRangeFromPoint(x, y) {
@@ -890,28 +1403,65 @@ async function uploadInlineImage(files, insertionRange = null) {
const uploaded = await uploadComposeFiles(images);
if (!uploaded.length) return;
setComposeMode("html");
const editor = $("#compose-editor");
restoreEditorRange(insertionRange, editor);
const draftId = ensureDraftId();
let lastInsertedId = null;
let range = insertionRange;
for (const attachment of uploaded) {
const previewUrl = composeAttachmentContentUrl(draftId, attachment.id);
state.inlineAttachmentIds.add(String(attachment.id));
lastInsertedId = String(attachment.id);
const html = `<img src="${escapeHtml(previewUrl)}" data-rustbox-cid="${escapeHtml(attachment.id)}" data-rustbox-align="inline" alt="${escapeHtml(attachment.filename || "image")}" style="max-width:100%;height:auto">`;
document.execCommand("insertHTML", false, html);
}
if (lastInsertedId) {
const inserted = [...editor.querySelectorAll("img[data-rustbox-cid]")].reverse().find((image) => String(image.dataset.rustboxCid) === lastInsertedId);
if (inserted) setSelectedInlineImage(inserted);
insertComposeAttachment(attachment, range);
range = captureEditorRange();
}
syncComposeInlineAttachments();
scheduleDraftSave();
}
async function openImapDraftComposer(folder, uid) {
const draft = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
openCompose("new");
state.composeSignatureGeneration += 1;
state.composeDraftId = ensureDraftId();
state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 };
state.composeAttachments = mergeComposeAttachments(draft.attachments);
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = {
inReplyTo: draft.in_reply_to || null,
references: draft.references || null,
};
$("#compose-to").value = draft.to || "";
$("#compose-cc").value = draft.cc || "";
$("#compose-bcc").value = draft.bcc || "";
$("#compose-subject").value = draft.subject || "";
if (draft.from_identity_id && $("#compose-from")) $("#compose-from").value = String(draft.from_identity_id);
$("#compose-priority").value = ["high", "normal", "low"].includes(draft.priority) ? draft.priority : "normal";
$("#compose-mdn").checked = Boolean(draft.request_mdn);
$("#compose-pgp-mode").value = "none";
$("#compose-pgp-inline").checked = false;
$("#compose-pgp-attach-key").checked = false;
$("#compose-pgp-attach-recipient-keys").checked = false;
$("#compose-pgp-passphrase").value = "";
const identity = state.identities.find((item) => String(item.email || "").trim().toLowerCase() === String(draft.from_address || "").trim().toLowerCase());
if (identity && $("#compose-from")) $("#compose-from").value = String(identity.id);
updateComposePgpUi();
const mode = draft.compose_format === "html" && draft.html_body ? "html" : "text";
setComposeMode(mode);
const editor = $("#compose-editor");
if (mode === "html") editor.innerHTML = hydrateComposeHtml(draft.html_body || "", state.composeAttachments, state.composeDraftId);
else editor.textContent = draft.text_body || "";
renderComposeAttachments();
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
clearLocalDraftSnapshot(state.composeDraftId);
updateComposeDraftDeleteAction();
$("#compose-title").textContent = trText("Draft");
$("#compose-status").textContent = trText("Loaded from IMAP Drafts");
updateComposeMessageSize();
setTimeout(() => editor.focus(), 20);
}
function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
state.composeSignatureGeneration += 1;
state.composeDraftId = draft.id || null;
state.composeAttachments = Array.isArray(draft.attachments) ? draft.attachments : [];
state.composeRemoteDraft = draft.imap_source || null;
state.composeAttachments = mergeComposeAttachments(draft.attachments);
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = {
@@ -939,6 +1489,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
state.composeDirty = sourceLabel === "Browser recovery";
state.composeNeedsRemoteSync = sourceLabel === "Browser recovery";
if (state.composeDirty) storeLocalDraftSnapshot(); else clearLocalDraftSnapshot();
updateComposeDraftDeleteAction();
$("#compose-title").textContent = trText("Recovered draft");
$("#compose-status").textContent = `${sourceLabel}${draft.updated_at ? ` · ${draft.updated_at}` : ""}`;
setTimeout(() => editor.focus(), 20);
@@ -974,7 +1525,17 @@ async function recoverDraft() {
const id = selected.slice("local:".length);
const local = localDrafts.find((draft) => draft.id === id);
if (!local) return toast("Browser recovery snapshot is no longer available", "error");
applyRecoveredDraft(local, "Browser recovery");
let recovered = local;
try {
const server = await request(`/drafts/${encodeURIComponent(id)}`, { method: "GET" });
recovered = {
...local,
attachments: mergeComposeAttachments(local.attachments, server.attachments),
};
} catch (_) {
recovered = { ...local, attachments: mergeComposeAttachments(local.attachments) };
}
applyRecoveredDraft(recovered, "Browser recovery");
toast("Recovered unsaved browser draft", "success");
return;
}
@@ -1010,14 +1571,27 @@ function attachmentReminderNeeded() {
}
async function deleteComposeAttachment(id) {
const attachment = state.composeAttachments.find((item) => String(item.id) === String(id));
if (!attachment) return;
if (attachment.source === "imap") {
state.composeAttachments = state.composeAttachments.filter((item) => String(item.id) !== String(id));
state.inlineAttachmentIds.delete(String(id));
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
});
renderComposeAttachments();
scheduleDraftSave();
return;
}
if (!state.composeDraftId) return;
try {
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" });
state.composeAttachments = state.composeAttachments.filter((a) => a.id !== id);
state.inlineAttachmentIds.delete(String(id));
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid]").forEach((image) => {
if (image.dataset.rustboxCid === id) image.remove();
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
});
renderComposeAttachments();
scheduleDraftSave();
@@ -1058,6 +1632,23 @@ function toggleComposeFullscreen() {
setComposeFullscreen(!$("#compose-dialog").classList.contains("compose-fullscreen"));
}
function setComposeFieldsCollapsed(collapsed) {
const dialog = $("#compose-dialog");
const button = $("#compose-fields-toggle");
if (!dialog || !button) return;
const isCollapsed = Boolean(collapsed);
dialog.classList.toggle("compose-fields-collapsed", isCollapsed);
button.setAttribute("aria-expanded", isCollapsed ? "false" : "true");
button.textContent = trText(isCollapsed ? "Show fields" : "Hide fields");
button.title = trText(isCollapsed ? "Show addressing fields" : "Hide addressing fields");
}
function toggleComposeFields() {
const collapse = !$("#compose-dialog").classList.contains("compose-fields-collapsed");
setComposeFieldsCollapsed(collapse);
if (collapse) $("#compose-editor")?.focus();
}
function openCompose(mode = "new") {
if (mode === "forward" && state.prefs.forwardMode === "attachment") {
void forwardAsAttachment();
@@ -1069,15 +1660,18 @@ function openCompose(mode = "new") {
renderComposeIdentities();
if (detail && ["reply", "reply-all", "forward"].includes(mode)) selectIdentityForMessage(detail);
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;
setComposeUploadProgress(false);
renderComposeAttachments();
updateComposeDraftDeleteAction();
$("#compose-error").classList.add("hidden");
$("#compose-status").textContent = "";
$("#compose-title").textContent = trText(mode === "reply" ? "Reply" : mode === "reply-all" ? "Reply all" : mode === "forward" ? "Forward" : "New message");
@@ -1202,11 +1796,19 @@ async function sendMessage(event) {
pgp_passphrase: $("#compose-pgp-passphrase").value || "",
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key").checked),
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys").checked),
imap_source: state.composeRemoteDraft ? { ...state.composeRemoteDraft } : null,
imap_attachments: state.composeRemoteDraft
? state.composeAttachments
.filter((attachment) => attachment?.source === "imap")
.map((attachment) => ({ id: String(attachment.id), index: Number(attachment.source_index) }))
.filter((attachment) => attachment.id && Number.isInteger(attachment.index) && attachment.index >= 0)
: [],
}),
});
$("#compose-dialog").close();
clearLocalDraftSnapshot(state.composeDraftId);
state.composeDraftId = null;
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
+13
View File
@@ -239,8 +239,21 @@ function suppressRealtimeNotificationsForFolder(folder, accountId = state.accoun
};
}
function realtimeFolderIsDrafts(folderName) {
const name = String(folderName || "");
if (!name) return false;
const configured = state.specialFolders?.drafts;
if (configured && name.toLowerCase() === String(configured).toLowerCase()) return true;
const meta = state.folders.find((folder) => String(folder?.name || "").toLowerCase() === name.toLowerCase());
const attrs = Array.isArray(meta?.attributes) ? meta.attributes : String(meta?.attributes || "").split(/\s+/);
if (attrs.some((value) => String(value).trim().toLowerCase() === "\\drafts")) return true;
const leaf = name.split(/[\/.]/).filter(Boolean).at(-1)?.toLowerCase();
return leaf === "draft" || leaf === "drafts";
}
function realtimeNewMailNotificationsSuppressed(data, accountId) {
if (!state.realtimeNotificationsReady) return true;
if (realtimeFolderIsDrafts(data?.folder)) return true;
return (state.realtimeNotificationSuppressions.get(realtimeNotificationSuppressionKey(data?.folder, accountId)) || 0) > 0;
}
+73 -7
View File
@@ -132,11 +132,49 @@ function bindEvents() {
$("#logout-btn").addEventListener("click", logout);
$("#compose-btn").addEventListener("click", () => openCompose("new"));
$("#compose-fullscreen").addEventListener("click", toggleComposeFullscreen);
$("#compose-fields-toggle").addEventListener("click", toggleComposeFields);
$("#compose-to").addEventListener("invalid", () => setComposeFieldsCollapsed(false));
$("#compose-form").addEventListener("submit", sendMessage);
$("#compose-attach").addEventListener("click", () => $("#compose-files").click());
$("#compose-files").addEventListener("change", async (event) => { await uploadComposeFiles(event.target.files); event.target.value = ""; });
$("#compose-delete-draft").addEventListener("click", deleteComposeDraft);
$("#compose-save-draft").addEventListener("click", saveComposeDraftToImap);
$("#compose-attach").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-files").click(); });
$("#compose-files").addEventListener("change", async (event) => {
const files = [...event.target.files];
const htmlMode = $("#compose-mode").value === "html";
const images = htmlMode ? files.filter((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : [];
const attachments = htmlMode ? files.filter((file) => !/^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : files;
if (images.length) await uploadInlineImage(images, state.composeInsertRange);
if (attachments.length) await uploadComposeFiles(attachments);
state.composeInsertRange = null;
event.target.value = "";
});
$("#editor-image").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-images").click(); });
$("#editor-image-edit").addEventListener("click", () => openInlineImageEditor());
$("#compose-image-actions").addEventListener("click", (event) => {
const image = state.composeSelectedImage;
if (!image) return;
const widthButton = event.target.closest("[data-image-width]");
const alignButton = event.target.closest("[data-image-align]");
if (widthButton) {
applyInlineImageDisplay(image, Number(widthButton.dataset.imageWidth), inlineImageAlignment(image));
scheduleDraftSave();
return;
}
if (alignButton) {
const currentWidth = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : 100;
applyInlineImageDisplay(image, currentWidth, alignButton.dataset.imageAlign);
scheduleDraftSave();
}
});
$("#compose-image-more").addEventListener("click", () => openInlineImageEditor());
$("#compose-image-detach").addEventListener("click", () => {
const image = state.composeSelectedImage;
if (!image) return;
image.remove();
setSelectedInlineImage(null);
syncComposeInlineAttachments();
scheduleDraftSave();
});
$("#compose-images").addEventListener("change", async (event) => { await uploadInlineImage(event.target.files, state.composeInsertRange); state.composeInsertRange = null; event.target.value = ""; });
$("#image-edit-form").addEventListener("submit", applyInlineImageEditor);
$("#image-edit-reset").addEventListener("click", resetImageCropControls);
@@ -173,6 +211,9 @@ function bindEvents() {
updateComposeMessageSize();
}));
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); updateComposeMessageSize(); });
$("#compose-editor").addEventListener("scroll", positionInlineImageUi, { passive: true });
window.addEventListener("resize", positionInlineImageUi, { passive: true });
$("#compose-image-resize").addEventListener("pointerdown", beginInlineImageResize);
$("#compose-editor").addEventListener("click", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
setSelectedInlineImage(image || null);
@@ -192,14 +233,38 @@ function bindEvents() {
const range = captureEditorRange();
await uploadInlineImage(images, range);
});
$("#compose-editor").addEventListener("dragover", (event) => { if ([...event.dataTransfer.types].includes("Files")) event.preventDefault(); });
$("#compose-editor").addEventListener("dragstart", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
if (!image || !event.dataTransfer) return;
setSelectedInlineImage(image);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-rustbox-inline-image", image.dataset.rustboxCid || "");
});
$("#compose-editor").addEventListener("dragover", (event) => {
const types = [...(event.dataTransfer?.types || [])];
if (types.includes("Files") || types.includes("application/x-rustbox-inline-image")) {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = types.includes("Files") ? "copy" : "move";
}
});
$("#compose-editor").addEventListener("drop", async (event) => {
const files = [...event.dataTransfer.files];
const inlineCid = event.dataTransfer?.getData("application/x-rustbox-inline-image") || "";
const range = editorRangeFromPoint(event.clientX, event.clientY);
if (inlineCid) {
event.preventDefault();
const image = [...$("#compose-editor").querySelectorAll("img[data-rustbox-cid]")]
.find((candidate) => String(candidate.dataset.rustboxCid || "") === inlineCid);
if (image && range) moveInlineImageToRange(image, range);
return;
}
const files = [...(event.dataTransfer?.files || [])];
if (!files.length) return;
event.preventDefault();
const allImages = files.every((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type));
if ($("#compose-mode").value === "html" && allImages) await uploadInlineImage(files, editorRangeFromPoint(event.clientX, event.clientY));
else await uploadComposeFiles(files);
const htmlMode = $("#compose-mode").value === "html";
const images = htmlMode ? files.filter((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : [];
const attachments = htmlMode ? files.filter((file) => !/^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : files;
if (images.length) await uploadInlineImage(images, range);
if (attachments.length) await uploadComposeFiles(attachments);
});
$("#reply-btn").addEventListener("click", () => openCompose("reply"));
$("#reply-all-btn").addEventListener("click", () => openCompose("reply-all"));
@@ -259,6 +324,7 @@ function bindEvents() {
} catch (error) { toast(error.message, "error"); }
});
$("#load-more").addEventListener("click", async () => { state.page += 1; await loadMessages(false); });
$("#load-all").addEventListener("click", loadAllMessages);
$("#message-list").addEventListener("scroll", scheduleMessageListStateSave, { passive: true });
window.addEventListener("offline", () => setConnectivityState(false));
window.addEventListener("online", async () => {