web & server: add support for email photos

This commit is contained in:
Bill Thiede 2025-01-14 12:05:03 -08:00
parent f27f0deb38
commit 5a997e61da
7 changed files with 255 additions and 45 deletions

View File

@ -0,0 +1,3 @@
DROP TABLE IF EXISTS email_address;
DROP TABLE IF EXISTS photo;
DROP TABLE IF EXISTS google_person;

View File

@ -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)
);

View File

@ -237,6 +237,7 @@ impl Body {
pub struct Email {
pub name: Option<String>,
pub addr: Option<String>,
pub photo_url: Option<String>,
}
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?)
}
}
}

View File

@ -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<Vec<Tag>, 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<Thread, ServerError> {
@ -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<Option<String>, 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))
}

View File

@ -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,

View File

@ -21,6 +21,7 @@ query ShowThreadQuery($threadId: String!) {
from {
name
addr
photoUrl
}
to {
name

View File

@ -375,7 +375,21 @@ fn has_unread(tags: &[String]) -> bool {
tags.contains(&String::from("unread"))
}
fn render_avatar(_avatar: Option<String>, from: &str, big: bool) -> Node<Msg> {
fn render_avatar(photo_url: Option<String>, from: &str, big: bool) -> Node<Msg> {
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<String>, from: &str, big: bool) -> Node<Msg> {
.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<Msg> {
]
}
fn render_open_header(msg: &ShowThreadQueryThreadOnEmailThreadMessages) -> Node<Msg> {
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<String> = None;
//let avatar: Option<String> = 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<Msg> {
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<String> = None;
//let avatar: Option<String> = 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"],