//! Row layer for the email repository: the column lists every query selects, //! the raw `EmailRow` those queries deserialize into, and its conversion into //! the domain [`Email`]. use goingson_core::{BodyFormat, CoreError, Email, ParseableEnum}; use crate::utils::{parse_datetime, parse_uuid, parse_uuid_opt}; /// Column list for SELECT queries - avoids duplication across methods. pub(super) const EMAIL_SELECT_COLUMNS: &str = r"e.id, e.project_id, p.name as project_name, e.from_address, e.to_address, e.subject, e.body, e.body_format, e.html_body, e.is_read, e.is_archived, e.received_at, e.message_id, e.in_reply_to, e.thread_id, e.email_account_id, e.is_outgoing, e.imap_uid, e.source_folder, e.attachment_meta, e.labels, e.is_draft, e.cc_address, e.bcc_address, e.draft_account_id, e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date, e.body_truncated, e.jmap_id, e.queued_at, e.send_after, e.send_attempts, e.send_error"; /// Same shape as [`EMAIL_SELECT_COLUMNS`] but with the two heavy body columns /// blanked, for flat list views that never render a body (Perf S3). `body_truncated` /// is forced to 1 so the reader knows to re-fetch the full body on open. pub(super) const EMAIL_LIST_COLUMNS: &str = r"e.id, e.project_id, p.name as project_name, e.from_address, e.to_address, e.subject, '' AS body, e.body_format, NULL AS html_body, e.is_read, e.is_archived, e.received_at, e.message_id, e.in_reply_to, e.thread_id, e.email_account_id, e.is_outgoing, e.imap_uid, e.source_folder, e.attachment_meta, e.labels, e.is_draft, e.cc_address, e.bcc_address, e.draft_account_id, e.snoozed_until, e.waiting_for_response, e.waiting_since, e.expected_response_date, 1 AS body_truncated, e.jmap_id, e.queued_at, e.send_after, e.send_attempts, e.send_error"; /// Upper bound on a flat metadata list so it can never materialize an unbounded /// number of rows (the list UI paginates via `list_threaded`; this flat path is a /// safety-capped fallback). pub(super) const EMAIL_LIST_CAP: i64 = 1000; #[derive(Debug, Clone)] pub(super) struct EmailRow { pub id: String, pub project_id: Option, pub project_name: Option, pub from_address: String, pub to_address: String, pub subject: String, pub body: String, pub body_format: String, pub html_body: Option, pub is_read: i32, pub is_archived: i32, pub received_at: String, pub message_id: Option, pub in_reply_to: Option, pub thread_id: Option, pub email_account_id: Option, pub is_outgoing: i32, pub imap_uid: Option, pub source_folder: Option, pub attachment_meta: Option, pub labels: String, pub is_draft: i32, pub cc_address: Option, pub bcc_address: Option, pub draft_account_id: Option, pub snoozed_until: Option, pub waiting_for_response: i32, pub waiting_since: Option, pub expected_response_date: Option, pub body_truncated: i32, pub jmap_id: Option, pub queued_at: Option, pub send_after: Option, pub send_attempts: i32, pub send_error: Option, } impl EmailRow { pub(super) fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Self { id: row.get("id")?, project_id: row.get("project_id")?, project_name: row.get("project_name")?, from_address: row.get("from_address")?, to_address: row.get("to_address")?, subject: row.get("subject")?, body: row.get("body")?, body_format: row.get("body_format")?, html_body: row.get("html_body")?, is_read: row.get("is_read")?, is_archived: row.get("is_archived")?, received_at: row.get("received_at")?, message_id: row.get("message_id")?, in_reply_to: row.get("in_reply_to")?, thread_id: row.get("thread_id")?, email_account_id: row.get("email_account_id")?, is_outgoing: row.get("is_outgoing")?, imap_uid: row.get("imap_uid")?, source_folder: row.get("source_folder")?, attachment_meta: row.get("attachment_meta")?, labels: row.get("labels")?, is_draft: row.get("is_draft")?, cc_address: row.get("cc_address")?, bcc_address: row.get("bcc_address")?, draft_account_id: row.get("draft_account_id")?, snoozed_until: row.get("snoozed_until")?, waiting_for_response: row.get("waiting_for_response")?, waiting_since: row.get("waiting_since")?, expected_response_date: row.get("expected_response_date")?, body_truncated: row.get("body_truncated")?, jmap_id: row.get("jmap_id")?, queued_at: row.get("queued_at")?, send_after: row.get("send_after")?, send_attempts: row.get("send_attempts")?, send_error: row.get("send_error")?, }) } } impl TryFrom for Email { type Error = CoreError; fn try_from(row: EmailRow) -> std::result::Result { Ok(Email { id: parse_uuid(&row.id)?.into(), project_id: parse_uuid_opt(row.project_id.as_deref())?.map(Into::into), project_name: row.project_name, from: row.from_address, to: row.to_address, subject: row.subject, body: row.body, body_format: BodyFormat::from_str_or_default(&row.body_format), html_body: row.html_body, body_truncated: row.body_truncated != 0, jmap_id: row.jmap_id, queued_at: row .queued_at .as_ref() .map(|s| parse_datetime(s)) .transpose()?, send_after: row .send_after .as_ref() .map(|s| parse_datetime(s)) .transpose()?, send_attempts: row.send_attempts, send_error: row.send_error, is_read: row.is_read != 0, is_archived: row.is_archived != 0, received_at: parse_datetime(&row.received_at)?, message_id: row.message_id, in_reply_to: row.in_reply_to, thread_id: row.thread_id, email_account_id: parse_uuid_opt(row.email_account_id.as_deref())?.map(Into::into), is_outgoing: row.is_outgoing != 0, imap_uid: row.imap_uid, source_folder: row.source_folder, attachment_meta: row.attachment_meta, labels: serde_json::from_str(&row.labels).unwrap_or_default(), is_draft: row.is_draft != 0, cc_address: row.cc_address, bcc_address: row.bcc_address, draft_account_id: parse_uuid_opt(row.draft_account_id.as_deref())?.map(Into::into), snoozed_until: row .snoozed_until .as_ref() .map(|s| parse_datetime(s)) .transpose()?, waiting_for_response: row.waiting_for_response != 0, waiting_since: row .waiting_since .as_ref() .map(|s| parse_datetime(s)) .transpose()?, expected_response_date: row .expected_response_date .as_ref() .map(|s| parse_datetime(s)) .transpose()?, }) } }