Skip to content

Commit 54e6960

Browse files
feat: add standalone Go background script for reading Gmail with attachments
Adds `cmd/gmail-reader/main.go` that: * Authenticates via OAuth 2.0 Desktop flow to the Gmail API. * Polls for specific emails matching a configurable query using `time.Ticker`. * Extracts email metadata and attachments. * Forwards attachments as multiple files in a multipart/form-data POST request under the `payload` key to a configurable local Webhook URL. * Marks successfully forwarded emails as read to prevent duplicate processing. * Retrieves configurations for polling interval, webhook URL, and search query via standard environment variables. Co-authored-by: Gerifield <195914+Gerifield@users.noreply.github.com>
1 parent c62e17f commit 54e6960

3 files changed

Lines changed: 352 additions & 0 deletions

File tree

cmd/gmail-reader/main.go

Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/base64"
7+
"encoding/json"
8+
"fmt"
9+
"io"
10+
"log"
11+
"mime/multipart"
12+
"net/http"
13+
"os"
14+
"strconv"
15+
"strings"
16+
"time"
17+
18+
"golang.org/x/oauth2"
19+
"golang.org/x/oauth2/google"
20+
"google.golang.org/api/gmail/v1"
21+
"google.golang.org/api/option"
22+
)
23+
24+
// Retrieve a token, saves the token, then returns the generated client.
25+
func getClient(config *oauth2.Config) *http.Client {
26+
// The file token.json stores the user's access and refresh tokens, and is
27+
// created automatically when the authorization flow completes for the first
28+
// time.
29+
tokFile := "token.json"
30+
tok, err := tokenFromFile(tokFile)
31+
if err != nil {
32+
tok = getTokenFromWeb(config)
33+
saveToken(tokFile, tok)
34+
}
35+
return config.Client(context.Background(), tok)
36+
}
37+
38+
// Request a token from the web, then returns the retrieved token.
39+
func getTokenFromWeb(config *oauth2.Config) *oauth2.Token {
40+
authURL := config.AuthCodeURL("state-token", oauth2.AccessTypeOffline)
41+
fmt.Printf("Go to the following link in your browser then type the "+
42+
"authorization code: \n%v\n", authURL)
43+
44+
var authCode string
45+
if _, err := fmt.Scan(&authCode); err != nil {
46+
log.Fatalf("Unable to read authorization code: %v", err)
47+
}
48+
49+
tok, err := config.Exchange(context.TODO(), authCode)
50+
if err != nil {
51+
log.Fatalf("Unable to retrieve token from web: %v", err)
52+
}
53+
return tok
54+
}
55+
56+
// Retrieves a token from a local file.
57+
func tokenFromFile(file string) (*oauth2.Token, error) {
58+
f, err := os.Open(file)
59+
if err != nil {
60+
return nil, err
61+
}
62+
defer f.Close()
63+
tok := &oauth2.Token{}
64+
err = json.NewDecoder(f).Decode(tok)
65+
return tok, err
66+
}
67+
68+
// Saves a token to a file path.
69+
func saveToken(path string, token *oauth2.Token) {
70+
fmt.Printf("Saving credential file to: %s\n", path)
71+
f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
72+
if err != nil {
73+
log.Fatalf("Unable to cache oauth token: %v", err)
74+
}
75+
defer f.Close()
76+
json.NewEncoder(f).Encode(token)
77+
}
78+
79+
// Configuration holds the configurable variables
80+
type Configuration struct {
81+
PollingInterval time.Duration
82+
WebhookURL string
83+
SearchQuery string
84+
}
85+
86+
func loadConfig() Configuration {
87+
cfg := Configuration{
88+
WebhookURL: "http://localhost:8080/webhook",
89+
SearchQuery: "label:Assistantis:unread",
90+
PollingInterval: 60 * time.Second,
91+
}
92+
93+
if val := os.Getenv("WEBHOOK_URL"); val != "" {
94+
cfg.WebhookURL = val
95+
}
96+
if val := os.Getenv("SEARCH_QUERY"); val != "" {
97+
cfg.SearchQuery = val
98+
}
99+
if val := os.Getenv("POLLING_INTERVAL"); val != "" {
100+
if i, err := strconv.Atoi(val); err == nil {
101+
cfg.PollingInterval = time.Duration(i) * time.Second
102+
} else {
103+
log.Printf("Invalid POLLING_INTERVAL: %v, using default 60s", err)
104+
}
105+
}
106+
107+
return cfg
108+
}
109+
110+
type emailData struct {
111+
ID string
112+
Sender string
113+
Subject string
114+
Date string
115+
Body string
116+
Attachments []attachment
117+
}
118+
119+
type attachment struct {
120+
Filename string
121+
Data []byte
122+
MimeType string
123+
}
124+
125+
func main() {
126+
cfg := loadConfig()
127+
log.Printf("Starting Gmail Reader Script with config: %+v\n", cfg)
128+
129+
ctx := context.Background()
130+
131+
b, err := os.ReadFile("credentials.json")
132+
if err != nil {
133+
log.Fatalf("Unable to read client secret file: %v. Please make sure credentials.json is present in the current directory.", err)
134+
}
135+
136+
// If modifying these scopes, delete your previously saved token.json.
137+
config, err := google.ConfigFromJSON(b, gmail.MailGoogleComScope)
138+
if err != nil {
139+
log.Fatalf("Unable to parse client secret file to config: %v", err)
140+
}
141+
client := getClient(config)
142+
143+
srv, err := gmail.NewService(ctx, option.WithHTTPClient(client))
144+
if err != nil {
145+
log.Fatalf("Unable to retrieve Gmail client: %v", err)
146+
}
147+
148+
// Start polling loop
149+
ticker := time.NewTicker(cfg.PollingInterval)
150+
defer ticker.Stop()
151+
152+
// Run once immediately
153+
pollEmails(srv, cfg)
154+
155+
for range ticker.C {
156+
pollEmails(srv, cfg)
157+
}
158+
}
159+
160+
func pollEmails(srv *gmail.Service, cfg Configuration) {
161+
log.Printf("Polling emails with query: %s\n", cfg.SearchQuery)
162+
user := "me"
163+
164+
r, err := srv.Users.Messages.List(user).Q(cfg.SearchQuery).MaxResults(5).Do()
165+
if err != nil {
166+
log.Printf("Unable to retrieve messages: %v", err)
167+
return
168+
}
169+
170+
if len(r.Messages) == 0 {
171+
log.Println("No matching messages found.")
172+
return
173+
}
174+
175+
for _, m := range r.Messages {
176+
processMessage(srv, user, m.Id, cfg.WebhookURL)
177+
}
178+
}
179+
180+
func processMessage(srv *gmail.Service, user string, msgId string, webhookURL string) {
181+
msg, err := srv.Users.Messages.Get(user, msgId).Format("full").Do()
182+
if err != nil {
183+
log.Printf("Unable to get message %s: %v", msgId, err)
184+
return
185+
}
186+
187+
data := extractEmailData(srv, user, msg)
188+
189+
// Send to webhook
190+
err = forwardToWebhook(data, webhookURL)
191+
if err != nil {
192+
log.Printf("Failed to forward message %s: %v", msgId, err)
193+
// Leave as unread so it gets picked up again
194+
return
195+
}
196+
197+
// Mark as read
198+
log.Printf("Message %s forwarded successfully, marking as read.", msgId)
199+
mods := &gmail.ModifyMessageRequest{
200+
RemoveLabelIds: []string{"UNREAD"},
201+
}
202+
_, err = srv.Users.Messages.Modify(user, msgId, mods).Do()
203+
if err != nil {
204+
log.Printf("Unable to modify message %s: %v", msgId, err)
205+
}
206+
}
207+
208+
func extractEmailData(srv *gmail.Service, user string, msg *gmail.Message) emailData {
209+
data := emailData{
210+
ID: msg.Id,
211+
}
212+
213+
// Extract headers
214+
for _, header := range msg.Payload.Headers {
215+
switch strings.ToLower(header.Name) {
216+
case "from":
217+
data.Sender = header.Value
218+
case "subject":
219+
data.Subject = header.Value
220+
case "date":
221+
data.Date = header.Value
222+
}
223+
}
224+
225+
// Extract body and attachments
226+
extractParts(srv, user, msg.Id, msg.Payload, &data)
227+
228+
return data
229+
}
230+
231+
func extractParts(srv *gmail.Service, user string, msgId string, part *gmail.MessagePart, data *emailData) {
232+
if part.Filename != "" && part.Body != nil && part.Body.AttachmentId != "" {
233+
// It's an attachment
234+
attachObj, err := srv.Users.Messages.Attachments.Get(user, msgId, part.Body.AttachmentId).Do()
235+
if err != nil {
236+
log.Printf("Unable to get attachment %s for message %s: %v", part.Filename, msgId, err)
237+
return
238+
}
239+
240+
decoded, err := base64.URLEncoding.DecodeString(attachObj.Data)
241+
if err != nil {
242+
log.Printf("Unable to decode attachment %s: %v", part.Filename, err)
243+
return
244+
}
245+
246+
data.Attachments = append(data.Attachments, attachment{
247+
Filename: part.Filename,
248+
Data: decoded,
249+
MimeType: part.MimeType,
250+
})
251+
} else if part.MimeType == "text/plain" && part.Body != nil && part.Body.Data != "" {
252+
// It's the body
253+
decoded, err := base64.URLEncoding.DecodeString(part.Body.Data)
254+
if err == nil {
255+
data.Body += string(decoded)
256+
}
257+
} else if part.MimeType == "text/html" && data.Body == "" && part.Body != nil && part.Body.Data != "" {
258+
// Fallback to HTML body if plain text is not found yet
259+
decoded, err := base64.URLEncoding.DecodeString(part.Body.Data)
260+
if err == nil {
261+
data.Body += string(decoded)
262+
}
263+
}
264+
265+
// Recursively check parts
266+
for _, p := range part.Parts {
267+
extractParts(srv, user, msgId, p, data)
268+
}
269+
}
270+
271+
func forwardToWebhook(data emailData, webhookURL string) error {
272+
body := &bytes.Buffer{}
273+
writer := multipart.NewWriter(body)
274+
275+
// Add metadata fields
276+
_ = writer.WriteField("Sender", data.Sender)
277+
_ = writer.WriteField("Subject", data.Subject)
278+
_ = writer.WriteField("Date", data.Date)
279+
280+
// Add body if no attachments or to provide context
281+
if data.Body != "" {
282+
_ = writer.WriteField("Body", data.Body)
283+
}
284+
285+
// Add attachments
286+
for _, att := range data.Attachments {
287+
// According to requirements, add as multiple files with name "payload"
288+
// or whatever array name is expected. I will use "payload" as array name
289+
// For proper boundary format, we use CreatePart instead of CreateFormFile
290+
// to set the content-disposition and content-type precisely
291+
h := make(map[string][]string)
292+
h["Content-Disposition"] = []string{fmt.Sprintf(`form-data; name="payload"; filename="%s"`, escapeQuotes(att.Filename))}
293+
if att.MimeType != "" {
294+
h["Content-Type"] = []string{att.MimeType}
295+
} else {
296+
h["Content-Type"] = []string{"application/octet-stream"}
297+
}
298+
299+
part, err := writer.CreatePart(h)
300+
if err != nil {
301+
return fmt.Errorf("could not create form file for %s: %v", att.Filename, err)
302+
}
303+
_, err = io.Copy(part, bytes.NewReader(att.Data))
304+
if err != nil {
305+
return fmt.Errorf("could not copy file data for %s: %v", att.Filename, err)
306+
}
307+
}
308+
309+
err := writer.Close()
310+
if err != nil {
311+
return fmt.Errorf("failed to close multipart writer: %v", err)
312+
}
313+
314+
req, err := http.NewRequest("POST", webhookURL, body)
315+
if err != nil {
316+
return fmt.Errorf("failed to create HTTP request: %v", err)
317+
}
318+
319+
req.Header.Set("Content-Type", writer.FormDataContentType())
320+
321+
client := &http.Client{Timeout: 30 * time.Second}
322+
resp, err := client.Do(req)
323+
if err != nil {
324+
return fmt.Errorf("failed to send HTTP request: %v", err)
325+
}
326+
defer resp.Body.Close()
327+
328+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
329+
respBody, _ := io.ReadAll(resp.Body)
330+
return fmt.Errorf("webhook returned non-2xx status code: %d, body: %s", resp.StatusCode, string(respBody))
331+
}
332+
333+
return nil
334+
}
335+
336+
// escapeQuotes escapes double quotes for header values
337+
func escapeQuotes(s string) string {
338+
return strings.ReplaceAll(s, `"`, `\"`)
339+
}

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@ require (
99
github.com/go-telegram/bot v1.17.0
1010
github.com/mark3labs/mcp-go v0.29.1-0.20250521213157-f99e5472f312
1111
github.com/philippgille/chromem-go v0.7.0
12+
golang.org/x/oauth2 v0.30.0
13+
google.golang.org/api v0.236.0
1214
google.golang.org/genai v1.51.0
1315
)
1416

