Skip to main content

postcrate_core/http/routes/
emails.rs

1use axum::body::Body;
2use axum::extract::{Path, Query, State};
3use axum::http::{header, HeaderValue, StatusCode};
4use axum::response::{IntoResponse, Response};
5use axum::routing::{get, post};
6use axum::{Json, Router};
7
8use crate::db::emails::{EmailDetail, EmailSummary};
9use crate::error::{Error, Result};
10use crate::http::dto::{ListMessagesQuery, SearchBody};
11use crate::service::ServiceHandle;
12
13pub fn router() -> Router<ServiceHandle> {
14    Router::new()
15        .route("/messages", get(list))
16        .route("/messages/{id}", get(get_one).delete(delete_one))
17        .route("/messages/{id}/raw", get(get_raw))
18        .route(
19            "/messages/{id}/attachments/{aid}",
20            get(get_attachment),
21        )
22        .route("/messages/search", post(search))
23        .route("/messages/{id}/read", post(mark_read))
24        .route("/messages/{id}/pin", post(set_pin))
25        .route("/messages/{id}/star", post(set_star))
26        .route("/messages/{id}/note", post(set_note))
27        .route("/messages/{id}/tag", post(set_tag))
28        .route("/messages/{id}/release", post(release))
29}
30
31async fn list(
32    State(h): State<ServiceHandle>,
33    Query(q): Query<ListMessagesQuery>,
34) -> Result<Json<Vec<EmailSummary>>> {
35    let mb = q
36        .mailbox_id
37        .ok_or_else(|| Error::Invalid("mailboxId query param required".into()))?;
38    let limit = q.limit.unwrap_or(100).min(1000);
39    let offset = q.offset.unwrap_or(0);
40    Ok(Json(h.as_service().list_emails(&mb, limit, offset).await?))
41}
42
43async fn get_one(
44    State(h): State<ServiceHandle>,
45    Path(id): Path<String>,
46) -> Result<Json<EmailDetail>> {
47    Ok(Json(h.as_service().get_email(&id).await?))
48}
49
50async fn get_raw(
51    State(h): State<ServiceHandle>,
52    Path(id): Path<String>,
53) -> Result<Response> {
54    let bytes = h.as_service().get_email_raw(&id).await?;
55    let mut resp = (StatusCode::OK, bytes).into_response();
56    resp.headers_mut().insert(
57        header::CONTENT_TYPE,
58        HeaderValue::from_static("message/rfc822"),
59    );
60    Ok(resp)
61}
62
63async fn get_attachment(
64    State(h): State<ServiceHandle>,
65    Path((email_id, attachment_id)): Path<(String, String)>,
66) -> Result<Response> {
67    // We validate email_id existence by fetching detail (cheap) so an
68    // attachment id from a different email returns 404 instead of leaking.
69    let detail = h.as_service().get_email(&email_id).await?;
70    if !detail.attachments.iter().any(|a| a.id == attachment_id) {
71        return Err(Error::AttachmentNotFound(attachment_id));
72    }
73    let (bytes, name, ct) = h.as_service().get_attachment_blob(&attachment_id).await?;
74    let mut resp = Response::builder().status(StatusCode::OK).body(Body::from(bytes))?;
75    if let Some(ct) = ct {
76        if let Ok(v) = HeaderValue::from_str(&ct) {
77            resp.headers_mut().insert(header::CONTENT_TYPE, v);
78        }
79    } else {
80        resp.headers_mut().insert(
81            header::CONTENT_TYPE,
82            HeaderValue::from_static("application/octet-stream"),
83        );
84    }
85    if let Some(name) = name {
86        let disposition = format!("attachment; filename=\"{}\"", sanitize(&name));
87        if let Ok(v) = HeaderValue::from_str(&disposition) {
88            resp.headers_mut().insert(header::CONTENT_DISPOSITION, v);
89        }
90    }
91    Ok(resp)
92}
93
94async fn delete_one(
95    State(h): State<ServiceHandle>,
96    Path(id): Path<String>,
97) -> Result<Json<serde_json::Value>> {
98    h.as_service().delete_email(&id).await?;
99    Ok(Json(serde_json::json!({"deleted": true})))
100}
101
102async fn search(
103    State(h): State<ServiceHandle>,
104    Json(body): Json<SearchBody>,
105) -> Result<Json<Vec<EmailSummary>>> {
106    let limit = body.limit.unwrap_or(50).min(500);
107    Ok(Json(
108        h.as_service()
109            .search_emails(&body.q, body.mailbox_id.as_deref(), limit)
110            .await?,
111    ))
112}
113
114#[derive(serde::Deserialize)]
115struct ReadBody {
116    read: bool,
117}
118
119async fn mark_read(
120    State(h): State<ServiceHandle>,
121    Path(id): Path<String>,
122    Json(body): Json<ReadBody>,
123) -> Result<Json<serde_json::Value>> {
124    h.as_service().mark_read(&id, body.read).await?;
125    Ok(Json(serde_json::json!({"read": body.read})))
126}
127
128#[derive(serde::Deserialize)]
129struct PinBody {
130    pinned: bool,
131}
132
133async fn set_pin(
134    State(h): State<ServiceHandle>,
135    Path(id): Path<String>,
136    Json(body): Json<PinBody>,
137) -> Result<Json<serde_json::Value>> {
138    h.as_service().set_pinned(&id, body.pinned).await?;
139    Ok(Json(serde_json::json!({"pinned": body.pinned})))
140}
141
142#[derive(serde::Deserialize)]
143struct StarBody {
144    starred: bool,
145}
146
147async fn set_star(
148    State(h): State<ServiceHandle>,
149    Path(id): Path<String>,
150    Json(body): Json<StarBody>,
151) -> Result<Json<serde_json::Value>> {
152    h.as_service().set_starred(&id, body.starred).await?;
153    Ok(Json(serde_json::json!({"starred": body.starred})))
154}
155
156#[derive(serde::Deserialize)]
157struct NoteBody {
158    /// `null` clears the note.
159    note: Option<String>,
160}
161
162async fn set_note(
163    State(h): State<ServiceHandle>,
164    Path(id): Path<String>,
165    Json(body): Json<NoteBody>,
166) -> Result<Json<serde_json::Value>> {
167    h.as_service().set_note(&id, body.note.as_deref()).await?;
168    Ok(Json(serde_json::json!({"note": body.note})))
169}
170
171#[derive(serde::Deserialize)]
172struct TagBody {
173    /// `null` clears the tag.
174    tag: Option<String>,
175}
176
177async fn set_tag(
178    State(h): State<ServiceHandle>,
179    Path(id): Path<String>,
180    Json(body): Json<TagBody>,
181) -> Result<Json<serde_json::Value>> {
182    h.as_service().set_tag(&id, body.tag.as_deref()).await?;
183    Ok(Json(serde_json::json!({"tag": body.tag})))
184}
185
186#[derive(serde::Deserialize)]
187#[serde(rename_all = "camelCase")]
188struct ReleaseBody {
189    to: String,
190    relay: crate::RelayConfig,
191}
192
193async fn release(
194    State(h): State<ServiceHandle>,
195    Path(id): Path<String>,
196    Json(body): Json<ReleaseBody>,
197) -> Result<Json<serde_json::Value>> {
198    h.as_service().release_email(&id, &body.to, &body.relay).await?;
199    Ok(Json(serde_json::json!({"released": true, "to": body.to})))
200}
201
202impl From<axum::http::Error> for Error {
203    fn from(e: axum::http::Error) -> Self {
204        Error::Internal(e.to_string())
205    }
206}
207
208fn sanitize(s: &str) -> String {
209    s.chars()
210        .map(|c| if c == '"' || c == '\\' || c.is_control() { '_' } else { c })
211        .collect()
212}