Skip to main content

ecr_server/
routes.rs

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
62/// The address book, for recipient completion in the composer.
63pub 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
82/// Every tag in the database, for query completion.
83pub async fn tags(State(state): State<AppState>) -> ApiResult<Json<Vec<String>>> {
84    Ok(Json(state.store.notmuch().tags().await?))
85}
86
87/// How many recent messages the list scan reads headers from.
88const LIST_SCAN: usize = 2000;
89
90#[derive(Serialize)]
91pub struct Lists {
92    pub lists: Vec<ecr_store::notmuch::MailingList>,
93    /// False when `index.header.List` is unset, which makes `List:` unsearchable
94    /// and every row below useless. The client says so rather than showing rows
95    /// that match nothing.
96    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
107/// How many queries one request may ask about.
108///
109/// The sidebar sends a query per visible row, which is tens. A cap keeps a
110/// single request from turning into an unbounded amount of Xapian work.
111const 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    /// Positional, matching the request: duplicate queries stay cheap and the
121    /// client zips by index rather than re-deriving the key it sent.
122    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
428/// The settings file every client shares. Absent is not an error — a client
429/// that finds it empty writes the commented default into it.
430pub 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
457/// Written only if it parses, so a client that sends nonsense cannot leave the
458/// user with a settings file that no client can read back.
459pub 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    /// The value that goes in settings.toml, relative to the config dir.
507    pub path: String,
508    /// The display name from the file, falling back to the stem.
509    pub name: String,
510    pub builtin: bool,
511}
512
513/// Every theme on disk, seeded with the shipped presets on first call so a fresh
514/// install has something to pick from.
515pub 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
560/// Unlike the settings file, a theme that is not there is a real error: the file
561/// was named by settings.toml, so its absence is a broken link the user should
562/// see rather than an empty palette that silently does nothing.
563pub 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
633/// A settings file half-written by a crash is worse than one not written.
634fn 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}