server: add ability to view inline image attachments

This commit is contained in:
Bill Thiede 2024-03-24 18:11:15 -07:00
parent c30cfec09d
commit c74cd66826
6 changed files with 410 additions and 120 deletions

View File

@ -16,7 +16,7 @@ use rocket::{
use rocket_cors::{AllowedHeaders, AllowedOrigins};
use server::{
error::ServerError,
graphql::{GraphqlSchema, Mutation, QueryRoot},
graphql::{attachment_bytes, Attachment, GraphqlSchema, Mutation, QueryRoot},
};
#[get("/refresh")]
@ -69,12 +69,34 @@ async fn show(nm: &State<Notmuch>, query: &str) -> Result<Json<ThreadSet>, Debug
Ok(Json(res))
}
struct PartResponder {
struct InlineAttachmentResponder(Attachment);
impl<'r, 'o: 'r> Responder<'r, 'o> for InlineAttachmentResponder {
fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'o> {
let mut resp = Response::build();
if let Some(filename) = self.0.filename {
info!("filename {:?}", filename);
resp.header(Header::new(
"Content-Disposition",
format!(r#"inline; filename="{}""#, filename),
));
}
if let Some(content_type) = self.0.content_type {
if let Some(ct) = ContentType::parse_flexible(&content_type) {
resp.header(ct);
}
}
resp.sized_body(self.0.bytes.len(), Cursor::new(self.0.bytes))
.ok()
}
}
struct DownloadPartResponder {
bytes: Vec<u8>,
filename: Option<String>,
}
impl<'r, 'o: 'r> Responder<'r, 'o> for PartResponder {
impl<'r, 'o: 'r> Responder<'r, 'o> for DownloadPartResponder {
fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'o> {
let mut resp = Response::build();
if let Some(filename) = self.filename {
@ -90,22 +112,49 @@ impl<'r, 'o: 'r> Responder<'r, 'o> for PartResponder {
}
}
#[get("/attachment/<id>/<idx>")]
async fn attachment(
_nm: &State<Notmuch>,
#[get("/view/attachment/<id>/<idx>/<_>")]
async fn view_attachment(
nm: &State<Notmuch>,
id: &str,
idx: usize,
) -> Result<PartResponder, Debug<NotmuchError>> {
let _idx = idx;
let _mid = if id.starts_with("id:") {
idx: &str,
) -> Result<InlineAttachmentResponder, Debug<ServerError>> {
let mid = if id.starts_with("id:") {
id.to_string()
} else {
format!("id:{}", id)
};
let bytes = Vec::new();
let filename = None;
info!("view attachment {mid} {idx}");
let idx: Vec<_> = idx
.split('.')
.map(|s| s.parse().expect("not a usize"))
.collect();
let attachment = attachment_bytes(nm, &mid, &idx)?;
// TODO: plumb Content-Type, or just create wrappers for serving the Attachment type
Ok(InlineAttachmentResponder(attachment))
}
#[get("/download/attachment/<id>/<idx>/<_>")]
async fn download_attachment(
nm: &State<Notmuch>,
id: &str,
idx: &str,
) -> Result<DownloadPartResponder, Debug<ServerError>> {
let mid = if id.starts_with("id:") {
id.to_string()
} else {
format!("id:{}", id)
};
info!("download attachment {mid} {idx}");
let idx: Vec<_> = idx
.split('.')
.map(|s| s.parse().expect("not a usize"))
.collect();
let attachment = attachment_bytes(nm, &mid, &idx)?;
// TODO(wathiede): use walk_attachments from graphql to fill this out
Ok(PartResponder { bytes, filename })
Ok(DownloadPartResponder {
bytes: attachment.bytes,
filename: attachment.filename,
})
}
#[get("/original/<id>/part/<part>")]
@ -113,7 +162,7 @@ async fn original_part(
nm: &State<Notmuch>,
id: &str,
part: usize,
) -> Result<PartResponder, Debug<NotmuchError>> {
) -> Result<DownloadPartResponder, Debug<NotmuchError>> {
let mid = if id.starts_with("id:") {
id.to_string()
} else {
@ -121,7 +170,7 @@ async fn original_part(
};
let meta = nm.show_part(&mid, part)?;
let res = nm.show_original_part(&mid, part)?;
Ok(PartResponder {
Ok(DownloadPartResponder {
bytes: res,
filename: meta.filename,
})
@ -201,7 +250,8 @@ async fn main() -> Result<(), Box<dyn Error>> {
graphql_query,
graphql_request,
graphiql,
attachment
view_attachment,
download_attachment,
],
)
.attach(cors)

View File

@ -1,3 +1,4 @@
use mailparse::MailParseError;
use thiserror::Error;
#[derive(Error, Debug)]
@ -6,4 +7,10 @@ pub enum ServerError {
NotmuchError(#[from] notmuch::NotmuchError),
#[error("flatten")]
FlattenError,
#[error("mail parse error")]
MailParseError(#[from] MailParseError),
#[error("IO error")]
IoError(#[from] std::io::Error),
#[error("attachement not found")]
PartNotFound,
}

View File

@ -1,3 +1,4 @@
const MAX_RAW_MESSAGE_SIZE: usize = 100_000;
use std::{
collections::HashMap,
fs::File,
@ -15,7 +16,7 @@ use memmap::MmapOptions;
use notmuch::Notmuch;
use rocket::time::Instant;
use crate::{linkify_html, sanitize_html};
use crate::{error::ServerError, linkify_html, sanitize_html};
/// # Number of seconds since the Epoch
pub type UnixTime = isize;
@ -25,6 +26,8 @@ pub type ThreadId = String;
const TEXT_PLAIN: &'static str = "text/plain";
const TEXT_HTML: &'static str = "text/html";
const IMAGE_JPEG: &'static str = "image/jpeg";
const IMAGE_PNG: &'static str = "image/png";
const MULTIPART_ALTERNATIVE: &'static str = "multipart/alternative";
const MULTIPART_MIXED: &'static str = "multipart/mixed";
const MULTIPART_RELATED: &'static str = "multipart/related";
@ -81,30 +84,14 @@ pub struct Message {
// Content-Transfer-Encoding: base64
// Content-ID: <f_lponoluo1>
// X-Attachment-Id: f_lponoluo1
#[derive(Debug, SimpleObject)]
#[derive(Default, Debug, SimpleObject)]
pub struct Attachment {
filename: String,
content_type: Option<String>,
content_id: Option<String>,
}
#[derive(Debug, Enum, Copy, Clone, Eq, PartialEq)]
enum DispositionType {
Inline,
Attachment,
}
impl FromStr for DispositionType {
type Err = String;
// Required method
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(match s {
"inline" => DispositionType::Inline,
"attachment" => DispositionType::Attachment,
c => return Err(format!("unknown disposition type: {c}")),
})
}
pub filename: Option<String>,
pub size: Option<usize>,
pub content_type: Option<String>,
pub content_id: Option<String>,
pub disposition: DispositionType,
pub bytes: Vec<u8>,
}
#[derive(Debug, SimpleObject)]
@ -345,18 +332,30 @@ impl QueryRoot {
.headers
.get_first_value("date")
.and_then(|d| mailparse::dateparse(&d).ok());
let body = match extract_body(&m)? {
Body::PlainText(PlainText { text, content_tree }) => Body::Html(Html {
html: format!(
r#"<p class="view-part-text-plain">{}</p>"#,
sanitize_html(&linkify_html(&text))?
),
content_tree: if debug_content_tree {
render_content_type_tree(&m)
let body = match extract_body(&m, &id)? {
Body::PlainText(PlainText { text, content_tree }) => {
let text = if text.len() > MAX_RAW_MESSAGE_SIZE {
format!(
"{}...\n\nMESSAGE WAS TRUNCATED @ {} bytes",
&text[..MAX_RAW_MESSAGE_SIZE],
MAX_RAW_MESSAGE_SIZE
)
} else {
content_tree
},
}),
text
};
Body::Html(Html {
html: format!(
r#"<p class="view-part-text-plain">{}</p>"#,
sanitize_html(&linkify_html(&text))?
),
content_tree: if debug_content_tree {
render_content_type_tree(&m)
} else {
content_tree
},
})
}
Body::Html(Html { html, content_tree }) => Body::Html(Html {
html: sanitize_html(&html)?,
content_tree: if debug_content_tree {
@ -428,13 +427,46 @@ impl Mutation {
}
}
fn extract_body(m: &ParsedMail) -> Result<Body, Error> {
pub type GraphqlSchema = Schema<QueryRoot, Mutation, EmptySubscription>;
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct Disposition {
pub r#type: DispositionType,
pub filename: Option<String>,
pub size: Option<usize>,
}
#[derive(Debug, Enum, Copy, Clone, Eq, PartialEq)]
pub enum DispositionType {
Inline,
Attachment,
}
impl From<mailparse::DispositionType> for DispositionType {
fn from(value: mailparse::DispositionType) -> Self {
match value {
mailparse::DispositionType::Inline => DispositionType::Inline,
mailparse::DispositionType::Attachment => DispositionType::Attachment,
dt => panic!("unhandled DispositionType {dt:?}"),
}
}
}
impl Default for DispositionType {
fn default() -> Self {
DispositionType::Attachment
}
}
fn extract_body(m: &ParsedMail, id: &str) -> Result<Body, Error> {
let mut part_addr = Vec::new();
part_addr.push(id.to_string());
let body = m.get_body()?;
let ret = match m.ctype.mimetype.as_str() {
TEXT_PLAIN => return Ok(Body::text(body)),
TEXT_HTML => return Ok(Body::html(body)),
MULTIPART_MIXED => extract_mixed(m),
MULTIPART_ALTERNATIVE => extract_alternative(m),
MULTIPART_MIXED => extract_mixed(m, &mut part_addr),
MULTIPART_ALTERNATIVE => extract_alternative(m, &mut part_addr),
_ => extract_unhandled(m),
};
if let Err(err) = ret {
@ -457,7 +489,29 @@ fn extract_unhandled(m: &ParsedMail) -> Result<Body, Error> {
// multipart/alternative defines multiple representations of the same message, and clients should
// show the fanciest they can display. For this program, the priority is text/html, text/plain,
// then give up.
fn extract_alternative(m: &ParsedMail) -> Result<Body, Error> {
fn extract_alternative(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Error> {
let handled_types = vec![
MULTIPART_ALTERNATIVE,
MULTIPART_MIXED,
MULTIPART_RELATED,
TEXT_HTML,
TEXT_PLAIN,
];
for sp in &m.subparts {
if sp.ctype.mimetype.as_str() == MULTIPART_ALTERNATIVE {
return extract_alternative(sp, part_addr);
}
}
for sp in &m.subparts {
if sp.ctype.mimetype.as_str() == MULTIPART_MIXED {
return extract_related(sp, part_addr);
}
}
for sp in &m.subparts {
if sp.ctype.mimetype.as_str() == MULTIPART_RELATED {
return extract_related(sp, part_addr);
}
}
for sp in &m.subparts {
if sp.ctype.mimetype.as_str() == TEXT_HTML {
let body = sp.get_body()?;
@ -470,26 +524,23 @@ fn extract_alternative(m: &ParsedMail) -> Result<Body, Error> {
return Ok(Body::text(body));
}
}
for sp in &m.subparts {
if sp.ctype.mimetype.as_str() == MULTIPART_RELATED {
return extract_related(sp);
}
}
Err(format!(
"extract_alternative failed to find suitable subpart, searched: {:?}",
vec![TEXT_HTML, TEXT_PLAIN]
handled_types
)
.into())
}
// multipart/mixed defines multiple types of context all of which should be presented to the user
// 'serially'.
fn extract_mixed(m: &ParsedMail) -> Result<Body, Error> {
fn extract_mixed(m: &ParsedMail, part_addr: &mut Vec<String>) -> Result<Body, Error> {
let handled_types = vec![
MULTIPART_ALTERNATIVE,
MULTIPART_RELATED,
TEXT_HTML,
TEXT_PLAIN,
IMAGE_JPEG,
IMAGE_PNG,
];
let mut unhandled_types: Vec<_> = m
.subparts
@ -498,33 +549,64 @@ fn extract_mixed(m: &ParsedMail) -> Result<Body, Error> {
.filter(|mt| !handled_types.contains(&mt))
.collect();
unhandled_types.sort();
warn!("{MULTIPART_MIXED} contains the following unhandled mimetypes {unhandled_types:?}");
for sp in &m.subparts {
if sp.ctype.mimetype.as_str() == MULTIPART_ALTERNATIVE {
return extract_alternative(sp);
}
if !unhandled_types.is_empty() {
warn!("{MULTIPART_MIXED} contains the following unhandled mimetypes {unhandled_types:?}");
}
for sp in &m.subparts {
if sp.ctype.mimetype == MULTIPART_RELATED {
return extract_related(sp);
}
}
for sp in &m.subparts {
let body = sp.get_body()?;
let mut parts = Vec::new();
for (idx, sp) in m.subparts.iter().enumerate() {
part_addr.push(idx.to_string());
match sp.ctype.mimetype.as_str() {
TEXT_PLAIN => return Ok(Body::text(body)),
TEXT_HTML => return Ok(Body::html(body)),
MULTIPART_RELATED => parts.push(extract_related(sp, part_addr)?),
MULTIPART_ALTERNATIVE => parts.push(extract_alternative(sp, part_addr)?),
TEXT_PLAIN => parts.push(Body::text(sp.get_body()?)),
TEXT_HTML => parts.push(Body::html(sp.get_body()?)),
IMAGE_JPEG | IMAGE_PNG => {
let filename = {
let pcd = sp.get_content_disposition();
pcd.params
.get("filename")
.map(|s| s.clone())
.unwrap_or("".to_string())
};
parts.push(Body::html(format!(
r#"<img src="/view/attachment/{}/{}/{filename}">"#,
part_addr[0],
part_addr
.iter()
.skip(1)
.map(|i| i.to_string())
.collect::<Vec<_>>()
.join(".")
)))
}
_ => (),
}
part_addr.pop();
}
Err(format!(
"extract_mixed failed to find suitable subpart, searched: {:?}",
handled_types
)
.into())
Ok(flatten_body_parts(&parts))
}
fn extract_related(m: &ParsedMail) -> Result<Body, Error> {
fn flatten_body_parts(parts: &[Body]) -> Body {
let html = parts
.iter()
.map(|p| match p {
Body::PlainText(PlainText { text, .. }) => format!(
r#"<p class="view-part-text-plain">{}</p>"#,
linkify_html(&text)
),
Body::Html(Html { html, .. }) => html.clone(),
Body::UnhandledContentType(UnhandledContentType { text }) => {
format!(r#"<p class="view-part-unhandled">{text}</p>"#)
}
})
.collect::<Vec<_>>()
.join("\n");
info!("flatten_body_parts {} {html}", parts.len());
Body::html(html)
}
fn extract_related(m: &ParsedMail, _part_addr: &mut Vec<String>) -> Result<Body, Error> {
// TODO(wathiede): collect related things and change return type to new Body arm.
let handled_types = vec![TEXT_HTML, TEXT_PLAIN];
let mut unhandled_types: Vec<_> = m
@ -555,42 +637,69 @@ fn extract_related(m: &ParsedMail) -> Result<Body, Error> {
.into())
}
fn walk_attachments<T, F: Fn(&ParsedMail, &[usize]) -> Option<T>>(
m: &ParsedMail,
visitor: F,
) -> Option<T> {
let mut cur_addr = Vec::new();
for (idx, sp) in m.subparts.iter().enumerate() {
cur_addr.push(idx);
let val = visitor(sp, &cur_addr);
if val.is_some() {
return val;
}
cur_addr.pop();
}
None
}
// TODO(wathiede): make this walk_attachments that takes a closure.
// Then implement one closure for building `Attachment` and imlement another that can be used to
// get the bytes for serving attachments of HTTP
fn extract_attachments(m: &ParsedMail) -> Result<Vec<Attachment>, Error> {
let mut attachements = Vec::new();
let mut attachments = Vec::new();
for sp in &m.subparts {
for h in &sp.headers {
if h.get_key() == "Content-Disposition" {
let v = h.get_value();
if let Some(idx) = v.find(";") {
let dt = &v[..idx];
match DispositionType::from_str(dt) {
Ok(DispositionType::Attachment) => {
attachements.push(Attachment {
filename: get_attachment_filename(&v).to_string(),
content_type: get_content_type(&sp.headers),
content_id: get_content_id(&sp.headers),
});
}
Ok(DispositionType::Inline) => continue,
Err(e) => {
warn!("failed to parse Content-Disposition type '{}'", e);
continue;
}
};
} else {
warn!("header has Content-Disposition missing ';'");
continue;
}
}
if let Some(attachment) = extract_attachment(sp) {
attachments.push(attachment);
}
}
Ok(attachements)
Ok(attachments)
}
fn extract_attachment(m: &ParsedMail) -> Option<Attachment> {
let pcd = m.get_content_disposition();
// TODO: do we need to handle empty filename attachments, or should we change the definition of
// Attachment::filename?
let Some(filename) = pcd.params.get("filename").map(|f| f.clone()) else {
return None;
};
// TODO: grab this from somewhere
let content_id = None;
let bytes = match m.get_body_raw() {
Ok(bytes) => bytes,
Err(err) => {
error!("failed to get body for attachment: {err}");
return None;
}
};
return Some(Attachment {
disposition: pcd.disposition.into(),
filename: Some(filename),
size: pcd
.params
.get("size")
.map(|s| s.parse().unwrap_or_default()),
// TODO: what is the default for ctype?
// TODO: do we want to use m.ctype.params for anything?
content_type: Some(m.ctype.mimetype.clone()),
content_id,
bytes,
});
}
fn get_attachment_filename(header_value: &str) -> &str {
info!("get_attachment_filename {header_value}");
// Strip last "
let v = &header_value[..header_value.len() - 1];
if let Some(idx) = v.rfind('"') {
@ -625,6 +734,21 @@ fn get_content_id<'a>(headers: &[MailHeader<'a>]) -> Option<String> {
fn render_content_type_tree(m: &ParsedMail) -> String {
const WIDTH: usize = 4;
const SKIP_HEADERS: [&str; 4] = [
"Authentication-Results",
"DKIM-Signature",
"Received",
"Received-SPF",
];
fn render_ct_rec(m: &ParsedMail, depth: usize) -> String {
let mut parts = Vec::new();
let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
parts.push(msg);
for sp in &m.subparts {
parts.push(render_ct_rec(sp, depth + 1))
}
parts.join("\n")
}
fn render_rec(m: &ParsedMail, depth: usize) -> String {
let mut parts = Vec::new();
let msg = format!("{} {}", "-".repeat(depth * WIDTH), m.ctype.mimetype);
@ -639,7 +763,14 @@ fn render_content_type_tree(m: &ParsedMail) -> String {
if !m.headers.is_empty() {
parts.push(format!("{indent} == headers =="));
for h in &m.headers {
parts.push(format!("{indent} {}: {}", h.get_key(), h.get_value()));
if h.get_key().starts_with('X') {
continue;
}
if SKIP_HEADERS.contains(&h.get_key().as_str()) {
continue;
}
parts.push(format!("{indent} {}: {}", h.get_key_ref(), h.get_value()));
}
}
for sp in &m.subparts {
@ -647,11 +778,14 @@ fn render_content_type_tree(m: &ParsedMail) -> String {
}
parts.join("\n")
}
render_rec(m, 1)
format!(
"Outline:\n{}\n\nDetailed:\n{}\n\nNot showing headers:\n {}\n X.*",
render_ct_rec(m, 1),
render_rec(m, 1),
SKIP_HEADERS.join("\n ")
)
}
pub type GraphqlSchema = Schema<QueryRoot, Mutation, EmptySubscription>;
fn email_addresses(path: &str, m: &ParsedMail, header_name: &str) -> Result<Vec<Email>, Error> {
let mut addrs = Vec::new();
for header_value in m.headers.get_all_values(header_name) {
@ -694,3 +828,28 @@ fn email_addresses(path: &str, m: &ParsedMail, header_name: &str) -> Result<Vec<
}
Ok(addrs)
}
pub fn attachment_bytes(nm: &Notmuch, id: &str, idx: &[usize]) -> Result<Attachment, ServerError> {
let files = nm.files(id)?;
let Some(path) = files.first() else {
warn!("failed to find files for message {id}");
return Err(ServerError::PartNotFound);
};
let file = File::open(&path)?;
let mmap = unsafe { MmapOptions::new().map(&file)? };
let m = parse_mail(&mmap)?;
if let Some(attachment) = walk_attachments(&m, |sp, cur_idx| {
info!("checking {cur_idx:?}=={idx:?}");
if cur_idx == idx {
let attachment = extract_attachment(&sp).unwrap_or(Attachment {
..Attachment::default()
});
return Some(attachment);
}
None
}) {
return Ok(attachment);
}
Err(ServerError::PartNotFound)
}

View File

@ -33,7 +33,6 @@ thiserror = "1.0.50"
seed_hooks = { git = "https://github.com/wathiede/styles_hooks", package = "seed_hooks", branch = "main" }
gloo-net = { version = "0.4.0", features = ["json", "serde_json"] }
[package.metadata.wasm-pack.profile.release]
wasm-opt = ['-Os']

View File

@ -15,6 +15,10 @@ backend="http://localhost:9345/original"
backend="http://localhost:9345/graphiql"
[[proxy]]
backend="http://localhost:9345/graphql"
[[proxy]]
backend="http://localhost:9345/download"
[[proxy]]
backend="http://localhost:9345/view"
[[hooks]]
stage = "pre_build"

View File

@ -72,13 +72,21 @@
"isDeprecated": false,
"name": "filename",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "size",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
{
@ -104,6 +112,46 @@
"name": "String",
"ofType": null
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "disposition",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "DispositionType",
"ofType": null
}
}
},
{
"args": [],
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "bytes",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
}
}
}
}
],
"inputFields": null,
@ -148,6 +196,29 @@
"name": "Boolean",
"possibleTypes": null
},
{
"description": null,
"enumValues": [
{
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "INLINE"
},
{
"deprecationReason": null,
"description": null,
"isDeprecated": false,
"name": "ATTACHMENT"
}
],
"fields": null,
"inputFields": null,
"interfaces": null,
"kind": "ENUM",
"name": "DispositionType",
"possibleTypes": null
},
{
"description": null,
"enumValues": null,