Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion DESCRIPTION
Original file line number Diff line number Diff line change
@@ -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"))
Expand All @@ -19,6 +19,7 @@ Suggests:
callr,
kableExtra,
knitr,
mockery,
rmarkdown,
testthat (>= 3.0.0)
Config/testthat/edition: 3
Expand Down
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
95 changes: 80 additions & 15 deletions R/mc_thread_find.R
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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
}


Expand Down
10 changes: 8 additions & 2 deletions man/mc_thread_read.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

100 changes: 100 additions & 0 deletions tests/testthat/test-mc_thread_read.R
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down