1use crate::error::{ApiError, ApiResult};
2use crate::events::{ServerEvent, SyncProgress};
3use crate::state::AppState;
4use axum::extract::{Path, Query as AxumQuery, State};
5use axum::http::{header, HeaderMap, StatusCode};
6use axum::response::sse::{Event, KeepAlive, Sse};
7use axum::response::{IntoResponse, Response};
8use axum::Json;
9use ecr_core::account::{Account, AccountId};
10use ecr_core::doctor::Doctor;
11use ecr_core::message::{
12 Body, BodyFormat, Message, MessageId, PartId, Query, SyncReport, TagOp, Thread, ThreadId,
13};
14use ecr_core::revision::Revision;
15use ecr_store::store::{BodyOptions, MailStore};
16use futures::stream::Stream;
17use serde::{Deserialize, Serialize};
18
19#[derive(Serialize)]
20pub struct Page<T> {
21 pub revision: Revision,
22 pub total: usize,
23 pub items: Vec<T>,
24}
25
26#[derive(Debug, Deserialize)]
27pub struct ThreadQuery {
28 #[serde(default)]
29 pub q: String,
30 pub limit: Option<usize>,
31 pub offset: Option<usize>,
32}
33
34impl ThreadQuery {
35 fn to_query(&self) -> Query {
36 Query::new(self.q.clone())
37 .limit(self.limit.unwrap_or(Query::DEFAULT_LIMIT).clamp(1, 500))
38 .offset(self.offset.unwrap_or(0))
39 }
40}
41
42pub async fn health(State(state): State<AppState>) -> Json<Doctor> {
43 Json(state.store.doctor().await)
44}
45
46pub async fn revision(State(state): State<AppState>) -> ApiResult<Json<Revision>> {
47 Ok(Json(state.store.revision().await?))
48}
49
50pub async fn accounts(State(state): State<AppState>) -> ApiResult<Json<Vec<Account>>> {
51 Ok(Json(state.store.accounts().await?))
52}
53
54#[derive(Serialize)]
55pub struct AddressEntry {
56 pub name: Option<String>,
57 pub email: String,
58 pub source: &'static str,
59 pub count: usize,
60}
61
62pub async fn addresses(State(state): State<AppState>) -> ApiResult<Json<Vec<AddressEntry>>> {
64 let book = state.store.notmuch().address_book(0).await?;
65
66 Ok(Json(
67 book.ranked()
68 .into_iter()
69 .map(|entry| AddressEntry {
70 name: entry.address.name,
71 email: entry.address.email,
72 source: match entry.source {
73 ecr_store::address::Source::Recipient => "recipient",
74 ecr_store::address::Source::Sender => "sender",
75 },
76 count: entry.count,
77 })
78 .collect(),
79 ))
80}
81
82pub async fn tags(State(state): State<AppState>) -> ApiResult<Json<Vec<String>>> {
84 Ok(Json(state.store.notmuch().tags().await?))
85}
86
87const LIST_SCAN: usize = 2000;
89
90#[derive(Serialize)]
91pub struct Lists {
92 pub lists: Vec<ecr_store::notmuch::MailingList>,
93 pub searchable: bool,
97}
98
99pub async fn lists(State(state): State<AppState>) -> ApiResult<Json<Lists>> {
100 let notmuch = state.store.notmuch();
101 Ok(Json(Lists {
102 lists: notmuch.mailing_lists(LIST_SCAN).await?,
103 searchable: notmuch.indexes_list_id().await,
104 }))
105}
106
107const MAX_COUNT_QUERIES: usize = 200;
112
113#[derive(Deserialize)]
114pub struct CountsRequest {
115 pub queries: Vec<String>,
116}
117
118#[derive(Serialize)]
119pub struct CountsResponse {
120 pub counts: Vec<u64>,
123}
124
125pub async fn counts(
126 State(state): State<AppState>,
127 Json(request): Json<CountsRequest>,
128) -> ApiResult<Json<CountsResponse>> {
129 if request.queries.len() > MAX_COUNT_QUERIES {
130 return Err(ApiError::BadRequest(format!(
131 "at most {MAX_COUNT_QUERIES} queries per request, got {}",
132 request.queries.len()
133 )));
134 }
135
136 let counts = state.store.count_batch(&request.queries).await?;
137 Ok(Json(CountsResponse { counts }))
138}
139
140pub async fn threads(
141 State(state): State<AppState>,
142 headers: HeaderMap,
143 AxumQuery(params): AxumQuery<ThreadQuery>,
144) -> ApiResult<Response> {
145 let revision = state.store.revision().await?;
146
147 if let Some(etag) = headers
148 .get(header::IF_NONE_MATCH)
149 .and_then(|v| v.to_str().ok())
150 {
151 if etag == revision.etag() {
152 return Ok(StatusCode::NOT_MODIFIED.into_response());
153 }
154 }
155
156 let query = params.to_query();
157 let items = state.store.search_threads(&query).await?;
158 let total = state.store.count(&query).await?;
159
160 let etag = revision.etag();
161 let page = Page {
162 revision,
163 total,
164 items,
165 };
166
167 Ok(([(header::ETAG, etag)], Json(page)).into_response())
168}
169
170pub async fn thread(
171 State(state): State<AppState>,
172 Path(id): Path<String>,
173) -> ApiResult<Json<Thread>> {
174 let thread = state.store.thread(&ThreadId(id)).await?;
175 if thread.messages.is_empty() {
176 return Err(ApiError::NotFound("no such thread".to_string()));
177 }
178 Ok(Json(thread))
179}
180
181pub async fn message(
182 State(state): State<AppState>,
183 Path(id): Path<String>,
184) -> ApiResult<Json<Message>> {
185 Ok(Json(state.store.message(&MessageId(id)).await?))
186}
187
188#[derive(Debug, Deserialize)]
189pub struct BodyQuery {
190 #[serde(default)]
191 pub html: Option<bool>,
192 #[serde(default)]
193 pub remote: Option<bool>,
194}
195
196pub async fn body(
197 State(state): State<AppState>,
198 Path(id): Path<String>,
199 AxumQuery(params): AxumQuery<BodyQuery>,
200) -> ApiResult<Json<Body>> {
201 let options = BodyOptions {
202 format: if params.html.unwrap_or(true) {
203 BodyFormat::Html
204 } else {
205 BodyFormat::Text
206 },
207 allow_remote_resources: params.remote.unwrap_or(false),
208 };
209
210 Ok(Json(state.store.body(&MessageId(id), options).await?))
211}
212
213pub async fn part(
214 State(state): State<AppState>,
215 Path((id, part)): Path<(String, u32)>,
216) -> ApiResult<Response> {
217 let part = state.store.part(&MessageId(id), &PartId(part)).await?;
218
219 let disposition = match &part.meta.filename {
220 Some(name) => format!("attachment; filename=\"{}\"", sanitize_filename(name)),
221 None => "inline".to_string(),
222 };
223
224 Ok((
225 [
226 (header::CONTENT_TYPE, part.meta.content_type.clone()),
227 (header::CONTENT_DISPOSITION, disposition),
228 (
229 header::CACHE_CONTROL,
230 "private, max-age=31536000, immutable".to_string(),
231 ),
232 ],
233 part.bytes,
234 )
235 .into_response())
236}
237
238fn sanitize_filename(name: &str) -> String {
239 name.chars()
240 .filter(|c| !matches!(c, '"' | '\\' | '\r' | '\n'))
241 .collect()
242}
243
244#[derive(Debug, Deserialize)]
245pub struct TagRequest {
246 pub ops: Vec<TagOp>,
247}
248
249pub async fn tag(
250 State(state): State<AppState>,
251 Json(request): Json<TagRequest>,
252) -> ApiResult<Json<Revision>> {
253 reject_if_read_only(&state)?;
254
255 let ids: Vec<String> = request.ops.iter().map(|o| o.id.to_string()).collect();
256 let revision = state.store.tag(&request.ops).await?;
257 state.note_own_write(&revision).await;
258
259 state.events.publish(ServerEvent::TagsChanged {
260 revision: revision.clone(),
261 ids,
262 });
263
264 Ok(Json(revision))
265}
266
267#[derive(Debug, Default, Deserialize)]
268#[serde(default)]
269pub struct SyncRequest {
270 pub accounts: Vec<String>,
271}
272
273pub async fn sync(
274 State(state): State<AppState>,
275 body: Option<Json<SyncRequest>>,
276) -> ApiResult<Json<SyncReport>> {
277 reject_if_read_only(&state)?;
278
279 let accounts: Vec<AccountId> = body
280 .map(|Json(r)| r.accounts)
281 .unwrap_or_default()
282 .into_iter()
283 .map(AccountId)
284 .collect();
285
286 state.events.publish(ServerEvent::SyncStarted {
287 accounts: accounts.iter().map(|a| a.to_string()).collect(),
288 });
289
290 let progress = SyncProgress::new(state.events.clone());
291 let report = match state.store.sync(&accounts, &progress).await {
292 Ok(report) => report,
293 Err(err) => {
294 state.events.publish(ServerEvent::Error {
295 detail: err.to_string(),
296 });
297 return Err(err.into());
298 }
299 };
300
301 let revision = state.store.revision().await?;
302 state.events.publish(ServerEvent::SyncFinished {
303 new_messages: report.new_messages,
304 revision,
305 });
306
307 Ok(Json(report))
308}
309
310#[derive(Debug, Deserialize)]
311pub struct SendRequest {
312 pub account: String,
313 #[serde(flatten)]
314 pub draft: ecr_core::compose::Draft,
315}
316
317pub async fn send(
318 State(state): State<AppState>,
319 Json(request): Json<SendRequest>,
320) -> ApiResult<Json<SendResponse>> {
321 reject_if_read_only(&state)?;
322
323 let accounts = state.store.accounts().await?;
324 let account = accounts
325 .iter()
326 .find(|a| a.id.as_str() == request.account)
327 .ok_or_else(|| ApiError::BadRequest(format!("no account named {}", request.account)))?;
328
329 let raw = ecr_store::compose::build(account, &request.draft)
330 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
331
332 state.store.send(&account.id, &raw).await?;
333
334 Ok(Json(SendResponse {
335 bytes: raw.len(),
336 account: account.id.to_string(),
337 }))
338}
339
340#[derive(Serialize)]
341pub struct SendResponse {
342 pub bytes: usize,
343 pub account: String,
344}
345
346pub async fn events(
347 State(state): State<AppState>,
348) -> Sse<impl Stream<Item = Result<Event, std::convert::Infallible>>> {
349 use tokio_stream::wrappers::BroadcastStream;
350 use tokio_stream::StreamExt;
351
352 let stream = BroadcastStream::new(state.events.subscribe()).filter_map(|event| {
353 let event = event.ok()?;
354 Some(Ok(Event::default()
355 .event(event.name())
356 .json_data(&event)
357 .unwrap_or_else(|_| {
358 Event::default().data("serialization failed")
359 })))
360 });
361
362 Sse::new(stream).keep_alive(KeepAlive::default())
363}
364
365fn reject_if_read_only(state: &AppState) -> ApiResult<()> {
366 if state.read_only {
367 return Err(ApiError::BadRequest(
368 "the server is running in --read-only mode".to_string(),
369 ));
370 }
371 Ok(())
372}
373
374#[cfg(test)]
375mod tests {
376 use super::*;
377
378 #[test]
379 fn a_thread_query_clamps_an_absurd_limit() {
380 let params = ThreadQuery {
381 q: "tag:inbox".to_string(),
382 limit: Some(100_000),
383 offset: None,
384 };
385 assert_eq!(params.to_query().limit, 500);
386 }
387
388 #[test]
389 fn a_zero_limit_becomes_one_rather_than_returning_nothing() {
390 let params = ThreadQuery {
391 q: String::new(),
392 limit: Some(0),
393 offset: None,
394 };
395 assert_eq!(params.to_query().limit, 1);
396 }
397
398 #[test]
399 fn an_absent_limit_uses_the_default() {
400 let params = ThreadQuery {
401 q: String::new(),
402 limit: None,
403 offset: None,
404 };
405 assert_eq!(params.to_query().limit, Query::DEFAULT_LIMIT);
406 }
407
408 #[test]
409 fn a_filename_cannot_break_out_of_the_content_disposition_header() {
410 assert_eq!(
411 sanitize_filename("evil\";\r\nX-Injected: yes\".pdf"),
412 "evil;X-Injected: yes.pdf"
413 );
414 }
415
416 #[test]
417 fn an_ordinary_filename_survives() {
418 assert_eq!(sanitize_filename("report 2026.pdf"), "report 2026.pdf");
419 }
420}
421
422#[derive(Serialize)]
423pub struct ConfigFile {
424 pub path: String,
425 pub raw: String,
426}
427
428pub async fn config(State(state): State<AppState>) -> ApiResult<Json<ConfigFile>> {
431 let path = state.store.paths().settings_file();
432 let raw = match std::fs::read_to_string(&path) {
433 Ok(text) => text,
434 Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
435 Err(err) => return Err(ApiError::Internal(err.to_string())),
436 };
437
438 Ok(Json(ConfigFile {
439 path: path.display().to_string(),
440 raw,
441 }))
442}
443
444#[derive(Deserialize)]
445pub struct ConfigUpdate {
446 pub raw: String,
447}
448
449#[derive(Serialize)]
450pub struct ConfigRejected {
451 pub error: &'static str,
452 pub detail: String,
453 pub line: usize,
454 pub column: usize,
455}
456
457pub async fn save_config(
460 State(state): State<AppState>,
461 Json(update): Json<ConfigUpdate>,
462) -> Response {
463 if let Err(err) = update.raw.parse::<toml::Table>() {
464 let (line, column) = err
465 .span()
466 .map(|span| position(&update.raw, span.start))
467 .unwrap_or((1, 1));
468
469 return (
470 StatusCode::UNPROCESSABLE_ENTITY,
471 Json(ConfigRejected {
472 error: "invalid_toml",
473 detail: err.message().to_string(),
474 line,
475 column,
476 }),
477 )
478 .into_response();
479 }
480
481 let path = state.store.paths().settings_file();
482 if let Some(parent) = path.parent() {
483 if let Err(err) = std::fs::create_dir_all(parent) {
484 return ApiError::Internal(err.to_string()).into_response();
485 }
486 }
487
488 match write_atomically(&path, &update.raw) {
489 Ok(()) => (
490 StatusCode::OK,
491 Json(serde_json::json!({ "path": path.display().to_string() })),
492 )
493 .into_response(),
494 Err(err) => ApiError::Internal(err.to_string()).into_response(),
495 }
496}
497
498#[derive(Serialize)]
499pub struct ThemeListing {
500 pub dir: String,
501 pub presets: Vec<ThemeEntry>,
502}
503
504#[derive(Serialize)]
505pub struct ThemeEntry {
506 pub path: String,
508 pub name: String,
510 pub builtin: bool,
511}
512
513pub async fn themes(State(state): State<AppState>) -> ApiResult<Json<ThemeListing>> {
516 let dir = state.store.paths().themes_dir();
517 ecr_store::themes::seed(&dir).map_err(|e| ApiError::Internal(e.to_string()))?;
518
519 let builtin: std::collections::HashSet<&str> =
520 ecr_store::themes::PRESETS.iter().map(|(n, _)| *n).collect();
521
522 let mut presets = Vec::new();
523 let entries = std::fs::read_dir(&dir).map_err(|e| ApiError::Internal(e.to_string()))?;
524
525 for entry in entries.flatten() {
526 let path = entry.path();
527 if path.extension().and_then(|e| e.to_str()) != Some("toml") {
528 continue;
529 }
530 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
531 continue;
532 };
533
534 let name = std::fs::read_to_string(&path)
535 .ok()
536 .and_then(|raw| raw.parse::<toml::Table>().ok())
537 .and_then(|doc| doc.get("name")?.as_str().map(str::to_string))
538 .unwrap_or_else(|| stem.to_string());
539
540 presets.push(ThemeEntry {
541 path: format!("themes/{stem}.toml"),
542 name,
543 builtin: builtin.contains(stem),
544 });
545 }
546
547 presets.sort_by(|a, b| a.path.cmp(&b.path));
548
549 Ok(Json(ThemeListing {
550 dir: dir.display().to_string(),
551 presets,
552 }))
553}
554
555#[derive(Deserialize)]
556pub struct ThemeQuery {
557 pub path: String,
558}
559
560pub async fn theme(
564 State(state): State<AppState>,
565 AxumQuery(query): AxumQuery<ThemeQuery>,
566) -> ApiResult<Json<ConfigFile>> {
567 let path = state
568 .store
569 .paths()
570 .resolve_relative(&query.path)
571 .map_err(|e| ApiError::BadRequest(e.to_string()))?;
572
573 let raw = std::fs::read_to_string(&path).map_err(|err| match err.kind() {
574 std::io::ErrorKind::NotFound => ApiError::NotFound(format!("no theme at {}", query.path)),
575 _ => ApiError::Internal(err.to_string()),
576 })?;
577
578 Ok(Json(ConfigFile {
579 path: path.display().to_string(),
580 raw,
581 }))
582}
583
584#[derive(Deserialize)]
585pub struct ThemeUpdate {
586 pub path: String,
587 pub raw: String,
588}
589
590pub async fn save_theme(
591 State(state): State<AppState>,
592 Json(update): Json<ThemeUpdate>,
593) -> Response {
594 let path = match state.store.paths().resolve_relative(&update.path) {
595 Ok(path) => path,
596 Err(err) => return ApiError::BadRequest(err.to_string()).into_response(),
597 };
598
599 if let Err(err) = update.raw.parse::<toml::Table>() {
600 let (line, column) = err
601 .span()
602 .map(|span| position(&update.raw, span.start))
603 .unwrap_or((1, 1));
604
605 return (
606 StatusCode::UNPROCESSABLE_ENTITY,
607 Json(ConfigRejected {
608 error: "invalid_toml",
609 detail: err.message().to_string(),
610 line,
611 column,
612 }),
613 )
614 .into_response();
615 }
616
617 if let Some(parent) = path.parent() {
618 if let Err(err) = std::fs::create_dir_all(parent) {
619 return ApiError::Internal(err.to_string()).into_response();
620 }
621 }
622
623 match write_atomically(&path, &update.raw) {
624 Ok(()) => (
625 StatusCode::OK,
626 Json(serde_json::json!({ "path": path.display().to_string() })),
627 )
628 .into_response(),
629 Err(err) => ApiError::Internal(err.to_string()).into_response(),
630 }
631}
632
633fn write_atomically(path: &std::path::Path, contents: &str) -> std::io::Result<()> {
635 let temp = path.with_extension("toml.new");
636 std::fs::write(&temp, contents)?;
637 std::fs::rename(&temp, path)
638}
639
640fn position(text: &str, offset: usize) -> (usize, usize) {
641 let head = &text[..offset.min(text.len())];
642 let line = head.matches('\n').count() + 1;
643 let column = head.rsplit('\n').next().map(str::len).unwrap_or(0) + 1;
644 (line, column)
645}