1517
require (
1618
cloud.google.com/go v0.120.0 // indirect
1719
cloud.google.com/go/auth v0.16.2 // indirect
20+
cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect
1821
cloud.google.com/go/compute/metadata v0.7.0 // indirect
1922
github.com/bahlo/generic-list-go v0.2.0 // indirect
2023
github.com/buger/jsonparser v1.1.1 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ cloud.google.com/go v0.120.0 h1:wc6bgG9DHyKqF5/vQvX1CiZrtHnxJjBlKUyF9nP6meA=
22
cloud.google.com/go v0.120.0/go.mod h1:/beW32s8/pGRuj4IILWQNd4uuebeT4dkOhKmkfit64Q=
33
cloud.google.com/go/auth v0.16.2 h1:QvBAGFPLrDeoiNjyfVunhQ10HKNYuOwZ5noee0M5df4=
44
cloud.google.com/go/auth v0.16.2/go.mod h1:sRBas2Y1fB1vZTdurouM0AzuYQBMZinrUYL8EufhtEA=
5+
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
6+
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
57
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
68
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
79
github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=
@@ -107,6 +109,8 @@ golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
107109
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
108110
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
109111
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
112+
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
113+
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
110114
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
111115
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
112116
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -116,8 +120,14 @@ golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg=
116120
golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0=
117121
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
118122
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
123+
google.golang.org/api v0.236.0 h1:CAiEiDVtO4D/Qja2IA9VzlFrgPnK3XVMmRoJZlSWbc0=
124+
google.golang.org/api v0.236.0/go.mod h1:X1WF9CU2oTc+Jml1tiIxGmWFK/UZezdqEu09gcxZAj4=
119125
google.golang.org/genai v1.51.0 h1:IZGuUqgfx40INv3hLFGCbOSGp0qFqm7LVmDghzNIYqg=
120126
google.golang.org/genai v1.51.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
127+
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2 h1:1tXaIXCracvtsRxSBsYDiSBN0cuJvM7QYW+MrpIRY78=
128+
google.golang.org/genproto v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:49MsLSx0oWMOZqcpB3uL8ZOkAh1+TndpJ8ONoCBWiZk=
129+
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
130+
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
121131
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
122132
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
123133
google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok=

0 commit comments

Comments
 (0)