//! The compose and send state machine: drafts, the outbox queue, the due set, //! and send-failure bookkeeping. use chrono::{DateTime, Utc}; use goingson_core::{CoreError, Email, EmailAccountId, EmailId, Result, UserId}; use rusqlite::{Connection, params}; use crate::utils::{execute, format_datetime, format_datetime_now, format_datetime_opt, query_all}; use super::query; use super::row::{EMAIL_LIST_COLUMNS, EMAIL_SELECT_COLUMNS, EmailRow}; /// Every draft, newest first. pub(super) fn list_drafts(conn: &Connection, user_id: UserId) -> Result> { let sql = format!( "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 1 ORDER BY e.received_at DESC" ); let rows = query_all( conn, &sql, params![user_id.to_string(), user_id.to_string()], EmailRow::from_row, )?; rows.into_iter().map(Email::try_from).collect() } /// Upsert a draft by id and return it as stored. #[allow(clippy::too_many_arguments)] pub(super) fn save_draft( conn: &Connection, id: EmailId, user_id: UserId, from: &str, to: &str, cc: Option<&str>, bcc: Option<&str>, subject: &str, body: &str, account_id: Option, in_reply_to: Option<&str>, thread_id: Option<&str>, ) -> Result { let now = format_datetime_now(); let account_id_str = account_id.map(|a: EmailAccountId| a.to_string()); // Upsert: update if exists, insert if not execute( conn, r" INSERT INTO emails (id, user_id, from_address, to_address, cc_address, bcc_address, subject, body, is_read, is_archived, is_draft, is_outgoing, received_at, draft_account_id, in_reply_to, thread_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 1, 0, 1, 1, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET from_address = excluded.from_address, to_address = excluded.to_address, cc_address = excluded.cc_address, bcc_address = excluded.bcc_address, subject = excluded.subject, body = excluded.body, received_at = excluded.received_at, draft_account_id = excluded.draft_account_id, in_reply_to = excluded.in_reply_to, thread_id = excluded.thread_id ", params![ id.to_string(), user_id.to_string(), from, to, cc, bcc, subject, body, &now, &account_id_str, in_reply_to, thread_id ], )?; query::get_by_id(conn, id, user_id)? .ok_or_else(|| CoreError::internal("Failed to retrieve saved draft")) } /// Queue a draft for sending, optionally not before `send_after`. pub(super) fn queue_draft( conn: &Connection, id: EmailId, user_id: UserId, send_after: Option>, ) -> Result> { // `is_draft = 1` in the predicate rather than checked first: queueing a // received message is refused by the write not matching, so there is no // window between the check and the update. let changed = execute( conn, "UPDATE emails SET queued_at = ?, send_after = ?, send_attempts = 0, send_error = NULL WHERE id = ? AND user_id = ? AND is_draft = 1", params![ format_datetime_now(), format_datetime_opt(send_after), id.to_string(), user_id.to_string(), ], )?; if changed == 0 { return Ok(None); } query::get_by_id(conn, id, user_id) } /// Take a queued draft back out of the outbox. pub(super) fn unqueue_draft( conn: &Connection, id: EmailId, user_id: UserId, ) -> Result> { let changed = execute( conn, "UPDATE emails SET queued_at = NULL, send_after = NULL, send_attempts = 0, send_error = NULL WHERE id = ? AND user_id = ? AND queued_at IS NOT NULL", params![id.to_string(), user_id.to_string()], )?; if changed == 0 { return Ok(None); } query::get_by_id(conn, id, user_id) } /// Queued drafts, oldest queued first. Bodies are blanked. pub(super) fn list_outbox(conn: &Connection, user_id: UserId) -> Result> { let sql = format!( "SELECT {EMAIL_LIST_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 1 AND e.queued_at IS NOT NULL ORDER BY e.queued_at ASC" ); let rows = query_all( conn, &sql, params![user_id.to_string(), user_id.to_string()], EmailRow::from_row, )?; rows.into_iter().map(Email::try_from).collect() } /// Queued drafts whose send time has arrived, bodies included. pub(super) fn list_due( conn: &Connection, user_id: UserId, now: DateTime, ) -> Result> { // The body is wanted here, unlike the outbox list: this is the set that // is about to be sent, and a blanked body would send an empty message. let sql = format!( "SELECT {EMAIL_SELECT_COLUMNS} FROM emails e LEFT JOIN projects p ON e.project_id = p.id AND p.user_id = ? WHERE e.user_id = ? AND e.is_draft = 1 AND e.queued_at IS NOT NULL AND (e.send_after IS NULL OR e.send_after <= ?) ORDER BY e.queued_at ASC" ); let rows = query_all( conn, &sql, params![ user_id.to_string(), user_id.to_string(), format_datetime(&now) ], EmailRow::from_row, )?; rows.into_iter().map(Email::try_from).collect() } /// Count a failed send attempt and record its error. pub(super) fn record_send_failure( conn: &Connection, id: EmailId, user_id: UserId, error: &str, ) -> Result<()> { execute( conn, "UPDATE emails SET send_attempts = send_attempts + 1, send_error = ? WHERE id = ? AND user_id = ?", params![error, id.to_string(), user_id.to_string()], )?; Ok(()) }