max / goingson
6 files changed,
+290 insertions,
-100 deletions
| @@ -0,0 +1,174 @@ | |||
| 1 | + | //! Reply/forward compose-prefill construction. | |
| 2 | + | //! | |
| 3 | + | //! Building a reply or forward is domain logic — recipient assembly with | |
| 4 | + | //! own-address exclusion, `Re:`/`Fwd:` subject prefixing, and body quoting. | |
| 5 | + | //! It lives here in `core` (not the JS frontend) so the rules are uniform, | |
| 6 | + | //! unit-tested, and driven by the authoritative account list rather than a | |
| 7 | + | //! browser-side cache. | |
| 8 | + | ||
| 9 | + | /// The text parts of a compose prefill. Threading/account IDs are plumbed by | |
| 10 | + | /// the command that calls these functions. | |
| 11 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 12 | + | pub struct ComposePrefill { | |
| 13 | + | pub to: String, | |
| 14 | + | pub subject: String, | |
| 15 | + | pub body: String, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | /// Extract the bare address from `Name <addr@host>`, or return the trimmed input | |
| 19 | + | /// when there is no angle-bracket form. | |
| 20 | + | pub fn extract_email_address(addr: &str) -> &str { | |
| 21 | + | let trimmed = addr.trim(); | |
| 22 | + | if let Some(open) = trimmed.find('<') | |
| 23 | + | && let Some(rel_close) = trimmed[open + 1..].find('>') | |
| 24 | + | { | |
| 25 | + | return trimmed[open + 1..open + 1 + rel_close].trim(); | |
| 26 | + | } | |
| 27 | + | trimmed | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// Prefix `Re:` unless the subject already begins with it (case-insensitive), | |
| 31 | + | /// matching the old JS `/^Re:/i` guard. | |
| 32 | + | pub fn reply_subject(subject: &str) -> String { | |
| 33 | + | prefix_once(subject, "Re:") | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Prefix `Fwd:` unless the subject already begins with it (case-insensitive). | |
| 37 | + | pub fn forward_subject(subject: &str) -> String { | |
| 38 | + | prefix_once(subject, "Fwd:") | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | fn prefix_once(subject: &str, prefix: &str) -> String { | |
| 42 | + | let already = subject.len() >= prefix.len() | |
| 43 | + | && subject.is_char_boundary(prefix.len()) | |
| 44 | + | && subject[..prefix.len()].eq_ignore_ascii_case(prefix); | |
| 45 | + | if already { | |
| 46 | + | subject.to_string() | |
| 47 | + | } else { | |
| 48 | + | format!("{} {}", prefix, subject) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// Build the `To` line for a reply. | |
| 53 | + | /// | |
| 54 | + | /// - Plain reply: the original sender's address. | |
| 55 | + | /// - Reply-all: sender + every original `To` recipient, in order, de-duplicated, | |
| 56 | + | /// with the user's own addresses removed. `own_addresses` may be in any case. | |
| 57 | + | pub fn reply_recipients(from: &str, to: &str, own_addresses: &[String], reply_all: bool) -> String { | |
| 58 | + | if !reply_all { | |
| 59 | + | let sender = extract_email_address(from); | |
| 60 | + | return if sender.is_empty() { | |
| 61 | + | from.trim().to_string() | |
| 62 | + | } else { | |
| 63 | + | sender.to_string() | |
| 64 | + | }; | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | let own: std::collections::HashSet<String> = | |
| 68 | + | own_addresses.iter().map(|a| a.to_lowercase()).collect(); | |
| 69 | + | let mut result: Vec<String> = Vec::new(); | |
| 70 | + | ||
| 71 | + | let push_unique = |addr: &str, result: &mut Vec<String>| { | |
| 72 | + | if addr.is_empty() || own.contains(&addr.to_lowercase()) { | |
| 73 | + | return; | |
| 74 | + | } | |
| 75 | + | if !result.iter().any(|r| r == addr) { | |
| 76 | + | result.push(addr.to_string()); | |
| 77 | + | } | |
| 78 | + | }; | |
| 79 | + | ||
| 80 | + | push_unique(extract_email_address(from), &mut result); | |
| 81 | + | for part in to.split(',') { | |
| 82 | + | push_unique(extract_email_address(part.trim()), &mut result); | |
| 83 | + | } | |
| 84 | + | result.join(", ") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Build the quoted body for a reply: an attribution line followed by the | |
| 88 | + | /// original body with each line prefixed by `> `. | |
| 89 | + | pub fn quoted_reply_body(from: &str, date: &str, body: &str) -> String { | |
| 90 | + | let quoted: String = body | |
| 91 | + | .split('\n') | |
| 92 | + | .map(|l| format!("> {}", l)) | |
| 93 | + | .collect::<Vec<_>>() | |
| 94 | + | .join("\n"); | |
| 95 | + | format!("\n\nOn {}, {} wrote:\n>\n{}", date, from, quoted) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Build the body for a forwarded message: a header block followed by the | |
| 99 | + | /// original body verbatim. | |
| 100 | + | pub fn forward_body(from: &str, date: &str, subject: &str, to: &str, body: &str) -> String { | |
| 101 | + | format!( | |
| 102 | + | "\n\n---------- Forwarded message ----------\n\ | |
| 103 | + | From: {}\n\ | |
| 104 | + | Date: {}\n\ | |
| 105 | + | Subject: {}\n\ | |
| 106 | + | To: {}\n\n\ | |
| 107 | + | {}", | |
| 108 | + | from, date, subject, to, body | |
| 109 | + | ) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | #[cfg(test)] | |
| 113 | + | mod tests { | |
| 114 | + | use super::*; | |
| 115 | + | ||
| 116 | + | #[test] | |
| 117 | + | fn extract_handles_angle_brackets_and_plain() { | |
| 118 | + | assert_eq!(extract_email_address("Max <max@example.com>"), "max@example.com"); | |
| 119 | + | assert_eq!(extract_email_address(" plain@example.com "), "plain@example.com"); | |
| 120 | + | assert_eq!(extract_email_address("Name < spaced@example.com >"), "spaced@example.com"); | |
| 121 | + | assert_eq!(extract_email_address(""), ""); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | #[test] | |
| 125 | + | fn subject_prefix_is_idempotent_and_case_insensitive() { | |
| 126 | + | assert_eq!(reply_subject("Hello"), "Re: Hello"); | |
| 127 | + | assert_eq!(reply_subject("Re: Hello"), "Re: Hello"); | |
| 128 | + | assert_eq!(reply_subject("re: hello"), "re: hello"); | |
| 129 | + | assert_eq!(forward_subject("Hello"), "Fwd: Hello"); | |
| 130 | + | assert_eq!(forward_subject("Fwd: Hello"), "Fwd: Hello"); | |
| 131 | + | assert_eq!(forward_subject(""), "Fwd: "); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[test] | |
| 135 | + | fn plain_reply_targets_sender_only() { | |
| 136 | + | let to = reply_recipients("Alice <alice@x.com>", "me@x.com, bob@x.com", &[], false); | |
| 137 | + | assert_eq!(to, "alice@x.com"); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[test] | |
| 141 | + | fn reply_all_includes_recipients_minus_self_deduped() { | |
| 142 | + | let own = vec!["me@x.com".to_string()]; | |
| 143 | + | let to = reply_recipients( | |
| 144 | + | "Alice <alice@x.com>", | |
| 145 | + | "me@x.com, Bob <bob@x.com>, alice@x.com", | |
| 146 | + | &own, | |
| 147 | + | true, | |
| 148 | + | ); | |
| 149 | + | // sender first, then To recipients; own address dropped; alice not duplicated. | |
| 150 | + | assert_eq!(to, "alice@x.com, bob@x.com"); | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | #[test] | |
| 154 | + | fn reply_all_excludes_self_case_insensitively() { | |
| 155 | + | let own = vec!["Me@X.com".to_string()]; | |
| 156 | + | let to = reply_recipients("boss@x.com", "ME@x.com, peer@x.com", &own, true); | |
| 157 | + | assert_eq!(to, "boss@x.com, peer@x.com"); | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | #[test] | |
| 161 | + | fn quoted_reply_body_prefixes_each_line() { | |
| 162 | + | let body = quoted_reply_body("Alice <a@x.com>", "Mar 1", "line1\nline2"); | |
| 163 | + | assert_eq!(body, "\n\nOn Mar 1, Alice <a@x.com> wrote:\n>\n> line1\n> line2"); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | #[test] | |
| 167 | + | fn forward_body_has_header_block() { | |
| 168 | + | let body = forward_body("a@x.com", "Mar 1", "Hi", "b@x.com", "original"); | |
| 169 | + | assert!(body.contains("---------- Forwarded message ----------")); | |
| 170 | + | assert!(body.contains("From: a@x.com")); | |
| 171 | + | assert!(body.contains("Subject: Hi")); | |
| 172 | + | assert!(body.ends_with("original")); | |
| 173 | + | } | |
| 174 | + | } |
| @@ -36,6 +36,7 @@ pub mod contact; | |||
| 36 | 36 | pub mod date_parser; | |
| 37 | 37 | pub mod date_utils; | |
| 38 | 38 | pub mod day_planning; | |
| 39 | + | pub mod email_compose; | |
| 39 | 40 | pub mod email_id; | |
| 40 | 41 | pub mod email_sync; | |
| 41 | 42 | pub mod error; | |
| @@ -90,6 +91,10 @@ pub use import::{ | |||
| 90 | 91 | ImportItemData, ImportOptions, ImportParseResult, ImportProjectData, ImportTaskData, | |
| 91 | 92 | }; | |
| 92 | 93 | pub use email_id::deterministic_email_id; | |
| 94 | + | pub use email_compose::{ | |
| 95 | + | forward_body, forward_subject, quoted_reply_body, reply_recipients, reply_subject, | |
| 96 | + | ComposePrefill, | |
| 97 | + | }; | |
| 93 | 98 | pub use validation::Validate; | |
| 94 | 99 | ||
| 95 | 100 | /// A specialized `Result` type for core operations. |
| @@ -148,6 +148,8 @@ const api = { | |||
| 148 | 148 | listByProject: (projectId) => invoke('list_emails_for_project', { projectId }), | |
| 149 | 149 | listUnlinked: () => invoke('list_unlinked_emails'), // Emails not linked to any project | |
| 150 | 150 | get: (id) => invoke('get_email', { id }), | |
| 151 | + | buildReplyPrefill: (id, replyAll) => invoke('build_reply_prefill', { id, replyAll }), // Rust builds reply recipients/subject/quote | |
| 152 | + | buildForwardPrefill: (id) => invoke('build_forward_prefill', { id }), // Rust builds Fwd: subject + forwarded body | |
| 151 | 153 | fetchFullBody: (id) => invoke('fetch_email_full_body', { id }), // Lazy-load a body truncated at sync (JMAP >100KB) | |
| 152 | 154 | create: (input) => invoke('create_email', { input }), | |
| 153 | 155 | send: (input) => invoke('send_email', { input }), // SMTP send + save copy to local DB |
| @@ -859,79 +859,25 @@ | |||
| 859 | 859 | */ | |
| 860 | 860 | async function openReply(emailId, replyAll) { | |
| 861 | 861 | try { | |
| 862 | - | const email = await GoingsOn.api.emails.get(emailId); | |
| 863 | - | if (!email) return; | |
| 864 | - | ||
| 865 | - | // Determine the From account (match the account that received this email) | |
| 866 | - | const accountId = email.emailAccountId || ''; | |
| 867 | - | ||
| 868 | - | // Determine the To address | |
| 869 | - | let to; | |
| 870 | - | if (replyAll) { | |
| 871 | - | // Reply-All: sender + all recipients, minus our own address | |
| 872 | - | const accounts = GoingsOn.getEmailAccountsCache(); | |
| 873 | - | const ownAddresses = new Set(accounts.map(a => a.email.toLowerCase())); | |
| 874 | - | const allAddresses = []; | |
| 875 | - | ||
| 876 | - | // Add original sender | |
| 877 | - | const senderEmail = extractEmail(email.from); | |
| 878 | - | if (senderEmail && !ownAddresses.has(senderEmail.toLowerCase())) { | |
| 879 | - | allAddresses.push(senderEmail); | |
| 880 | - | } | |
| 881 | - | ||
| 882 | - | // Add original To recipients | |
| 883 | - | if (email.to) { | |
| 884 | - | email.to.split(',').map(a => extractEmail(a.trim())).forEach(addr => { | |
| 885 | - | if (addr && !ownAddresses.has(addr.toLowerCase()) && !allAddresses.includes(addr)) { | |
| 886 | - | allAddresses.push(addr); | |
| 887 | - | } | |
| 888 | - | }); | |
| 889 | - | } | |
| 890 | - | ||
| 891 | - | to = allAddresses.join(', '); | |
| 892 | - | } else { | |
| 893 | - | // Reply: just the sender | |
| 894 | - | to = extractEmail(email.from) || email.from; | |
| 895 | - | } | |
| 896 | - | ||
| 897 | - | // Build subject with Re: prefix (don't double-prefix) | |
| 898 | - | let subject = email.subject || ''; | |
| 899 | - | if (!subject.match(/^Re:/i)) { | |
| 900 | - | subject = 'Re: ' + subject; | |
| 901 | - | } | |
| 902 | - | ||
| 903 | - | // Build quoted body | |
| 904 | - | const date = new Date(email.receivedAt).toLocaleString(); | |
| 905 | - | const quotedLines = (email.body || '').split('\n').map(l => '> ' + l).join('\n'); | |
| 906 | - | const body = '\n\nOn ' + date + ', ' + email.from + ' wrote:\n>\n' + quotedLines; | |
| 907 | - | ||
| 908 | - | // Build References header chain | |
| 909 | - | let references = ''; | |
| 910 | - | if (email.messageId) { | |
| 911 | - | references = email.messageId; | |
| 912 | - | } | |
| 862 | + | // Rust builds the reply prefill: recipients (reply-all excludes our | |
| 863 | + | // own accounts + de-dupes), Re: subject, and quoted body. | |
| 864 | + | const pf = await GoingsOn.api.emails.buildReplyPrefill(emailId, !!replyAll); | |
| 865 | + | if (!pf) return; | |
| 866 | + | ||
| 867 | + | const prefill = { | |
| 868 | + | to: pf.to, | |
| 869 | + | subject: pf.subject, | |
| 870 | + | body: pf.body, | |
| 871 | + | inReplyTo: pf.inReplyTo || null, | |
| 872 | + | references: pf.references || null, | |
| 873 | + | threadId: pf.threadId || null, | |
| 874 | + | accountId: pf.accountId || '', | |
| 875 | + | }; | |
| 913 | 876 | ||
| 914 | - | // Open compose with reply context | |
| 915 | 877 | if (GoingsOn.touch?.isTouchDevice) { | |
| 916 | - | openComposeModal({ | |
| 917 | - | to, | |
| 918 | - | subject, | |
| 919 | - | body, | |
| 920 | - | inReplyTo: email.messageId || null, | |
| 921 | - | references: references || null, | |
| 922 | - | threadId: email.threadId || null, | |
| 923 | - | accountId, | |
| 924 | - | }); | |
| 878 | + | openComposeModal(prefill); | |
| 925 | 879 | } else { | |
| 926 | - | await GoingsOn.api.window.openCompose({ | |
| 927 | - | to, | |
| 928 | - | subject, | |
| 929 | - | body, | |
| 930 | - | inReplyTo: email.messageId || null, | |
| 931 | - | references: references || null, | |
| 932 | - | threadId: email.threadId || null, | |
| 933 | - | accountId, | |
| 934 | - | }); | |
| 880 | + | await GoingsOn.api.window.openCompose(prefill); | |
| 935 | 881 | } | |
| 936 | 882 | } catch (err) { | |
| 937 | 883 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to open reply'), 'error'); | |
| @@ -944,35 +890,16 @@ | |||
| 944 | 890 | */ | |
| 945 | 891 | async function openForward(emailId) { | |
| 946 | 892 | try { | |
| 947 | - | const email = await GoingsOn.api.emails.get(emailId); | |
| 948 | - | if (!email) return; | |
| 949 | - | ||
| 950 | - | const accountId = email.emailAccountId || ''; | |
| 951 | - | ||
| 952 | - | // Build subject with Fwd: prefix (don't double-prefix) | |
| 953 | - | let subject = email.subject || ''; | |
| 954 | - | if (!subject.match(/^Fwd:/i)) { | |
| 955 | - | subject = 'Fwd: ' + subject; | |
| 956 | - | } | |
| 893 | + | // Rust builds the Fwd: subject and forwarded-message body block. | |
| 894 | + | const pf = await GoingsOn.api.emails.buildForwardPrefill(emailId); | |
| 895 | + | if (!pf) return; | |
| 957 | 896 | ||
| 958 | - | // Build forwarded body | |
| 959 | - | const date = new Date(email.receivedAt).toLocaleString(); | |
| 960 | - | const body = '\n\n---------- Forwarded message ----------\n' | |
| 961 | - | + 'From: ' + email.from + '\n' | |
| 962 | - | + 'Date: ' + date + '\n' | |
| 963 | - | + 'Subject: ' + (email.subject || '') + '\n' | |
| 964 | - | + 'To: ' + (email.to || '') + '\n\n' | |
| 965 | - | + (email.body || ''); | |
| 897 | + | const prefill = { to: '', subject: pf.subject, body: pf.body, accountId: pf.accountId || '' }; | |
| 966 | 898 | ||
| 967 | 899 | if (GoingsOn.touch?.isTouchDevice) { | |
| 968 | - | openComposeModal({ to: '', subject, body, accountId }); | |
| 900 | + | openComposeModal(prefill); | |
| 969 | 901 | } else { | |
| 970 | - | await GoingsOn.api.window.openCompose({ | |
| 971 | - | to: '', | |
| 972 | - | subject, | |
| 973 | - | body, | |
| 974 | - | accountId, | |
| 975 | - | }); | |
| 902 | + | await GoingsOn.api.window.openCompose(prefill); | |
| 976 | 903 | } | |
| 977 | 904 | } catch (err) { | |
| 978 | 905 | GoingsOn.ui.showToast(GoingsOn.utils.getErrorMessage(err, 'Failed to forward email'), 'error'); | |
| @@ -1017,11 +944,6 @@ | |||
| 1017 | 944 | /** | |
| 1018 | 945 | * Extract bare email address from "Name <email>" format. | |
| 1019 | 946 | */ | |
| 1020 | - | function extractEmail(addr) { | |
| 1021 | - | if (!addr) return ''; | |
| 1022 | - | const match = addr.match(/<([^>]+)>/); | |
| 1023 | - | return match ? match[1] : addr.trim(); | |
| 1024 | - | } | |
| 1025 | 947 | ||
| 1026 | 948 | /** | |
| 1027 | 949 | * Render email HTML to a temp file and open in the system browser. |
| @@ -344,6 +344,91 @@ pub async fn get_email(state: State<'_, Arc<AppState>>, id: EmailId) -> Result<O | |||
| 344 | 344 | Ok(email.map(EmailResponse::from)) | |
| 345 | 345 | } | |
| 346 | 346 | ||
| 347 | + | /// Prefill for the compose window when replying or forwarding. | |
| 348 | + | /// | |
| 349 | + | /// All the domain logic (recipient assembly, `Re:`/`Fwd:` prefixing, quoting) | |
| 350 | + | /// is computed in Rust so the frontend just renders the result. | |
| 351 | + | #[derive(Debug, Serialize)] | |
| 352 | + | #[serde(rename_all = "camelCase")] | |
| 353 | + | pub struct ComposePrefillResponse { | |
| 354 | + | pub to: String, | |
| 355 | + | pub subject: String, | |
| 356 | + | pub body: String, | |
| 357 | + | pub in_reply_to: Option<String>, | |
| 358 | + | pub references: Option<String>, | |
| 359 | + | pub thread_id: Option<String>, | |
| 360 | + | /// Account to send from ("" when the source email has no account). | |
| 361 | + | pub account_id: String, | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | /// Format an email timestamp for a reply/forward attribution line, in local time. | |
| 365 | + | fn format_attribution_date(dt: chrono::DateTime<Utc>) -> String { | |
| 366 | + | dt.with_timezone(&chrono::Local) | |
| 367 | + | .format("%b %-d, %Y, %-I:%M %p") | |
| 368 | + | .to_string() | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | /// Builds the compose prefill for replying to an email. | |
| 372 | + | /// | |
| 373 | + | /// Reply-all assembles sender + original recipients, excluding the user's own | |
| 374 | + | /// account addresses (authoritative, from the account repo — not a browser | |
| 375 | + | /// cache) and de-duplicating. | |
| 376 | + | #[tauri::command] | |
| 377 | + | #[instrument(skip_all)] | |
| 378 | + | pub async fn build_reply_prefill( | |
| 379 | + | state: State<'_, Arc<AppState>>, | |
| 380 | + | id: EmailId, | |
| 381 | + | reply_all: bool, | |
| 382 | + | ) -> Result<ComposePrefillResponse, ApiError> { | |
| 383 | + | let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await? | |
| 384 | + | .or_not_found("email", id)?; | |
| 385 | + | ||
| 386 | + | let own_addresses: Vec<String> = state.email_accounts.list_by_user(DESKTOP_USER_ID).await? | |
| 387 | + | .into_iter() | |
| 388 | + | .map(|a| a.email_address) | |
| 389 | + | .collect(); | |
| 390 | + | ||
| 391 | + | let to = goingson_core::reply_recipients(&email.from, &email.to, &own_addresses, reply_all); | |
| 392 | + | let subject = goingson_core::reply_subject(&email.subject); | |
| 393 | + | let date = format_attribution_date(email.received_at); | |
| 394 | + | let body = goingson_core::quoted_reply_body(&email.from, &date, &email.body); | |
| 395 | + | ||
| 396 | + | Ok(ComposePrefillResponse { | |
| 397 | + | to, | |
| 398 | + | subject, | |
| 399 | + | body, | |
| 400 | + | in_reply_to: email.message_id.clone(), | |
| 401 | + | references: email.message_id, | |
| 402 | + | thread_id: email.thread_id, | |
| 403 | + | account_id: email.email_account_id.map(|a| a.to_string()).unwrap_or_default(), | |
| 404 | + | }) | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | /// Builds the compose prefill for forwarding an email. | |
| 408 | + | #[tauri::command] | |
| 409 | + | #[instrument(skip_all)] | |
| 410 | + | pub async fn build_forward_prefill( | |
| 411 | + | state: State<'_, Arc<AppState>>, | |
| 412 | + | id: EmailId, | |
| 413 | + | ) -> Result<ComposePrefillResponse, ApiError> { | |
| 414 | + | let email = state.emails.get_by_id(id, DESKTOP_USER_ID).await? | |
| 415 | + | .or_not_found("email", id)?; | |
| 416 | + | ||
| 417 | + | let subject = goingson_core::forward_subject(&email.subject); | |
| 418 | + | let date = format_attribution_date(email.received_at); | |
| 419 | + | let body = goingson_core::forward_body(&email.from, &date, &email.subject, &email.to, &email.body); | |
| 420 | + | ||
| 421 | + | Ok(ComposePrefillResponse { | |
| 422 | + | to: String::new(), | |
| 423 | + | subject, | |
| 424 | + | body, | |
| 425 | + | in_reply_to: None, | |
| 426 | + | references: None, | |
| 427 | + | thread_id: None, | |
| 428 | + | account_id: email.email_account_id.map(|a| a.to_string()).unwrap_or_default(), | |
| 429 | + | }) | |
| 430 | + | } | |
| 431 | + | ||
| 347 | 432 | /// Fetches the full body of a JMAP email whose body was truncated at sync | |
| 348 | 433 | /// (>100KB). Re-fetches via the provider, stores the full body, clears the | |
| 349 | 434 | /// truncation flag, and returns the body. If the email was not truncated, the |
| @@ -113,6 +113,8 @@ macro_rules! all_commands { | |||
| 113 | 113 | $crate::commands::list_emails, | |
| 114 | 114 | $crate::commands::list_emails_threaded, | |
| 115 | 115 | $crate::commands::get_email, | |
| 116 | + | $crate::commands::build_reply_prefill, | |
| 117 | + | $crate::commands::build_forward_prefill, | |
| 116 | 118 | $crate::commands::fetch_email_full_body, | |
| 117 | 119 | $crate::commands::open_email_in_browser, | |
| 118 | 120 | $crate::commands::create_email, |