diff --git a/server/migrations/20250114045217_create-email-photos.down.sql b/server/migrations/20250114045217_create-email-photos.down.sql new file mode 100644 index 0000000..6fa7f03 --- /dev/null +++ b/server/migrations/20250114045217_create-email-photos.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS email_address; +DROP TABLE IF EXISTS photo; +DROP TABLE IF EXISTS google_person; \ No newline at end of file diff --git a/server/migrations/20250114045217_create-email-photos.up.sql b/server/migrations/20250114045217_create-email-photos.up.sql new file mode 100644 index 0000000..03fe8c3 --- /dev/null +++ b/server/migrations/20250114045217_create-email-photos.up.sql @@ -0,0 +1,19 @@ +-- Add up migration script here +CREATE TABLE IF NOT EXISTS google_person ( + id SERIAL PRIMARY KEY, + resource_name TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS email_photo ( + id SERIAL PRIMARY KEY, + google_person_id INTEGER REFERENCES google_person (id) UNIQUE, + url TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS email_address ( + id SERIAL PRIMARY KEY, + address TEXT NOT NULL UNIQUE, + email_photo_id INTEGER REFERENCES email_photo (id), + google_person_id INTEGER REFERENCES google_person (id) +); \ No newline at end of file diff --git a/server/src/graphql.rs b/server/src/graphql.rs index b98de2f..fd3eb6e 100644 --- a/server/src/graphql.rs +++ b/server/src/graphql.rs @@ -237,6 +237,7 @@ impl Body { pub struct Email { pub name: Option, pub addr: Option, + pub photo_url: Option, } impl fmt::Display for Email { @@ -422,6 +423,7 @@ impl QueryRoot { } let mut connection = Connection::new(has_previous_page, has_next_page); + // Set starting offset as the value from cursor to preserve state if no results from a corpus survived the truncation let mut newsreader_offset = after.as_ref().map(|sc| sc.newsreader_offset).unwrap_or(0); let mut notmuch_offset = after.as_ref().map(|sc| sc.notmuch_offset).unwrap_or(0); @@ -487,7 +489,7 @@ impl QueryRoot { if newsreader::is_newsreader_thread(&thread_id) { Ok(newsreader::thread(config, pool, thread_id).await?) } else { - Ok(nm::thread(nm, thread_id, debug_content_tree).await?) + Ok(nm::thread(nm, pool, thread_id, debug_content_tree).await?) } } } diff --git a/server/src/nm.rs b/server/src/nm.rs index 331220a..31ce33b 100644 --- a/server/src/nm.rs +++ b/server/src/nm.rs @@ -9,6 +9,8 @@ use log::{error, info, warn}; use mailparse::{parse_content_type, parse_mail, MailHeader, MailHeaderMap, ParsedMail}; use memmap::MmapOptions; use notmuch::Notmuch; +use rocket::http::uri::error::PathError; +use sqlx::PgPool; use tracing::instrument; use crate::{ @@ -147,6 +149,7 @@ pub fn tags(nm: &Notmuch, needs_unread: bool) -> Result, ServerError> { #[instrument(name="nm::thread", skip_all, fields(thread_id=thread_id))] pub async fn thread( nm: &Notmuch, + pool: &PgPool, thread_id: String, debug_content_tree: bool, ) -> Result { @@ -159,7 +162,7 @@ pub async fn thread( let mmap = unsafe { MmapOptions::new().map(&file)? }; let m = parse_mail(&mmap)?; let from = email_addresses(&path, &m, "from")?; - let from = match from.len() { + let mut from = match from.len() { 0 => None, 1 => from.into_iter().next(), _ => { @@ -171,6 +174,16 @@ pub async fn thread( from.into_iter().next() } }; + match from.as_mut() { + Some(from) => { + if let Some(addr) = from.addr.as_mut() { + let photo_url = photo_url_for_email_address(&pool, &addr).await?; + from.photo_url = photo_url; + } + } + _ => (), + } + let to = email_addresses(&path, &m, "to")?; let cc = email_addresses(&path, &m, "cc")?; let subject = m.headers.get_first_value("subject"); @@ -329,6 +342,7 @@ fn email_addresses( mailparse::MailAddr::Single(s) => addrs.push(Email { name: s.display_name, addr: Some(s.addr), + photo_url: None, }), //println!("Single: {s}"), } } @@ -343,12 +357,14 @@ fn email_addresses( addrs.push(Email { name: Some(name.to_string()), addr: Some(addr.to_string()), + photo_url: None, }); } } else { addrs.push(Email { name: Some(v), addr: None, + photo_url: None, }); } } @@ -877,3 +893,24 @@ pub async fn set_read_status<'ctx>( } Ok(true) } + +async fn photo_url_for_email_address( + pool: &PgPool, + addr: &str, +) -> Result, ServerError> { + let row = sqlx::query!( + r#" +SELECT + url +FROM email_photo ep +JOIN email_address ea +ON ep.id = ea.email_photo_id +WHERE + address = $1 + "#, + addr + ) + .fetch_optional(pool) + .await?; + Ok(row.map(|r| r.url)) +} diff --git a/web/graphql/schema.json b/web/graphql/schema.json index 80ea28f..422ed6b 100644 --- a/web/graphql/schema.json +++ b/web/graphql/schema.json @@ -2,6 +2,28 @@ "data": { "__schema": { "directives": [ + { + "args": [ + { + "defaultValue": "\"No longer supported\"", + "description": "A reason for why it is deprecated, formatted using Markdown syntax", + "name": "reason", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + ], + "description": "Marks an element of a GraphQL schema as no longer supported.", + "locations": [ + "FIELD_DEFINITION", + "ARGUMENT_DEFINITION", + "INPUT_FIELD_DEFINITION", + "ENUM_VALUE" + ], + "name": "deprecated" + }, { "args": [ { @@ -27,6 +49,14 @@ ], "name": "include" }, + { + "args": [], + "description": "Indicates that an Input Object is a OneOf Input Object (and thus requires\n exactly one of its field be provided)", + "locations": [ + "INPUT_OBJECT" + ], + "name": "oneOf" + }, { "args": [ { @@ -51,6 +81,29 @@ "INLINE_FRAGMENT" ], "name": "skip" + }, + { + "args": [ + { + "defaultValue": null, + "description": "URL that specifies the behavior of this scalar.", + "name": "url", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + ], + "description": "Provides a scalar specification URL for specifying the behavior of custom scalar types.", + "locations": [ + "SCALAR" + ], + "name": "specifiedBy" } ], "mutationType": { @@ -311,6 +364,18 @@ "name": "String", "ofType": null } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "photoUrl", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } } ], "inputFields": null, @@ -880,22 +945,6 @@ } } }, - { - "args": [], - "deprecationReason": null, - "description": "Drop and recreate tantivy index. Warning this is slow", - "isDeprecated": false, - "name": "dropAndLoadIndex", - "type": { - "kind": "NON_NULL", - "name": null, - "ofType": { - "kind": "SCALAR", - "name": "Boolean", - "ofType": null - } - } - }, { "args": [], "deprecationReason": null, @@ -1839,7 +1888,22 @@ } }, { - "args": [], + "args": [ + { + "defaultValue": "false", + "description": null, + "name": "includeDeprecated", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], "deprecationReason": null, "description": null, "isDeprecated": false, @@ -2110,7 +2174,22 @@ } }, { - "args": [], + "args": [ + { + "defaultValue": "false", + "description": null, + "name": "includeDeprecated", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], "deprecationReason": null, "description": null, "isDeprecated": false, @@ -2243,6 +2322,34 @@ "name": "String", "ofType": null } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "isDeprecated", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + }, + { + "args": [], + "deprecationReason": null, + "description": null, + "isDeprecated": false, + "name": "deprecationReason", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } } ], "inputFields": null, @@ -2255,6 +2362,22 @@ "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes\nall available types and directives on the server, as well as the entry\npoints for query, mutation, and subscription operations.", "enumValues": null, "fields": [ + { + "args": [], + "deprecationReason": null, + "description": "description of __Schema for newer graphiql introspection schema\nrequirements", + "isDeprecated": false, + "name": "description", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, { "args": [], "deprecationReason": null, @@ -2505,7 +2628,22 @@ } }, { - "args": [], + "args": [ + { + "defaultValue": "false", + "description": null, + "name": "includeDeprecated", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + } + } + ], "deprecationReason": null, "description": null, "isDeprecated": false, diff --git a/web/graphql/show_thread.graphql b/web/graphql/show_thread.graphql index 33092ab..467bdf1 100644 --- a/web/graphql/show_thread.graphql +++ b/web/graphql/show_thread.graphql @@ -21,6 +21,7 @@ query ShowThreadQuery($threadId: String!) { from { name addr + photoUrl } to { name diff --git a/web/src/view/mod.rs b/web/src/view/mod.rs index 3f31d2d..2384270 100644 --- a/web/src/view/mod.rs +++ b/web/src/view/mod.rs @@ -375,7 +375,21 @@ fn has_unread(tags: &[String]) -> bool { tags.contains(&String::from("unread")) } -fn render_avatar(_avatar: Option, from: &str, big: bool) -> Node { +fn render_avatar(photo_url: Option, from: &str, big: bool) -> Node { + let size = if big { + C!["w-16", "h-16", "text-4xl"] + } else { + C!["w-8", "h-8", "text-l"] + }; + if let Some(photo_url) = photo_url { + return div![ + size, + img![attrs! { + At::Src => photo_url, + }] + ]; + } + let initials: String = from .to_lowercase() .trim() @@ -386,11 +400,7 @@ fn render_avatar(_avatar: Option, from: &str, big: bool) -> Node { .take(2) .collect(); let from_color = compute_color(from); - let size = if big { - C!["w-16", "h-16", "text-4xl"] - } else { - C!["w-8", "h-8", "text-l"] - }; + div![ C![ "[text-shadow:_0_1px_0_rgb(0_0_0_/_40%)]", @@ -416,22 +426,22 @@ fn copy_text_widget(text: &str) -> Node { ] } fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node { - let (from, from_detail) = match &msg.from { + let (from, from_detail, photo_url) = match &msg.from { Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom { name: Some(name), addr, - }) => (name.to_string(), addr.clone()), + photo_url, + }) => (name.to_string(), addr.clone(), photo_url.clone()), Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom { - addr: Some(addr), .. - }) => (addr.to_string(), None), - _ => (String::from("UNKNOWN"), None), + addr: Some(addr), + photo_url, + .. + }) => (addr.to_string(), None, photo_url.clone()), + _ => (String::from("UNKNOWN"), None, None), }; - // TODO(wathiede): get this from server - let avatar: Option = None; - //let avatar: Option = Some(String::from("https://bulma.io/images/placeholders/64x64.png")); let id = msg.id.clone(); let is_unread = has_unread(&msg.tags); - let avatar = render_avatar(avatar, &from, true); + let avatar = render_avatar(photo_url, &from, true); let unknown = "UNKNOWN".to_string(); div![ C!["flex", "p-4"], @@ -513,22 +523,22 @@ fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node< } fn render_closed_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node { - let (from, from_detail) = match &msg.from { + let (from, from_detail, photo_url) = match &msg.from { Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom { name: Some(name), addr, - }) => (name.to_string(), addr.clone()), + photo_url, + }) => (name.to_string(), addr.clone(), photo_url.clone()), Some(ShowThreadQueryThreadOnEmailThreadMessagesFrom { - addr: Some(addr), .. - }) => (addr.to_string(), None), - _ => (String::from("UNKNOWN"), None), + addr: Some(addr), + photo_url, + .. + }) => (addr.to_string(), None, photo_url.clone()), + _ => (String::from("UNKNOWN"), None, None), }; - // TODO(wathiede): get this from server - let avatar: Option = None; - //let avatar: Option = Some(String::from("https://bulma.io/images/placeholders/64x64.png")); let id = msg.id.clone(); let is_unread = has_unread(&msg.tags); - let avatar = render_avatar(avatar, &from, false); + let avatar = render_avatar(photo_url, &from, false); let unknown = "UNKNOWN".to_string(); div![ C!["flex", "p-4"],