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: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,6 @@ history-gemini/*
!history-gemini/.gitkeep
bot-context/*
!bot-context/.gitkeep

credentials.json
token.json
366 changes: 366 additions & 0 deletions cmd/gmail-reader/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,366 @@
package main

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"mime/multipart"
"net/http"
"os"
"strconv"
"strings"
"time"

"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)

// Retrieve a token, saves the token, then returns the generated client.
func getClient(config *oauth2.Config) *http.Client {
// The file token.json stores the user's access and refresh tokens, and is
// created automatically when the authorization flow completes for the first
// time.
tokFile := "token.json"
tok, err := tokenFromFile(tokFile)
if err != nil {
tok = getTokenFromWeb(config)
saveToken(tokFile, tok)
}
return config.Client(context.Background(), tok)
}

// Request a token from the web, then returns the retrieved token.
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
// Start a local web server to catch the callback
codeCh := make(chan string)
mux := http.NewServeMux()
srv := &http.Server{Addr: "localhost:9099", Handler: mux}

mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
if code != "" {
fmt.Fprintf(w, "Authorization successful! You can close this window and return to the terminal.")
codeCh <- code
} else {
fmt.Fprintf(w, "Error: No code found in request.")
}
})

go func() {
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("HTTP server ListenAndServe: %v", err)
}
}()

// Ensure the redirect URL is set to our local server
config.RedirectURL = "http://localhost:9099/"

authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
fmt.Printf("Go to the following link in your browser to authorize:\n%v\n\nWaiting for authorization...\n", authURL)

// Wait for the code
authCode := <-codeCh

// Shutdown the server gracefully
if err := srv.Shutdown(context.Background()); err != nil {
log.Printf("HTTP server Shutdown: %v", err)
}

tok, err := config.Exchange(context.Background(), authCode)
if err != nil {
log.Fatalf("Unable to retrieve token from web: %v", err)
}
return tok
}

// Retrieves a token from a local file.
func tokenFromFile(file string) (*oauth2.Token, error) {
f, err := os.Open(file)
if err != nil {
return nil, err
}
defer f.Close()
tok := &oauth2.Token{}
err = json.NewDecoder(f).Decode(tok)
return tok, err
}

// Saves a token to a file path.
func saveToken(path string, token *oauth2.Token) {
fmt.Printf("Saving credential file to: %s\n", path)
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
log.Fatalf("Unable to cache oauth token: %v", err)
}
defer f.Close()
json.NewEncoder(f).Encode(token)
}

// Configuration holds the configurable variables
type Configuration struct {
PollingInterval time.Duration
WebhookURL string
SearchQuery string
}

func loadConfig() Configuration {
cfg := Configuration{
WebhookURL: "http://localhost:8080/webhook",
SearchQuery: "(label:Assistant OR to:gergo254+assistant@gmail.com) is:unread",
PollingInterval: 60 * time.Second,
}

if val := os.Getenv("WEBHOOK_URL"); val != "" {
cfg.WebhookURL = val
}
if val := os.Getenv("SEARCH_QUERY"); val != "" {
cfg.SearchQuery = val
}
if val := os.Getenv("POLLING_INTERVAL"); val != "" {
if i, err := strconv.Atoi(val); err == nil {
cfg.PollingInterval = time.Duration(i) * time.Second
} else {
log.Printf("Invalid POLLING_INTERVAL: %v, using default 60s", err)
}
}

return cfg
}

type emailData struct {
ID string
Sender string
Subject string
Date string
Body string
Attachments []attachment
}

type attachment struct {
Filename string
Data []byte
MimeType string
}

func main() {
cfg := loadConfig()
log.Printf("Starting Gmail Reader Script with config: %+v\n", cfg)

ctx := context.Background()

b, err := os.ReadFile("credentials.json")
if err != nil {
log.Fatalf("Unable to read client secret file: %v. Please make sure credentials.json is present in the current directory.", err)
}

// If modifying these scopes, delete your previously saved token.json.
config, err := google.ConfigFromJSON(b, gmail.MailGoogleComScope)
if err != nil {
log.Fatalf("Unable to parse client secret file to config: %v", err)
}
client := getClient(config)

srv, err := gmail.NewService(ctx, option.WithHTTPClient(client))
if err != nil {
log.Fatalf("Unable to retrieve Gmail client: %v", err)
}

// Start polling loop
ticker := time.NewTicker(cfg.PollingInterval)
defer ticker.Stop()

// Run once immediately
pollEmails(srv, cfg)

for range ticker.C {
pollEmails(srv, cfg)
}
}

func pollEmails(srv *gmail.Service, cfg Configuration) {
log.Printf("Polling emails with query: %s\n", cfg.SearchQuery)
user := "me"

r, err := srv.Users.Messages.List(user).Q(cfg.SearchQuery).MaxResults(5).Do()
if err != nil {
log.Printf("Unable to retrieve messages: %v", err)
return
}

if len(r.Messages) == 0 {
log.Println("No matching messages found.")
return
}

for _, m := range r.Messages {
processMessage(srv, user, m.Id, cfg.WebhookURL)
Comment thread
Gerifield marked this conversation as resolved.
}
}

func processMessage(srv *gmail.Service, user string, msgId string, webhookURL string) {
msg, err := srv.Users.Messages.Get(user, msgId).Format("full").Do()
if err != nil {
log.Printf("Unable to get message %s: %v", msgId, err)
return
}

data := extractEmailData(srv, user, msg)

// Send to webhook
err = forwardToWebhook(data, webhookURL)
if err != nil {
log.Printf("Failed to forward message %s: %v", msgId, err)
// Leave as unread so it gets picked up again
return
}

// Mark as read
log.Printf("Message %s forwarded successfully, marking as read.", msgId)
mods := &gmail.ModifyMessageRequest{
RemoveLabelIds: []string{"UNREAD"},
}
_, err = srv.Users.Messages.Modify(user, msgId, mods).Do()
if err != nil {
log.Printf("Unable to modify message %s: %v", msgId, err)
}
}

func extractEmailData(srv *gmail.Service, user string, msg *gmail.Message) emailData {
data := emailData{
ID: msg.Id,
}

// Extract headers
for _, header := range msg.Payload.Headers {
switch strings.ToLower(header.Name) {
case "from":
data.Sender = header.Value
case "subject":
data.Subject = header.Value
case "date":
data.Date = header.Value
}
}

// Extract body and attachments
extractParts(srv, user, msg.Id, msg.Payload, &data)

return data
}

func extractParts(srv *gmail.Service, user string, msgId string, part *gmail.MessagePart, data *emailData) {
if part.Filename != "" && part.Body != nil && part.Body.AttachmentId != "" {
// It's an attachment
attachObj, err := srv.Users.Messages.Attachments.Get(user, msgId, part.Body.AttachmentId).Do()
if err != nil {
log.Printf("Unable to get attachment %s for message %s: %v", part.Filename, msgId, err)
return
}

decoded, err := base64.URLEncoding.DecodeString(attachObj.Data)
if err != nil {
log.Printf("Unable to decode attachment %s: %v", part.Filename, err)
return
}

data.Attachments = append(data.Attachments, attachment{
Filename: part.Filename,
Data: decoded,
MimeType: part.MimeType,
})
} else if part.MimeType == "text/plain" && part.Body != nil && part.Body.Data != "" {
// It's the body
decoded, err := base64.URLEncoding.DecodeString(part.Body.Data)
if err == nil {
data.Body += string(decoded)
}
} else if part.MimeType == "text/html" && data.Body == "" && part.Body != nil && part.Body.Data != "" {
// Fallback to HTML body if plain text is not found yet
decoded, err := base64.URLEncoding.DecodeString(part.Body.Data)
if err == nil {
Comment on lines +267 to +287
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

Gmail message/attachment bodies are base64url-encoded and commonly omit padding. Using base64.URLEncoding.DecodeString will fail on unpadded data. Prefer base64.RawURLEncoding.DecodeString(...) (or URLEncoding.WithPadding(base64.NoPadding)) for attachObj.Data and part.Body.Data.

Copilot uses AI. Check for mistakes.
data.Body += string(decoded)
}
}

// Recursively check parts
for _, p := range part.Parts {
extractParts(srv, user, msgId, p, data)
}
}

func forwardToWebhook(data emailData, webhookURL string) error {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)

// Add metadata fields
_ = writer.WriteField("Sender", data.Sender)
_ = writer.WriteField("Subject", data.Subject)
_ = writer.WriteField("Date", data.Date)

// Add body if no attachments or to provide context
if data.Body != "" {
_ = writer.WriteField("Body", data.Body)
Comment on lines +303 to +309
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

writer.WriteField(...) return values are ignored. If a write fails, the script will still send the request (possibly missing required fields) with no surfaced error. Capture these errors and return early so the message remains unread and can be retried with a clear log.

Suggested change
_ = writer.WriteField("Sender", data.Sender)
_ = writer.WriteField("Subject", data.Subject)
_ = writer.WriteField("Date", data.Date)
// Add body if no attachments or to provide context
if data.Body != "" {
_ = writer.WriteField("Body", data.Body)
if err := writer.WriteField("Sender", data.Sender); err != nil {
return fmt.Errorf("failed to write Sender field: %w", err)
}
if err := writer.WriteField("Subject", data.Subject); err != nil {
return fmt.Errorf("failed to write Subject field: %w", err)
}
if err := writer.WriteField("Date", data.Date); err != nil {
return fmt.Errorf("failed to write Date field: %w", err)
}
// Add body if no attachments or to provide context
if data.Body != "" {
if err := writer.WriteField("Body", data.Body); err != nil {
return fmt.Errorf("failed to write Body field: %w", err)
}

Copilot uses AI. Check for mistakes.
}

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

The repo’s /message endpoint reads the user message from form field message (r.PostFormValue("message")). This client sends Sender/Subject/Date/Body fields but never sends message, so the server will see an empty prompt. Consider populating a message field (e.g., subject + body + metadata) and keeping payload for attachments.

Suggested change
// Populate unified message field expected by the /message endpoint.
// This combines metadata and body so that r.PostFormValue("message")
// on the server side receives a meaningful prompt.
var messageContentBuilder strings.Builder
if data.Sender != "" {
messageContentBuilder.WriteString("From: ")
messageContentBuilder.WriteString(data.Sender)
messageContentBuilder.WriteString("\n")
}
if data.Subject != "" {
messageContentBuilder.WriteString("Subject: ")
messageContentBuilder.WriteString(data.Subject)
messageContentBuilder.WriteString("\n")
}
if data.Date != "" {
messageContentBuilder.WriteString("Date: ")
messageContentBuilder.WriteString(data.Date)
messageContentBuilder.WriteString("\n")
}
if data.Body != "" {
messageContentBuilder.WriteString("\n")
messageContentBuilder.WriteString(data.Body)
}
messageContent := messageContentBuilder.String()
if messageContent != "" {
_ = writer.WriteField("message", messageContent)
}

Copilot uses AI. Check for mistakes.
// Add attachments
for _, att := range data.Attachments {
// According to requirements, add as multiple files with name "payload"
// or whatever array name is expected. I will use "payload" as array name
// For proper boundary format, we use CreatePart instead of CreateFormFile
// to set the content-disposition and content-type precisely
h := make(map[string][]string)
h["Content-Disposition"] = []string{fmt.Sprintf(`form-data; name="payload"; filename="%s"`, escapeQuotes(att.Filename))}
if att.MimeType != "" {
Comment on lines +319 to +320
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

Attachment filenames come from email content and can contain CR/LF or other special characters. Building Content-Disposition manually with fmt.Sprintf(...) risks header injection / malformed multipart bodies. Prefer multipart.FileContentDisposition(...) (already used in pkg/httpBotter/logic.go) or explicitly reject/strip \r and \n in filenames.

Copilot uses AI. Check for mistakes.
h["Content-Type"] = []string{att.MimeType}
} else {
h["Content-Type"] = []string{"application/octet-stream"}
}

part, err := writer.CreatePart(h)
if err != nil {
Comment on lines +318 to +327
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

multipart.Writer.CreatePart expects a textproto.MIMEHeader, but this code builds a plain map[string][]string. This won’t compile (cannot use h (type map[string][]string) as textproto.MIMEHeader). Use make(textproto.MIMEHeader) (and import net/textproto), or follow the existing pattern used in pkg/httpBotter/logic.go.

Copilot uses AI. Check for mistakes.
return fmt.Errorf("could not create form file for %s: %v", att.Filename, err)
}
_, err = io.Copy(part, bytes.NewReader(att.Data))
if err != nil {
return fmt.Errorf("could not copy file data for %s: %v", att.Filename, err)
}
}

err := writer.Close()
if err != nil {
return fmt.Errorf("failed to close multipart writer: %v", err)
}

req, err := http.NewRequest("POST", webhookURL, body)
if err != nil {
return fmt.Errorf("failed to create HTTP request: %v", err)
}

req.Header.Set("Content-Type", writer.FormDataContentType())

client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send HTTP request: %v", err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webhook returned non-2xx status code: %d, body: %s", resp.StatusCode, string(respBody))
}

return nil
}

// escapeQuotes escapes double quotes for header values
func escapeQuotes(s string) string {
return strings.ReplaceAll(s, `"`, `\"`)
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@ require (
github.com/go-telegram/bot v1.17.0
github.com/mark3labs/mcp-go v0.29.1-0.20250521213157-f99e5472f312
github.com/philippgille/chromem-go v0.7.0
golang.org/x/oauth2 v0.30.0
google.golang.org/api v0.236.0
google.golang.org/genai v1.51.0
)

require (
cloud.google.com/go v0.120.0 // indirect
cloud.google.com/go/auth v0.16.2 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
cloud.google.com/go/compute/metadata v0.7.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/buger/jsonparser v1.1.1 // indirect
Expand Down
Loading
Loading