Skip to main content

max / goingson

7.1 KB · 192 lines History Blame Raw
1 //! Email send service: build the IMAP client for mailbox mutations, and the
2 //! SMTP send flow (validate, credentials, send, persist the outgoing record).
3 //! Split out of the email command module.
4
5 use tracing::warn;
6
7 use super::*;
8 use super::contacts::create_implicit_contacts;
9
10 /// Result of attempting to build an IMAP client for an email's account.
11 ///
12 /// If the email has no associated account/UID, or the account uses JMAP,
13 /// returns `None`. If credential retrieval fails, logs a warning and
14 /// returns `None` so the caller can fall through to the local-only path.
15 pub(super) async fn build_imap_client(
16 state: &Arc<AppState>,
17 account_id: EmailAccountId,
18 operation: &str,
19 ) -> Option<(ImapClient, String)> {
20 let account = match state.email_accounts.get_by_id(account_id, DESKTOP_USER_ID).await {
21 Ok(Some(a)) => a,
22 _ => return None,
23 };
24
25 if uses_jmap(&account) {
26 return None;
27 }
28
29 let archive_folder = account
30 .archive_folder_name
31 .as_deref()
32 .unwrap_or("Archive")
33 .to_string();
34
35 let client = if uses_oauth_imap(&account) {
36 match get_valid_access_token(state, &account).await {
37 Ok(token) => ImapClient::with_oauth(
38 &account.imap_server,
39 account.imap_port as u16,
40 &account.email_address,
41 &token,
42 ),
43 Err(e) => {
44 warn!("Failed to get OAuth token for {}: {}", operation, e);
45 return None;
46 }
47 }
48 } else {
49 match get_account_password(&account) {
50 Ok(password) => ImapClient::with_password(&account, &password),
51 Err(e) => {
52 warn!("Failed to get password for {}: {}", operation, e);
53 return None;
54 }
55 }
56 };
57
58 Some((client, archive_folder))
59 }
60
61 /// Core send logic shared by `send_email` and `send_email_draft`.
62 ///
63 /// Flow: (1) validate inputs, (2) look up account + credentials,
64 /// (3) send via SMTP (OAuth or password), (4) save outgoing record to
65 /// the local database with `is_outgoing=true` and `source_folder="Sent"`.
66 pub(super) async fn send_email_inner(state: &Arc<AppState>, input: SendEmailInput) -> Result<SendEmailResponse, ApiError> {
67 if input.subject.trim().is_empty() {
68 return Err(ApiError::validation("subject", "Subject is required"));
69 }
70 if input.to_address.trim().is_empty() {
71 return Err(ApiError::validation("toAddress", "At least one recipient is required"));
72 }
73
74 let account = state.email_accounts
75 .get_by_id(input.account_id, DESKTOP_USER_ID)
76 .await?
77 .or_not_found("emailAccount", input.account_id)?;
78
79 // Read attachment files. Cap the combined size before buffering them all into
80 // memory (and base64-encoding ~1.33x on top) so a huge selection can't blow up
81 // memory or get rejected by the server after a long upload (Perf minor).
82 const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 25 * 1024 * 1024;
83 use crate::email::smtp_client::AttachmentFile;
84 let mut attachment_files = Vec::new();
85 let mut total_bytes: u64 = 0;
86 for path_str in &input.attachment_paths {
87 let path = std::path::Path::new(path_str);
88 if !path.is_file() {
89 return Err(ApiError::validation("attachmentPaths", format!("File not found: {}", path_str)));
90 }
91 let meta = tokio::fs::metadata(path).await
92 .map_api_err("Failed to read attachment file", ApiError::internal)?;
93 total_bytes = total_bytes.saturating_add(meta.len());
94 if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES {
95 return Err(ApiError::validation(
96 "attachmentPaths",
97 format!("Attachments exceed the {} MB total limit", MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024)),
98 ));
99 }
100 let data = tokio::fs::read(path).await
101 .map_api_err("Failed to read attachment file", ApiError::internal)?;
102 let filename = path.file_name()
103 .and_then(|n| n.to_str())
104 .unwrap_or("attachment")
105 .to_string();
106 let mime_type = goingson_core::mime_from_extension(&filename).to_string();
107 attachment_files.push(AttachmentFile { filename, mime_type, data });
108 }
109
110 let params = crate::email::smtp_client::SendParams {
111 to: &input.to_address,
112 cc: input.cc_address.as_deref(),
113 bcc: input.bcc_address.as_deref(),
114 subject: &input.subject,
115 body: &input.body,
116 in_reply_to: input.in_reply_to.as_deref(),
117 references: input.references.as_deref(),
118 attachments: attachment_files,
119 };
120
121 let message_id = if uses_jmap(&account) {
122 return Err(ApiError::bad_request("JMAP email sending not yet implemented - use IMAP account"));
123 } else if uses_oauth_imap(&account) {
124 let access_token = get_valid_access_token(state, &account).await?;
125 let smtp_client = SmtpClient::with_oauth(
126 &account.smtp_server,
127 account.smtp_port as u16,
128 &account.email_address,
129 &access_token,
130 );
131 smtp_client
132 .send_message(&params)
133 .await
134 .map_api_err("Failed to send email", ApiError::external_service)?
135 } else {
136 let password = get_account_password(&account)?;
137 let smtp_client = SmtpClient::with_password(&account, &password);
138 smtp_client
139 .send_message(&params)
140 .await
141 .map_api_err("Failed to send email", ApiError::external_service)?
142 };
143
144 // For replies, join the existing thread. For new emails, start a new thread.
145 let thread_id = input.thread_id.unwrap_or_else(|| message_id.clone());
146
147 // Capture recipient addresses before they're moved into new_email
148 let to_for_contacts = input.to_address.clone();
149 let cc_for_contacts = input.cc_address.clone();
150 let bcc_for_contacts = input.bcc_address.clone();
151
152 let new_email = NewEmailWithTracking {
153 project_id: input.project_id,
154 from_address: account.email_address.clone(),
155 to_address: input.to_address,
156 subject: input.subject,
157 body: input.body,
158 html_body: None,
159 is_read: true,
160 is_archived: false,
161 received_at: Some(Utc::now()),
162 message_id: Some(message_id.clone()),
163 in_reply_to: input.in_reply_to,
164 thread_id: Some(thread_id),
165 imap_uid: None,
166 source_folder: Some("Sent".to_string()),
167 email_account_id: Some(input.account_id),
168 is_outgoing: true,
169 attachment_meta: None,
170 body_truncated: false,
171 jmap_id: None,
172 };
173
174 let saved = state.emails.create_with_tracking(DESKTOP_USER_ID, new_email).await?;
175
176 // Auto-create implicit contacts for unknown recipients
177 let new_implicit_contacts = create_implicit_contacts(
178 state,
179 &to_for_contacts,
180 cc_for_contacts.as_deref(),
181 bcc_for_contacts.as_deref(),
182 &account.email_address,
183 ).await;
184
185 Ok(SendEmailResponse {
186 success: true,
187 message_id: Some(message_id),
188 saved_email: EmailResponse::from(saved),
189 new_implicit_contacts,
190 })
191 }
192