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
339 changes: 339 additions & 0 deletions cmd/gmail-reader/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
package main

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"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 {
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
fmt.Printf("Go to the following link in your browser then type the "+
"authorization code: \n%v\n", authURL)

var authCode string
if _, err := fmt.Scan(&authCode); err != nil {
log.Fatalf("Unable to read authorization code: %v", err)
}

tok, err := config.Exchange(context.TODO(), 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)
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.

json.NewEncoder(f).Encode(token) error is ignored. If the token can’t be written (disk full, permission issues after open, etc.), the script may proceed but re-auth every run. Handle/return the encode error (and consider logging without printing sensitive paths).

Suggested change
json.NewEncoder(f).Encode(token)
if err := json.NewEncoder(f).Encode(token); err != nil {
log.Fatalf("Unable to encode oauth token to file: %v", err)
}

Copilot uses AI. Check for mistakes.
}

// 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",
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 default webhook URL (http://localhost:8080/webhook) doesn’t match the repo’s server route (POST /message in internal/server/server.go). With defaults, this script will 404 against the local server-bot. Consider defaulting to /message (or documenting that this targets a different endpoint).

Suggested change
WebhookURL: "http://localhost:8080/webhook",
WebhookURL: "http://localhost:8080/message",

Copilot uses AI. Check for mistakes.
SearchQuery: "label:Assistantis: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)
}
}

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 +240 to +260
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 +276 to +282
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 +292 to +293
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 +291 to +300
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, `"`, `\"`)
}
31 changes: 22 additions & 9 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,31 +8,44 @@ 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
google.golang.org/genai v1.33.0
golang.org/x/oauth2 v0.23.0
google.golang.org/api v0.197.0
google.golang.org/genai v1.49.0
)

require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.9.3 // indirect
cloud.google.com/go/compute/metadata v0.5.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
cloud.google.com/go/compute/metadata v0.5.2 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/fatih/color v1.7.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.13.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-colorable v0.1.2 // indirect
github.com/mattn/go-isatty v0.0.8 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.opencensus.io v0.24.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/term v0.24.0 // indirect
golang.org/x/text v0.18.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect
golang.org/x/crypto v0.36.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/term v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.66.2 // indirect
google.golang.org/protobuf v1.34.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
Loading
Loading