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
5 changes: 1 addition & 4 deletions .lintr
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
linters: linters_with_defaults(
quotes_linter = NULL,
line_length_linter = line_length_linter(120)
)
linters: linters_with_defaults(quotes_linter = NULL, line_length_linter = line_length_linter(120), object_usage_linter = NULL)
23 changes: 21 additions & 2 deletions R/mc_auth.R
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,39 @@
#' email address. Call once per session before [mc_send()].
#'
#' @param email Email address to authenticate as.
#' Default `"al@newgraphenvironment.com"`.
#' Default uses `getOption("mc.from")`, then the `MC_FROM` environment
#' variable, then `"al@newgraphenvironment.com"`.
#'
#' @return Invisible `NULL`. Called for side effect of authenticating.
#'
#' @examples
#' \dontrun{
#' mc_auth()
#'
#' # Set globally in .Rprofile to avoid passing email every time:
#' options(mc.from = "you@example.com")
#' }
#'
#' @importFrom chk chk_string
#' @importFrom gmailr gm_auth
#' @export
mc_auth <- function(email = "al@newgraphenvironment.com") {
mc_auth <- function(email = default_from()) {
chk::chk_string(email)
gmailr::gm_auth(email = email)
invisible(NULL)
}


#' Get the default sender address
#'
#' Checks `getOption("mc.from")`, then `MC_FROM` env var, then falls back
#' to `"al@newgraphenvironment.com"`.
#' @return Character string.
#' @noRd
default_from <- function() {
from <- getOption("mc.from")
if (!is.null(from)) return(from)
env <- Sys.getenv("MC_FROM", unset = "")
if (nzchar(env)) return(env)
"al@newgraphenvironment.com"
}
10 changes: 5 additions & 5 deletions R/mc_compose.R
Original file line number Diff line number Diff line change
Expand Up @@ -96,12 +96,12 @@ resolve_part <- function(part) {
}

# Markdown file — render it
if (grepl("\\.md$", part, ignore.case = TRUE) && file.exists(part)) {
raw <- paste(readLines(part, warn = FALSE), collapse = "\n")
# Strip header above --- if present
if (grepl("---", raw, fixed = TRUE)) {
raw <- sub("^[\\s\\S]*?---\\s*\\n", "", raw, perl = TRUE)
if (grepl("\\.md$", part, ignore.case = TRUE)) {
if (!file.exists(part)) {
stop("File not found: ", part, call. = FALSE)
}
raw <- paste(readLines(part, warn = FALSE), collapse = "\n")
raw <- strip_md_header(raw)
return(commonmark::markdown_html(raw, extensions = TRUE))
}

Expand Down
18 changes: 17 additions & 1 deletion R/mc_md_render.R
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ mc_md_render <- function(path, sig = TRUE, sig_path = NULL) {
raw <- paste(readLines(path, warn = FALSE), collapse = "\n")

# Strip header: everything up to and including the --- separator
body_md <- sub("^[\\s\\S]*?---\\s*\\n", "", raw, perl = TRUE)
body_md <- strip_md_header(raw)

# Convert markdown to HTML
body_html <- commonmark::markdown_html(body_md, extensions = TRUE)
Expand All @@ -72,6 +72,22 @@ mc_md_render <- function(path, sig = TRUE, sig_path = NULL) {
}


#' Strip the header above the first `---` separator in markdown
#'
#' Used by both [mc_md_render()] and `resolve_part()` in [mc_compose()].
#' If no `---` is found, the full text is returned unchanged.
#' @param md Character string of raw markdown.
#' @return Markdown with header stripped.
#' @noRd
strip_md_header <- function(md) {
if (grepl("---", md, fixed = TRUE)) {
sub("^[\\s\\S]*?---\\s*\\n", "", md, perl = TRUE)
} else {
md
}
}


#' Add inline styles to HTML tables for Gmail
#'
#' Gmail strips `<style>` blocks so table styling must be inline.
Expand Down
85 changes: 74 additions & 11 deletions R/mc_send.R
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
#' @param subject Email subject line.
#' @param cc Optional CC recipients (character vector). Default `NULL`.
#' @param bcc Optional BCC recipients (character vector). Default `NULL`.
#' @param from Sender address. Default `"al@newgraphenvironment.com"`.
#' @param from Sender address. Default uses `getOption("mc.from")`,
#' then the `MC_FROM` environment variable, then
#' `"al@newgraphenvironment.com"` as a final fallback.
#' @param thread_id Gmail thread ID to reply into. Default `NULL` (new thread).
#' Use [mc_thread_find()] to look up thread IDs.
#' @param draft Logical. If `TRUE` (default), create a Gmail draft.
Expand Down Expand Up @@ -110,7 +112,7 @@ mc_send <- function(path = NULL,
subject,
cc = NULL,
bcc = NULL,
from = "al@newgraphenvironment.com",
from = default_from(),
thread_id = NULL,
draft = TRUE,
test = FALSE,
Expand Down Expand Up @@ -154,20 +156,39 @@ mc_send <- function(path = NULL,
# Check if we missed the window (machine was asleep)
late <- as.numeric(difftime(Sys.time(), target_time, units = "secs"))
if (late > grace_secs) {
stop(
msg <- paste0(
"Scheduled send SKIPPED. Machine woke ",
round(late / 60, 1), " min past target time ",
format(target_time, "%H:%M:%S"),
". Draft not sent to protect against stale context.",
call. = FALSE
". Draft not sent to protect against stale context."
)
mc:::send_log(subject, to, "SKIPPED", msg)
mc:::send_notify(paste0("SKIPPED: ", subject), msg)
stop(msg, call. = FALSE)
}
mc::mc_send(
path = path, to = to, subject = subject,
cc = cc, bcc = bcc, from = from,
thread_id = thread_id, draft = FALSE,
test = test, sig = sig, sig_path = sig_path,
html = html, send_at = NULL
tryCatch(
{
mc::mc_send(
path = path, to = to, subject = subject,
cc = cc, bcc = bcc, from = from,
thread_id = thread_id, draft = FALSE,
test = test, sig = sig, sig_path = sig_path,
html = html, send_at = NULL
)
mc:::send_log(subject, to, "SENT")
mc:::send_notify(
paste0("Sent: ", subject),
paste0("To: ", paste(to, collapse = ", "))
)
},
error = function(e) {
mc:::send_log(subject, to, "FAILED", conditionMessage(e))
mc:::send_notify(
paste0("FAILED: ", subject),
conditionMessage(e)
)
stop(e)
}
)
},
args = list(
Expand Down Expand Up @@ -260,6 +281,48 @@ caffeinate_send <- function(proc) {
}


#' Log a scheduled send outcome to ~/.mc/send_log.txt
#'
#' Appends one line per event. Creates the directory if needed.
#' @param subject Email subject.
#' @param to Recipient(s).
#' @param status One of "SENT", "SKIPPED", "FAILED".
#' @param detail Optional detail message.
#' @noRd
send_log <- function(subject, to, status, detail = "") {
log_dir <- file.path(Sys.getenv("HOME"), ".mc")
if (!dir.exists(log_dir)) dir.create(log_dir, recursive = TRUE)
line <- paste0(
format(Sys.time(), "%Y-%m-%d %H:%M:%S"), " | ",
status, " | ",
"To: ", paste(to, collapse = ", "), " | ",
"Subject: ", subject,
if (nzchar(detail)) paste0(" | ", detail) else ""
)
cat(line, "\n", file = file.path(log_dir, "send_log.txt"), append = TRUE)
}


#' Show a macOS desktop notification for scheduled send outcomes
#'
#' Uses `osascript` to display a notification. No-op on non-macOS systems.
#' @param title Notification title.
#' @param body Notification body.
#' @noRd
send_notify <- function(title, body) {
if (Sys.info()[["sysname"]] != "Darwin") return(invisible(NULL))
script <- paste0(
'display notification "', gsub('"', '\\\\"', body),
'" with title "mc" subtitle "', gsub('"', '\\\\"', title), '"'
)
tryCatch(
system2("osascript", args = c("-e", script), stdout = FALSE, stderr = FALSE),
error = function(e) NULL
)
invisible(NULL)
}


#' Convert send_at value to a target POSIXct time
#' @param send_at POSIXct datetime or numeric minutes from now.
#' @return POSIXct target time.
Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,26 @@ that every email script repeats.
pak::pak("NewGraphEnvironment/mc")
```

## Setup

Set your default sender address in `~/.Rprofile`:

```r
options(mc.from = "you@example.com")
```

Or via environment variable in `~/.Renviron`:

```
MC_FROM=you@example.com
```

Then authenticate once per machine:

```r
mc_auth()
```

## Usage

Write your email body in markdown. Everything above the `---` separator is
Expand Down Expand Up @@ -140,6 +160,9 @@ laptop lid can be closed as long as power is connected. If the machine
sleeps through the send window (e.g., power loss), a 5-minute grace period
applies — past that, the send is skipped to prevent stale emails.

Outcomes are logged to `~/.mc/send_log.txt` and trigger a macOS desktop
notification on success, skip, or failure.

## Test mode

Send to yourself to preview:
Expand Down
8 changes: 6 additions & 2 deletions man/mc_auth.Rd

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

6 changes: 4 additions & 2 deletions man/mc_send.Rd

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

17 changes: 17 additions & 0 deletions tests/testthat/test-mc_compose.R
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,23 @@ test_that("mc_compose errors on bad types", {
expect_error(mc_compose(sig = "yes"))
})

test_that("mc_compose errors on non-existent .md file", {
expect_error(
mc_compose("nonexistent_file.md", sig = FALSE),
"File not found"
)
})

test_that("strip_md_header removes content above ---", {
md <- "# Header\n\n**To:** bob\n\n---\n\nBody text."
expect_equal(mc:::strip_md_header(md), "Body text.")
})

test_that("strip_md_header returns unchanged when no ---", {
md <- "Just a paragraph."
expect_equal(mc:::strip_md_header(md), "Just a paragraph.")
})

test_that("mc_compose mixes md file, kable, and HTML", {
md_tmp <- tempfile(fileext = ".md")
writeLines(c("---", "", "Opening paragraph."), md_tmp)
Expand Down
Loading