diff --git a/DESCRIPTION b/DESCRIPTION index 940e60e..9b3bb73 100644 --- a/DESCRIPTION +++ b/DESCRIPTION @@ -1,6 +1,6 @@ Package: mc Title: Mail Composer - Email Composition and Delivery -Version: 0.2.0 +Version: 0.2.1 Authors@R: person("Allan", "Irvine", , "al@newgraphenvironment.com", role = c("aut", "cre"), comment = c(ORCID = "0000-0002-3495-2128")) @@ -19,6 +19,7 @@ Suggests: callr, kableExtra, knitr, + mockery, rmarkdown, testthat (>= 3.0.0) Config/testthat/edition: 3 diff --git a/NAMESPACE b/NAMESPACE index c257a28..7073a7b 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -21,6 +21,8 @@ importFrom(gmailr,gm_auth) importFrom(gmailr,gm_bcc) importFrom(gmailr,gm_cc) importFrom(gmailr,gm_create_draft) +importFrom(gmailr,gm_draft) +importFrom(gmailr,gm_drafts) importFrom(gmailr,gm_from) importFrom(gmailr,gm_html_body) importFrom(gmailr,gm_id) diff --git a/NEWS.md b/NEWS.md index cc48eb6..08f1f80 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,3 +1,8 @@ +# mc 0.2.1 + +* Add `drafts` parameter to `mc_thread_read()` — includes draft messages + with a `status` column (`"sent"` / `"draft"`). + # mc 0.2.0 * Add `attachments` parameter to `mc_send()` for file attachments. diff --git a/R/mc_thread_find.R b/R/mc_thread_find.R index a403438..990e2d6 100644 --- a/R/mc_thread_find.R +++ b/R/mc_thread_find.R @@ -59,38 +59,49 @@ mc_thread_find <- function(query, n = 5) { #' a follow-up with [mc_send()]. #' #' @param thread_id Gmail thread ID (from [mc_thread_find()]). +#' @param drafts Logical. If `TRUE`, also include draft messages in the +#' output and add a `status` column (`"sent"` or `"draft"`). +#' Default `FALSE` for backwards compatibility. #' #' @return A data frame with columns `from`, `date`, `subject`, and `body`, -#' ordered oldest to newest. +#' ordered oldest to newest. When `drafts = TRUE`, an additional `status` +#' column is included. #' #' @examples #' \dontrun{ #' mc_thread_find("from:brandon subject:cottonwood") #' mc_thread_read("19adb18351867c34") +#' mc_thread_read("19adb18351867c34", drafts = TRUE) #' } #' -#' @importFrom chk chk_string -#' @importFrom gmailr gm_thread +#' @importFrom chk chk_string chk_flag +#' @importFrom gmailr gm_thread gm_drafts gm_draft #' @importFrom jsonlite base64url_dec #' @export -mc_thread_read <- function(thread_id) { +mc_thread_read <- function(thread_id, drafts = FALSE) { chk::chk_string(thread_id) + chk::chk_flag(drafts) thread <- gmailr::gm_thread(id = thread_id) msgs <- thread$messages + empty <- data.frame( + from = character(0), + date = character(0), + subject = character(0), + body = character(0), + stringsAsFactors = FALSE + ) + if (drafts) empty$status <- character(0) + if (is.null(msgs) || length(msgs) == 0) { - message("No messages in thread: ", thread_id) - return(data.frame( - from = character(0), - date = character(0), - subject = character(0), - body = character(0), - stringsAsFactors = FALSE - )) + if (!drafts) { + message("No messages in thread: ", thread_id) + return(empty) + } } - rows <- lapply(msgs, function(msg) { + rows <- lapply(msgs %||% list(), function(msg) { body <- extract_body(msg$payload, "text/plain") if (!nzchar(body)) { body_html <- extract_body(msg$payload, "text/html") @@ -100,16 +111,70 @@ mc_thread_read <- function(thread_id) { body <- trimws(body) } } - data.frame( + row <- data.frame( from = extract_header(msg, "From"), date = extract_header(msg, "Date"), subject = extract_header(msg, "Subject"), body = body, stringsAsFactors = FALSE ) + if (drafts) row$status <- "sent" + row }) - do.call(rbind, rows) + if (drafts) { + draft_rows <- fetch_thread_drafts(thread_id) + rows <- c(rows, draft_rows) + } + + result <- do.call(rbind, rows) + + if (is.null(result) || nrow(result) == 0) { + message("No messages in thread: ", thread_id) + return(empty) + } + + result +} + + +#' Fetch draft messages belonging to a specific thread +#' +#' Scans Gmail drafts and returns rows for any that belong to the given +#' thread. Used internally by [mc_thread_read()] when `drafts = TRUE`. +#' @param thread_id Gmail thread ID. +#' @return List of data frame rows (may be empty). +#' @noRd +fetch_thread_drafts <- function(thread_id) { + all_drafts <- gmailr::gm_drafts(num_results = 50) + draft_list <- all_drafts[[1]]$drafts + if (is.null(draft_list) || length(draft_list) == 0) return(list()) + + rows <- list() + for (d in draft_list) { + detail <- gmailr::gm_draft(d$id) + msg <- detail$message + if (!identical(msg$threadId, thread_id)) next + + body <- extract_body(msg$payload, "text/plain") + if (!nzchar(body)) { + body_html <- extract_body(msg$payload, "text/html") + if (nzchar(body_html)) { + body <- gsub("<[^>]+>", " ", body_html) + body <- gsub("[ \t]+", " ", body) + body <- trimws(body) + } + } + rows[[length(rows) + 1]] <- data.frame( + from = extract_header(msg, "From"), + date = extract_header(msg, "Date"), + subject = extract_header(msg, "Subject"), + body = body, + status = "draft", + stringsAsFactors = FALSE + ) + } + rows } diff --git a/man/mc_thread_read.Rd b/man/mc_thread_read.Rd index 8293d5e..52995bb 100644 --- a/man/mc_thread_read.Rd +++ b/man/mc_thread_read.Rd @@ -4,14 +4,19 @@ \alias{mc_thread_read} \title{Read all messages in a Gmail thread} \usage{ -mc_thread_read(thread_id) +mc_thread_read(thread_id, drafts = FALSE) } \arguments{ \item{thread_id}{Gmail thread ID (from \code{\link[=mc_thread_find]{mc_thread_find()}}).} + +\item{drafts}{Logical. If \code{TRUE}, also include draft messages in the +output and add a \code{status} column (\code{"sent"} or \code{"draft"}). +Default \code{FALSE} for backwards compatibility.} } \value{ A data frame with columns \code{from}, \code{date}, \code{subject}, and \code{body}, -ordered oldest to newest. +ordered oldest to newest. When \code{drafts = TRUE}, an additional \code{status} +column is included. } \description{ Fetches a thread by ID and returns each message's sender, date, subject, @@ -22,6 +27,7 @@ a follow-up with \code{\link[=mc_send]{mc_send()}}. \dontrun{ mc_thread_find("from:brandon subject:cottonwood") mc_thread_read("19adb18351867c34") +mc_thread_read("19adb18351867c34", drafts = TRUE) } } diff --git a/tests/testthat/test-mc_thread_read.R b/tests/testthat/test-mc_thread_read.R index a82a8e2..b3d2b65 100644 --- a/tests/testthat/test-mc_thread_read.R +++ b/tests/testthat/test-mc_thread_read.R @@ -3,6 +3,106 @@ test_that("mc_thread_read rejects bad types", { expect_error(mc_thread_read(thread_id = NULL)) }) +test_that("mc_thread_read rejects bad drafts param", { + expect_error(mc_thread_read(thread_id = "abc", drafts = "yes")) + expect_error(mc_thread_read(thread_id = "abc", drafts = 1)) +}) + +test_that("mc_thread_read without drafts has no status column", { + # Mock gm_thread to return one message + mock_msg <- list( + payload = list( + mimeType = "text/plain", + body = list( + size = 5, + data = jsonlite::base64url_enc(charToRaw("Hello")) + ), + headers = list( + list(name = "From", value = "test@test.com"), + list(name = "Date", value = "Mon, 1 Jan 2026 00:00:00 +0000"), + list(name = "Subject", value = "Test") + ) + ) + ) + mockery::stub(mc_thread_read, "gmailr::gm_thread", + list(messages = list(mock_msg))) + result <- mc_thread_read("fake_id") + expect_equal(names(result), c("from", "date", "subject", "body")) + expect_false("status" %in% names(result)) +}) + +test_that("mc_thread_read with drafts=TRUE adds status column", { + mock_msg <- list( + payload = list( + mimeType = "text/plain", + body = list( + size = 5, + data = jsonlite::base64url_enc(charToRaw("Hello")) + ), + headers = list( + list(name = "From", value = "test@test.com"), + list(name = "Date", value = "Mon, 1 Jan 2026 00:00:00 +0000"), + list(name = "Subject", value = "Test") + ) + ) + ) + mockery::stub(mc_thread_read, "gmailr::gm_thread", + list(messages = list(mock_msg))) + mockery::stub(mc_thread_read, "fetch_thread_drafts", list()) + result <- mc_thread_read("fake_id", drafts = TRUE) + expect_true("status" %in% names(result)) + expect_equal(result$status, "sent") +}) + +test_that("mc_thread_read with drafts=TRUE includes draft messages", { + mock_sent <- list( + payload = list( + mimeType = "text/plain", + body = list( + size = 4, + data = jsonlite::base64url_enc(charToRaw("sent")) + ), + headers = list( + list(name = "From", value = "al@test.com"), + list(name = "Date", value = "Mon, 1 Jan 2026 00:00:00 +0000"), + list(name = "Subject", value = "Test thread") + ) + ) + ) + mock_draft_row <- data.frame( + from = "al@test.com", + date = "Tue, 2 Jan 2026 00:00:00 +0000", + subject = "Re: Test thread", + body = "draft reply", + status = "draft", + stringsAsFactors = FALSE + ) + mockery::stub(mc_thread_read, "gmailr::gm_thread", + list(messages = list(mock_sent))) + mockery::stub(mc_thread_read, "fetch_thread_drafts", list(mock_draft_row)) + result <- mc_thread_read("fake_id", drafts = TRUE) + expect_equal(nrow(result), 2) + expect_equal(result$status, c("sent", "draft")) + expect_equal(result$body, c("sent", "draft reply")) +}) + +test_that("mc_thread_read empty thread with drafts=FALSE returns empty df", { + mockery::stub(mc_thread_read, "gmailr::gm_thread", + list(messages = NULL)) + result <- mc_thread_read("fake_id", drafts = FALSE) + expect_equal(nrow(result), 0) + expect_equal(names(result), c("from", "date", "subject", "body")) +}) + +test_that("mc_thread_read empty thread with drafts=TRUE returns empty df with status", { + mockery::stub(mc_thread_read, "gmailr::gm_thread", + list(messages = NULL)) + mockery::stub(mc_thread_read, "fetch_thread_drafts", list()) + result <- mc_thread_read("fake_id", drafts = TRUE) + expect_equal(nrow(result), 0) + expect_equal(names(result), c("from", "date", "subject", "body", "status")) +}) + test_that("extract_body finds plain text in simple payload", { payload <- list( mimeType = "text/plain",