1 parent
5dc9db0b1c
commit
83440f20e2
28 files changed
+1925
-213
No files matched your search
+703
-70
@@ -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(¤t_name, ¤t_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(¤t_name, ¤t_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())
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -75,6 +75,7 @@
|
||||
imapSyncInventoryPending: 0,
|
||||
calendarReminderTimer: null,
|
||||
composeDraftId: null,
|
||||
composeRemoteDraft: null,
|
||||
composeAttachments: [],
|
||||
composeDirty: false,
|
||||
composeNeedsRemoteSync: false,
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user