Compare commits

...

9 Commits

3 changed files with 141 additions and 58 deletions

View File

@ -23,6 +23,12 @@ pub type UnixTime = isize;
/// # Thread ID, sans "thread:" /// # Thread ID, sans "thread:"
pub type ThreadId = String; pub type ThreadId = String;
const TEXT_PLAIN: &'static str = "text/plain";
const TEXT_HTML: &'static str = "text/html";
const MULTIPART_ALTERNATIVE: &'static str = "multipart/alternative";
const MULTIPART_MIXED: &'static str = "multipart/mixed";
const MULTIPART_RELATED: &'static str = "multipart/related";
#[derive(Debug, SimpleObject)] #[derive(Debug, SimpleObject)]
pub struct ThreadSummary { pub struct ThreadSummary {
pub thread: ThreadId, pub thread: ThreadId,
@ -316,7 +322,6 @@ impl QueryRoot {
let mut messages = Vec::new(); let mut messages = Vec::new();
for (path, id) in std::iter::zip(nm.files(&thread_id)?, nm.message_ids(&thread_id)?) { for (path, id) in std::iter::zip(nm.files(&thread_id)?, nm.message_ids(&thread_id)?) {
let tags = nm.tags_for_query(&format!("id:{id}"))?; let tags = nm.tags_for_query(&format!("id:{id}"))?;
info!("{id}: {tags:?}\nfile: {path}");
let file = File::open(&path)?; let file = File::open(&path)?;
let mmap = unsafe { MmapOptions::new().map(&file)? }; let mmap = unsafe { MmapOptions::new().map(&file)? };
let m = parse_mail(&mmap)?; let m = parse_mail(&mmap)?;
@ -426,10 +431,10 @@ impl Mutation {
fn extract_body(m: &ParsedMail) -> Result<Body, Error> { fn extract_body(m: &ParsedMail) -> Result<Body, Error> {
let body = m.get_body()?; let body = m.get_body()?;
let ret = match m.ctype.mimetype.as_str() { let ret = match m.ctype.mimetype.as_str() {
"text/plain" => return Ok(Body::text(body)), TEXT_PLAIN => return Ok(Body::text(body)),
"text/html" => return Ok(Body::html(body)), TEXT_HTML => return Ok(Body::html(body)),
"multipart/mixed" => extract_mixed(m), MULTIPART_MIXED => extract_mixed(m),
"multipart/alternative" => extract_alternative(m), MULTIPART_ALTERNATIVE => extract_alternative(m),
_ => extract_unhandled(m), _ => extract_unhandled(m),
}; };
if let Err(err) = ret { if let Err(err) = ret {
@ -444,7 +449,6 @@ fn extract_unhandled(m: &ParsedMail) -> Result<Body, Error> {
"Unhandled body content type:\n{}", "Unhandled body content type:\n{}",
render_content_type_tree(m) render_content_type_tree(m)
); );
warn!("{}", msg);
Ok(Body::UnhandledContentType(UnhandledContentType { Ok(Body::UnhandledContentType(UnhandledContentType {
text: msg, text: msg,
})) }))
@ -455,59 +459,100 @@ fn extract_unhandled(m: &ParsedMail) -> Result<Body, Error> {
// then give up. // then give up.
fn extract_alternative(m: &ParsedMail) -> Result<Body, Error> { fn extract_alternative(m: &ParsedMail) -> Result<Body, Error> {
for sp in &m.subparts { for sp in &m.subparts {
if sp.ctype.mimetype == "text/html" { if sp.ctype.mimetype.as_str() == TEXT_HTML {
let body = sp.get_body()?; let body = sp.get_body()?;
return Ok(Body::html(body)); return Ok(Body::html(body));
} }
} }
for sp in &m.subparts { for sp in &m.subparts {
if sp.ctype.mimetype == "text/plain" { if sp.ctype.mimetype.as_str() == TEXT_PLAIN {
let body = sp.get_body()?; let body = sp.get_body()?;
return Ok(Body::text(body)); return Ok(Body::text(body));
} }
} }
Err("extract_alternative".into()) 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]
)
.into())
} }
// multipart/mixed defines multiple types of context all of which should be presented to the user // multipart/mixed defines multiple types of context all of which should be presented to the user
// 'serially'. // 'serially'.
fn extract_mixed(m: &ParsedMail) -> Result<Body, Error> { fn extract_mixed(m: &ParsedMail) -> Result<Body, Error> {
let handled_types = vec![
MULTIPART_ALTERNATIVE,
MULTIPART_RELATED,
TEXT_HTML,
TEXT_PLAIN,
];
let mut unhandled_types: Vec<_> = m
.subparts
.iter()
.map(|sp| sp.ctype.mimetype.as_str())
.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 { for sp in &m.subparts {
if sp.ctype.mimetype == "multipart/alternative" { if sp.ctype.mimetype.as_str() == MULTIPART_ALTERNATIVE {
return extract_alternative(sp); return extract_alternative(sp);
} }
} }
for sp in &m.subparts { for sp in &m.subparts {
if sp.ctype.mimetype == "multipart/related" { if sp.ctype.mimetype == MULTIPART_RELATED {
return extract_related(sp); return extract_related(sp);
} }
} }
for sp in &m.subparts { for sp in &m.subparts {
let body = sp.get_body()?; let body = sp.get_body()?;
match sp.ctype.mimetype.as_str() { match sp.ctype.mimetype.as_str() {
"text/plain" => return Ok(Body::text(body)), TEXT_PLAIN => return Ok(Body::text(body)),
"text/html" => return Ok(Body::html(body)), TEXT_HTML => return Ok(Body::html(body)),
_ => (), _ => (),
} }
} }
Err("extract_mixed".into()) Err(format!(
"extract_mixed failed to find suitable subpart, searched: {:?}",
handled_types
)
.into())
} }
fn extract_related(m: &ParsedMail) -> Result<Body, Error> { fn extract_related(m: &ParsedMail) -> Result<Body, Error> {
// TODO(wathiede): collect related things and change return type to new Body arm. // 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
.subparts
.iter()
.map(|sp| sp.ctype.mimetype.as_str())
.filter(|mt| !handled_types.contains(&mt))
.collect();
unhandled_types.sort();
warn!("{MULTIPART_RELATED} contains the following unhandled mimetypes {unhandled_types:?}");
for sp in &m.subparts { for sp in &m.subparts {
if sp.ctype.mimetype == "text/html" { if sp.ctype.mimetype == TEXT_HTML {
let body = sp.get_body()?; let body = sp.get_body()?;
return Ok(Body::html(body)); return Ok(Body::html(body));
} }
} }
for sp in &m.subparts { for sp in &m.subparts {
if sp.ctype.mimetype == "text/plain" { if sp.ctype.mimetype == TEXT_PLAIN {
let body = sp.get_body()?; let body = sp.get_body()?;
return Ok(Body::text(body)); return Ok(Body::text(body));
} }
} }
Err("extract_related".into()) Err(format!(
"extract_related failed to find suitable subpart, searched: {:?}",
handled_types
)
.into())
} }
// TODO(wathiede): make this walk_attachments that takes a closure. // TODO(wathiede): make this walk_attachments that takes a closure.

View File

@ -27,6 +27,10 @@
padding: 0; padding: 0;
} }
.message .header .media-right {
padding: 1rem;
}
.message .headers { .message .headers {
position: relative; position: relative;
width: 100%; width: 100%;
@ -54,6 +58,13 @@
overflow-wrap: break-word; overflow-wrap: break-word;
} }
.message .body .attachments hr {
border: none;
border-top: 1px dashed #888;
background-color: #f000;
margin: 0.5rem 0;
}
.error { .error {
background-color: red; background-color: red;
} }
@ -61,7 +72,9 @@
.view-part-text-plain { .view-part-text-plain {
padding: 0.5em; padding: 0.5em;
overflow-wrap: break-word; overflow-wrap: break-word;
white-space: pre-line; white-space: pre-wrap;
word-break: break-word;
word-wrap: break-word;
} }
iframe { iframe {
@ -96,7 +109,7 @@
} }
.index .date { .index .date {
width: 10em; width: 7em;
white-space: nowrap; white-space: nowrap;
text-align: right; text-align: right;
} }
@ -183,6 +196,7 @@
.search-results .row .summary { .search-results .row .summary {
min-width: 0; min-width: 0;
width: 100%;
} }
.search-results .row .subject { .search-results .row .subject {
@ -191,6 +205,20 @@
white-space: nowrap; white-space: nowrap;
} }
.search-results td.subject {
display: flex;
}
.search-results .subject .tag {}
.search-results .subject .text {
padding-left: 0.5rem;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-results .row .from { .search-results .row .from {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -250,7 +278,9 @@
} }
.content-tree { .content-tree {
white-space: pre-line; white-space: pre-wrap;
word-break: break-word;
word-wrap: break-word;
} }
</style> </style>
</head> </head>

View File

@ -21,6 +21,9 @@ mod desktop;
mod mobile; mod mobile;
mod tablet; mod tablet;
// TODO(wathiede): create a QueryString enum that wraps single and multiple message ids and thread
// ids, and has a to_query_string() that knows notmuch's syntax. Then remove the smattering of
// format!() calls all over with magic strings representing notmuch specific syntax.
const MAX_RAW_MESSAGE_SIZE: usize = 100_000; const MAX_RAW_MESSAGE_SIZE: usize = 100_000;
fn set_title(title: &str) { fn set_title(title: &str) {
seed::document().set_title(&format!("lb: {}", title)); seed::document().set_title(&format!("lb: {}", title));
@ -168,7 +171,7 @@ fn view_search_results(
tags_chiclet(&tags, false), tags_chiclet(&tags, false),
" ", " ",
a![ a![
C!["has-text-light"], C!["has-text-light", "text"],
attrs! { attrs! {
At::Href => urls::thread(&tid) At::Href => urls::thread(&tid)
}, },
@ -180,6 +183,7 @@ fn view_search_results(
}); });
div![ div![
C!["search-results"],
search_toolbar(count, pager, show_bulk_edit), search_toolbar(count, pager, show_bulk_edit),
table![ table![
C![ C![
@ -236,6 +240,7 @@ fn search_toolbar(
C!["level-left"], C!["level-left"],
IF!(show_bulk_edit => IF!(show_bulk_edit =>
span![ span![
// TODO(wathiede): add "Mark as spam"
C!["level-item", "buttons", "has-addons"], C!["level-item", "buttons", "has-addons"],
button![ button![
C!["button"], C!["button"],
@ -420,11 +425,13 @@ fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
small![from_detail], small![from_detail],
table![ table![
IF!(!msg.to.is_empty() => IF!(!msg.to.is_empty() =>
msg.to.iter().enumerate().map(|(i, to)|
tr![ tr![
td![ if i==0 { "To" }else { "" } ], td![ "To" ],
//td![ if i==0 { "To" }else { "" } ],
td![ td![
msg.to.iter().enumerate().map(|(i, to)|
small![ small![
if i>0 { ", " }else { "" },
match to { match to {
ShowThreadQueryThreadMessagesTo { ShowThreadQueryThreadMessagesTo {
name: Some(name), name: Some(name),
@ -436,18 +443,20 @@ fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
} => format!("{name}"), } => format!("{name}"),
ShowThreadQueryThreadMessagesTo { ShowThreadQueryThreadMessagesTo {
addr: Some(addr), .. addr: Some(addr), ..
} => format!("<{addr}>"), } => format!("{addr}"),
_ => String::from("UNKNOWN"), _ => String::from("UNKNOWN"),
} }
])
] ]
]])), ]),
IF!(!msg.cc.is_empty() => IF!(!msg.cc.is_empty() =>
msg.cc.iter().enumerate().map(|(i, cc)|
tr![ tr![
td![ if i==0 { "CC" }else { "" } ], td![ "CC" ],
td![ td![
msg.cc.iter().enumerate().map(|(i, cc)|
small![ small![
if i>0 { ", " }else { "" },
match cc { match cc {
ShowThreadQueryThreadMessagesCc { ShowThreadQueryThreadMessagesCc {
name: Some(name), name: Some(name),
@ -463,8 +472,9 @@ fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
_ => String::from("UNKNOWN"), _ => String::from("UNKNOWN"),
} }
])
] ]
]])), ]),
tr![ tr![
td!["Date"], td!["Date"],
td![msg.timestamp.map(|ts| span![C!["header"], human_age(ts)])] td![msg.timestamp.map(|ts| span![C!["header"], human_age(ts)])]
@ -477,14 +487,14 @@ fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
C!["media-right"], C!["media-right"],
span![ span![
C!["read-status"], C!["read-status"],
i![ i![C![
C![
"far", "far",
if is_unread { if is_unread {
"fa-envelope" "fa-envelope"
} else { } else {
"fa-envelope-open" "fa-envelope-open"
}, },
]]
], ],
ev(Ev::Click, move |e| { ev(Ev::Click, move |e| {
e.stop_propagation(); e.stop_propagation();
@ -492,8 +502,6 @@ fn render_open_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
}) })
] ]
] ]
]
]
} }
fn render_closed_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> { fn render_closed_header(msg: &ShowThreadQueryThreadMessages) -> Node<Msg> {
@ -617,7 +625,7 @@ fn message_render(msg: &ShowThreadQueryThreadMessages, open: bool) -> Node<Msg>
IF!(!msg.attachments.is_empty() => IF!(!msg.attachments.is_empty() =>
div![ div![
C!["attachments"], C!["attachments"],
br![], hr![],
h2!["Attachments"], h2!["Attachments"],
msg.attachments msg.attachments
.iter() .iter()
@ -655,6 +663,7 @@ fn thread(thread: &ShowThreadQueryThread, open_messages: &HashSet<String>) -> No
h3![C!["is-size-5"], &thread.subject], h3![C!["is-size-5"], &thread.subject],
span![C!["tags"], tags_chiclet(&tags, false)], span![C!["tags"], tags_chiclet(&tags, false)],
span![ span![
// TODO(wathiede): add "Mark as spam"
C!["level-item", "buttons", "has-addons"], C!["level-item", "buttons", "has-addons"],
button![ button![
C!["button"], C!["button"],
@ -691,7 +700,6 @@ fn thread(thread: &ShowThreadQueryThread, open_messages: &HashSet<String>) -> No
fn view_content_tree(content_tree: &str) -> Node<Msg> { fn view_content_tree(content_tree: &str) -> Node<Msg> {
let debug_open = use_state(|| false); let debug_open = use_state(|| false);
div![ div![
hr![],
small![ small![
i![C![ i![C![
"fa-solid", "fa-solid",