Skip to main content

max / goingson

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