Skip to content
Open
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
48 changes: 48 additions & 0 deletions NEWSLETTER_DIGEST_README.md
Original file line number Diff line number Diff line change
@@ -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.
148 changes: 148 additions & 0 deletions gmail_newsletter_digest.R
Original file line number Diff line number Diff line change
@@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Use supported base64 URL decoder

extract_text() calls base64urldecode(), which is not the current gmailr decode helper name, so decoding message bodies can fail at runtime with a missing-function error. Once any fetched message contains body data, digest generation breaks instead of producing output; switch to the supported decoder (base64url_decode/base64url_decode_to_char).

Useful? React with 👍 / 👎.

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) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Iterate with gm_id instead of ids$id

gm_messages() returns a gmail_messages collection, not a flat object with an id field, so ids$id is NULL and this loop never runs. In practice, fetch_newsletters() returns an empty list even when the query matches emails, which makes main() always emit "No messages found for this query." Extract message IDs first (e.g., via gm_id(ids, "message_id")) before calling gm_message().

Useful? React with 👍 / 👎.

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()