From 5b20d36128ae7d5a5e7795e36b1a3b6ac1063004 Mon Sep 17 00:00:00 2001 From: charles-hebert Date: Wed, 1 Apr 2026 20:52:48 -0400 Subject: [PATCH] Rewrite newsletter digest tool in R --- NEWSLETTER_DIGEST_README.md | 48 ++++++++++++ gmail_newsletter_digest.R | 148 ++++++++++++++++++++++++++++++++++++ 2 files changed, 196 insertions(+) create mode 100644 NEWSLETTER_DIGEST_README.md create mode 100644 gmail_newsletter_digest.R diff --git a/NEWSLETTER_DIGEST_README.md b/NEWSLETTER_DIGEST_README.md new file mode 100644 index 0000000..55d6711 --- /dev/null +++ b/NEWSLETTER_DIGEST_README.md @@ -0,0 +1,48 @@ +# Gmail Newsletter Digest with Ollama (R version) + +This script pulls newsletter-like emails from Gmail and asks your local Ollama model to build a digest. + +## 1) Setup + +Install R packages: + +```r +install.packages(c("argparse", "gmailr", "httr2", "jsonlite", "stringr")) +``` + +Enable Gmail API in Google Cloud and download OAuth Desktop credentials as `credentials.json` in this folder. + +## 2) Optional preference profile + +Create `preferences.txt` to guide ranking, for example: + +```text +Prioritize AI engineering, data, Python, product strategy, and practical tutorials. +Down-rank generic e-commerce promotions and celebrity/entertainment topics. +``` + +## 3) Run + +```bash +Rscript gmail_newsletter_digest.R \ + --query 'category:promotions newer_than:7d' \ + --max-messages 40 \ + --ollama-model llama3.1:8b \ + --preferences-file preferences.txt \ + --output newsletter_digest.md +``` + +Useful Gmail queries: +- `label:newsletters newer_than:14d` +- `category:promotions -from:(amazon.com) newer_than:7d` +- `from:(substack.com OR beehiiv.com) newer_than:10d` + +## 4) Matching to your preferences (recommended approach) + +Use a lightweight feedback loop: +1. Keep a `ratings.csv` with `message_id,rating` where rating is 1-5. +2. Add those examples to the prompt each run so Ollama learns your taste. +3. Promote senders/topics with avg rating >=4 and suppress <=2. +4. Once enough data exists, train a tiny local classifier (logistic regression on subject + snippet embeddings) and combine with LLM score. + +This gives progressively better filtering while staying local/private. diff --git a/gmail_newsletter_digest.R b/gmail_newsletter_digest.R new file mode 100644 index 0000000..0a705c0 --- /dev/null +++ b/gmail_newsletter_digest.R @@ -0,0 +1,148 @@ +#!/usr/bin/env Rscript + +suppressPackageStartupMessages({ + library(argparse) + library(gmailr) + library(httr2) + library(jsonlite) + library(stringr) +}) + +read_preferences <- function(path = NULL) { + if (is.null(path)) { + return(paste( + "Prefer newsletters about AI, software engineering, productivity, and practical tutorials.", + "Avoid celebrity news and generic promotions unless highly relevant." + )) + } + paste(readLines(path, warn = FALSE, encoding = "UTF-8"), collapse = "\n") +} + +extract_header <- function(headers, header_name) { + matches <- Filter(function(h) identical(tolower(h$name), tolower(header_name)), headers) + if (length(matches) == 0) return("") + matches[[1]]$value %||% "" +} + +`%||%` <- function(a, b) if (is.null(a)) b else a + +extract_text <- function(payload) { + if (!is.null(payload$parts)) { + plain_part <- NULL + html_part <- NULL + + for (part in payload$parts) { + mime <- part$mimeType %||% "" + if (identical(mime, "text/plain") && !is.null(part$body$data)) { + plain_part <- part + break + } + if (identical(mime, "text/html") && !is.null(part$body$data)) { + html_part <- part + } + } + + target <- plain_part %||% html_part + if (is.null(target)) return("") + + raw <- base64urldecode(target$body$data) + txt <- rawToChar(raw) + if (identical(target$mimeType, "text/html")) { + txt <- str_replace_all(txt, "<[^>]+>", " ") + txt <- str_squish(txt) + } + return(txt) + } + + if (!is.null(payload$body$data)) { + return(rawToChar(base64urldecode(payload$body$data))) + } + + "" +} + +fetch_newsletters <- function(query, max_messages) { + ids <- gm_messages(search = query, num_results = max_messages) + if (length(ids) == 0) return(list()) + + out <- list() + for (id in ids$id) { + msg <- gm_message(id) + payload <- msg$payload %||% list() + headers <- payload$headers %||% list() + + item <- list( + message_id = id, + from = extract_header(headers, "From"), + subject = extract_header(headers, "Subject"), + date = extract_header(headers, "Date"), + snippet = msg$snippet %||% "", + content = str_sub(extract_text(payload), 1, 8000) + ) + out[[length(out) + 1]] <- item + } + out +} + +build_prompt <- function(messages, preferences) { + serialized <- toJSON(messages, auto_unbox = TRUE, pretty = FALSE, null = "null") + paste0( + "You are a newsletter assistant. Given emails and user preferences, produce:\n", + "1) A concise digest grouped by theme.\n", + "2) Top 10 most relevant newsletters with one-line rationale.\n", + "3) A scoring rubric (0-100) based on user preferences.\n", + "4) A plan to improve matching over time.\n\n", + "User preferences:\n", preferences, "\n\n", + "Emails JSON:\n", str_sub(serialized, 1, 120000) + ) +} + +call_ollama <- function(model, host, prompt) { + req <- request(host) |> + req_url_path_append("api", "generate") |> + req_body_json(list(model = model, prompt = prompt, stream = FALSE)) |> + req_timeout(120) + + resp <- req_perform(req) + body <- resp_body_json(resp, simplifyVector = TRUE) + body$response %||% "" +} + +write_digest <- function(path, content) { + writeLines(enc2utf8(content), con = path, useBytes = TRUE) +} + +parse_args <- function() { + parser <- ArgumentParser(description = "Create a Gmail newsletter digest with local Ollama") + parser$add_argument("--query", default = "category:promotions newer_than:7d") + parser$add_argument("--max-messages", type = "integer", default = 30) + parser$add_argument("--ollama-model", default = "llama3.1:8b") + parser$add_argument("--ollama-host", default = "http://127.0.0.1:11434") + parser$add_argument("--preferences-file", default = NULL) + parser$add_argument("--output", default = "newsletter_digest.md") + parser$parse_args() +} + +main <- function() { + args <- parse_args() + + gm_auth_configure(path = "credentials.json") + gm_auth(email = TRUE) + + preferences <- read_preferences(args$`preferences-file`) + messages <- fetch_newsletters(args$query, args$`max-messages`) + + if (length(messages) == 0) { + write_digest(args$output, "No messages found for this query.") + cat("Digest created:", args$output, "\n") + return(invisible(NULL)) + } + + prompt <- build_prompt(messages, preferences) + digest <- call_ollama(args$`ollama-model`, args$`ollama-host`, prompt) + write_digest(args$output, digest) + + cat("Digest created:", args$output, "\n") +} + +main()