457 lines
12 KiB
Go
457 lines
12 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/mail"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"google.golang.org/api/gmail/v1"
|
|
|
|
"github.com/steipete/gogcli/internal/googleapi"
|
|
"github.com/steipete/gogcli/internal/outfmt"
|
|
"github.com/steipete/gogcli/internal/ui"
|
|
)
|
|
|
|
var newGmailService = googleapi.NewGmail
|
|
|
|
type GmailCmd struct {
|
|
Search GmailSearchCmd `cmd:"" name:"search" group:"Read" help:"Search threads using Gmail query syntax"`
|
|
Thread GmailThreadCmd `cmd:"" name:"thread" group:"Organize" help:"Thread operations (get, modify)"`
|
|
Get GmailGetCmd `cmd:"" name:"get" group:"Read" help:"Get a message (full|metadata|raw)"`
|
|
Attachment GmailAttachmentCmd `cmd:"" name:"attachment" group:"Read" help:"Download a single attachment"`
|
|
URL GmailURLCmd `cmd:"" name:"url" group:"Read" help:"Print Gmail web URLs for threads"`
|
|
History GmailHistoryCmd `cmd:"" name:"history" group:"Read" help:"Gmail history"`
|
|
|
|
Labels GmailLabelsCmd `cmd:"" name:"labels" group:"Organize" help:"Label operations"`
|
|
Batch GmailBatchCmd `cmd:"" name:"batch" group:"Organize" help:"Batch operations"`
|
|
|
|
Send GmailSendCmd `cmd:"" name:"send" group:"Write" help:"Send an email"`
|
|
Track GmailTrackCmd `cmd:"" name:"track" group:"Write" help:"Email open tracking"`
|
|
Drafts GmailDraftsCmd `cmd:"" name:"drafts" group:"Write" help:"Draft operations"`
|
|
|
|
Settings GmailSettingsCmd `cmd:"" name:"settings" group:"Admin" help:"Settings and admin"`
|
|
|
|
// Kept for backwards-compatibility; hidden from default help.
|
|
Watch GmailWatchCmd `cmd:"" name:"watch" hidden:"" help:"Manage Gmail watch"`
|
|
AutoForward GmailAutoForwardCmd `cmd:"" name:"autoforward" hidden:"" help:"Auto-forwarding settings"`
|
|
Delegates GmailDelegatesCmd `cmd:"" name:"delegates" hidden:"" help:"Delegate operations"`
|
|
Filters GmailFiltersCmd `cmd:"" name:"filters" hidden:"" help:"Filter operations"`
|
|
Forwarding GmailForwardingCmd `cmd:"" name:"forwarding" hidden:"" help:"Forwarding addresses"`
|
|
SendAs GmailSendAsCmd `cmd:"" name:"sendas" hidden:"" help:"Send-as settings"`
|
|
Vacation GmailVacationCmd `cmd:"" name:"vacation" hidden:"" help:"Vacation responder"`
|
|
}
|
|
|
|
type GmailSettingsCmd struct {
|
|
Filters GmailFiltersCmd `cmd:"" name:"filters" group:"Organize" help:"Filter operations"`
|
|
Delegates GmailDelegatesCmd `cmd:"" name:"delegates" group:"Admin" help:"Delegate operations"`
|
|
Forwarding GmailForwardingCmd `cmd:"" name:"forwarding" group:"Admin" help:"Forwarding addresses"`
|
|
AutoForward GmailAutoForwardCmd `cmd:"" name:"autoforward" group:"Admin" help:"Auto-forwarding settings"`
|
|
SendAs GmailSendAsCmd `cmd:"" name:"sendas" group:"Admin" help:"Send-as settings"`
|
|
Vacation GmailVacationCmd `cmd:"" name:"vacation" group:"Admin" help:"Vacation responder"`
|
|
Watch GmailWatchCmd `cmd:"" name:"watch" group:"Admin" help:"Manage Gmail watch"`
|
|
}
|
|
|
|
type GmailSearchCmd struct {
|
|
Query []string `arg:"" name:"query" help:"Search query"`
|
|
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"10"`
|
|
Page string `name:"page" help:"Page token"`
|
|
Oldest bool `name:"oldest" help:"Show first message date instead of last"`
|
|
}
|
|
|
|
func (c *GmailSearchCmd) Run(ctx context.Context, flags *RootFlags) error {
|
|
u := ui.FromContext(ctx)
|
|
account, err := requireAccount(flags)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
query := strings.TrimSpace(strings.Join(c.Query, " "))
|
|
if query == "" {
|
|
return usage("missing query")
|
|
}
|
|
|
|
svc, err := newGmailService(ctx, account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
resp, err := svc.Users.Threads.List("me").
|
|
Q(query).
|
|
MaxResults(c.Max).
|
|
PageToken(c.Page).
|
|
Context(ctx).
|
|
Do()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
idToName, err := fetchLabelIDToName(svc)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Fetch thread details concurrently (fixes N+1 query pattern)
|
|
items, err := fetchThreadDetails(ctx, svc, resp.Threads, idToName, c.Oldest)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if outfmt.IsJSON(ctx) {
|
|
return outfmt.WriteJSON(os.Stdout, map[string]any{
|
|
"threads": items,
|
|
"nextPageToken": resp.NextPageToken,
|
|
})
|
|
}
|
|
|
|
if len(items) == 0 {
|
|
u.Err().Println("No results")
|
|
return nil
|
|
}
|
|
|
|
w, flush := tableWriter(ctx)
|
|
defer flush()
|
|
|
|
fmt.Fprintln(w, "ID\tDATE\tFROM\tSUBJECT\tLABELS")
|
|
for _, it := range items {
|
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", it.ID, it.Date, it.From, it.Subject, strings.Join(it.Labels, ","))
|
|
}
|
|
printNextPageHint(u, resp.NextPageToken)
|
|
return nil
|
|
}
|
|
|
|
func firstMessage(t *gmail.Thread) *gmail.Message {
|
|
if t == nil || len(t.Messages) == 0 {
|
|
return nil
|
|
}
|
|
return t.Messages[0]
|
|
}
|
|
|
|
func lastMessage(t *gmail.Thread) *gmail.Message {
|
|
if t == nil || len(t.Messages) == 0 {
|
|
return nil
|
|
}
|
|
return t.Messages[len(t.Messages)-1]
|
|
}
|
|
|
|
func messageDateMillis(msg *gmail.Message) int64 {
|
|
if msg == nil {
|
|
return 0
|
|
}
|
|
if msg.InternalDate > 0 {
|
|
return msg.InternalDate
|
|
}
|
|
if msg.Payload == nil {
|
|
return 0
|
|
}
|
|
raw := headerValue(msg.Payload, "Date")
|
|
if raw == "" {
|
|
return 0
|
|
}
|
|
parsed, err := mailParseDate(raw)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return parsed.UnixMilli()
|
|
}
|
|
|
|
func messageByDate(t *gmail.Thread, oldest bool) *gmail.Message {
|
|
if t == nil || len(t.Messages) == 0 {
|
|
return nil
|
|
}
|
|
var picked *gmail.Message
|
|
var pickedDate int64
|
|
for _, msg := range t.Messages {
|
|
if msg == nil {
|
|
continue
|
|
}
|
|
date := messageDateMillis(msg)
|
|
if date == 0 {
|
|
continue
|
|
}
|
|
if picked == nil {
|
|
picked = msg
|
|
pickedDate = date
|
|
continue
|
|
}
|
|
if oldest {
|
|
if date < pickedDate {
|
|
picked = msg
|
|
pickedDate = date
|
|
}
|
|
continue
|
|
}
|
|
if date > pickedDate {
|
|
picked = msg
|
|
pickedDate = date
|
|
}
|
|
}
|
|
if picked != nil {
|
|
return picked
|
|
}
|
|
if oldest {
|
|
return firstMessage(t)
|
|
}
|
|
return lastMessage(t)
|
|
}
|
|
|
|
func newestMessageByDate(t *gmail.Thread) *gmail.Message {
|
|
return messageByDate(t, false)
|
|
}
|
|
|
|
func oldestMessageByDate(t *gmail.Thread) *gmail.Message {
|
|
return messageByDate(t, true)
|
|
}
|
|
|
|
func headerValue(p *gmail.MessagePart, name string) string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
for _, h := range p.Headers {
|
|
if strings.EqualFold(h.Name, name) {
|
|
return h.Value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func hasHeaderName(headers []string, name string) bool {
|
|
for _, h := range headers {
|
|
if strings.EqualFold(strings.TrimSpace(h), name) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func formatGmailDate(raw string) string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return ""
|
|
}
|
|
if t, err := mailParseDate(raw); err == nil {
|
|
return t.Format("2006-01-02 15:04")
|
|
}
|
|
return raw
|
|
}
|
|
|
|
var listUnsubscribeLinkPattern = regexp.MustCompile(`<([^>]+)>`)
|
|
|
|
func bestUnsubscribeLink(p *gmail.MessagePart) string {
|
|
links := parseListUnsubscribe(headerValue(p, "List-Unsubscribe"))
|
|
if len(links) == 0 {
|
|
return ""
|
|
}
|
|
var httpLink string
|
|
var mailtoLink string
|
|
for _, link := range links {
|
|
lower := strings.ToLower(link)
|
|
if strings.HasPrefix(lower, "https://") {
|
|
return link
|
|
}
|
|
if strings.HasPrefix(lower, "http://") && httpLink == "" {
|
|
httpLink = link
|
|
continue
|
|
}
|
|
if strings.HasPrefix(lower, "mailto:") && mailtoLink == "" {
|
|
mailtoLink = link
|
|
}
|
|
}
|
|
if httpLink != "" {
|
|
return httpLink
|
|
}
|
|
if mailtoLink != "" {
|
|
return mailtoLink
|
|
}
|
|
return links[0]
|
|
}
|
|
|
|
func parseListUnsubscribe(raw string) []string {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return nil
|
|
}
|
|
candidates := make([]string, 0)
|
|
matches := listUnsubscribeLinkPattern.FindAllStringSubmatch(raw, -1)
|
|
if len(matches) > 0 {
|
|
for _, match := range matches {
|
|
candidate := strings.TrimSpace(match[1])
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate)
|
|
}
|
|
}
|
|
parts := strings.Split(raw, ",")
|
|
for _, part := range parts {
|
|
candidate := strings.TrimSpace(strings.Trim(part, "<>\""))
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
candidates = append(candidates, candidate)
|
|
}
|
|
filtered := make([]string, 0, len(candidates))
|
|
seen := make(map[string]struct{})
|
|
for _, candidate := range candidates {
|
|
if !isUnsubscribeLink(candidate) {
|
|
continue
|
|
}
|
|
if _, ok := seen[candidate]; ok {
|
|
continue
|
|
}
|
|
seen[candidate] = struct{}{}
|
|
filtered = append(filtered, candidate)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func isUnsubscribeLink(raw string) bool {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return false
|
|
}
|
|
lower := strings.ToLower(raw)
|
|
return strings.HasPrefix(lower, "http://") ||
|
|
strings.HasPrefix(lower, "https://") ||
|
|
strings.HasPrefix(lower, "mailto:")
|
|
}
|
|
|
|
func mailParseDate(s string) (time.Time, error) {
|
|
// net/mail has the most compatible Date parser, but we keep this isolated for easier tests/mocks later.
|
|
return mail.ParseDate(s)
|
|
}
|
|
|
|
// threadItem holds parsed thread metadata for display/JSON output
|
|
type threadItem struct {
|
|
ID string `json:"id"`
|
|
Date string `json:"date,omitempty"`
|
|
From string `json:"from,omitempty"`
|
|
Subject string `json:"subject,omitempty"`
|
|
Labels []string `json:"labels,omitempty"`
|
|
}
|
|
|
|
// fetchThreadDetails fetches thread metadata concurrently with bounded parallelism.
|
|
// This eliminates N+1 queries by fetching all threads in parallel.
|
|
// When oldest is false (default), the date shown is from the last message in the thread.
|
|
// When oldest is true, the date shown is from the first message in the thread.
|
|
func fetchThreadDetails(ctx context.Context, svc *gmail.Service, threads []*gmail.Thread, idToName map[string]string, oldest bool) ([]threadItem, error) {
|
|
if len(threads) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
const maxConcurrency = 10 // Limit parallel requests to avoid rate limiting
|
|
sem := make(chan struct{}, maxConcurrency)
|
|
|
|
type result struct {
|
|
index int
|
|
item threadItem
|
|
err error
|
|
}
|
|
|
|
results := make(chan result, len(threads))
|
|
var wg sync.WaitGroup
|
|
|
|
for i, t := range threads {
|
|
if t.Id == "" {
|
|
continue
|
|
}
|
|
|
|
wg.Add(1)
|
|
go func(idx int, threadID string) {
|
|
defer wg.Done()
|
|
|
|
// Acquire semaphore
|
|
select {
|
|
case sem <- struct{}{}:
|
|
defer func() { <-sem }()
|
|
case <-ctx.Done():
|
|
results <- result{index: idx, err: ctx.Err()}
|
|
return
|
|
}
|
|
|
|
thread, err := svc.Users.Threads.Get("me", threadID).
|
|
Format("metadata").
|
|
MetadataHeaders("From", "Subject", "Date").
|
|
Context(ctx).
|
|
Do()
|
|
if err != nil {
|
|
results <- result{index: idx, err: err}
|
|
return
|
|
}
|
|
|
|
item := threadItem{ID: threadID}
|
|
if first := firstMessage(thread); first != nil {
|
|
item.From = sanitizeTab(headerValue(first.Payload, "From"))
|
|
item.Subject = sanitizeTab(headerValue(first.Payload, "Subject"))
|
|
if len(first.LabelIds) > 0 {
|
|
names := make([]string, 0, len(first.LabelIds))
|
|
for _, lid := range first.LabelIds {
|
|
if n, ok := idToName[lid]; ok {
|
|
names = append(names, n)
|
|
} else {
|
|
names = append(names, lid)
|
|
}
|
|
}
|
|
item.Labels = names
|
|
}
|
|
}
|
|
// Date from newest message by default, oldest if --oldest
|
|
dateMsg := newestMessageByDate(thread)
|
|
if oldest {
|
|
dateMsg = oldestMessageByDate(thread)
|
|
}
|
|
if dateMsg != nil {
|
|
item.Date = formatGmailDate(headerValue(dateMsg.Payload, "Date"))
|
|
}
|
|
|
|
results <- result{index: idx, item: item}
|
|
}(i, t.Id)
|
|
}
|
|
|
|
// Close results channel when all goroutines complete
|
|
go func() {
|
|
wg.Wait()
|
|
close(results)
|
|
}()
|
|
|
|
// Collect results in order
|
|
ordered := make([]threadItem, len(threads))
|
|
hasErr := false
|
|
for r := range results {
|
|
if r.err != nil {
|
|
hasErr = true
|
|
ordered[r.index] = threadItem{ID: "", Date: "", From: "", Subject: "", Labels: nil}
|
|
continue
|
|
}
|
|
ordered[r.index] = r.item
|
|
}
|
|
|
|
if hasErr {
|
|
// Re-run sequentially to find and return the first actual error
|
|
for _, t := range threads {
|
|
if t.Id == "" {
|
|
continue
|
|
}
|
|
_, err := svc.Users.Threads.Get("me", t.Id).
|
|
Format("metadata").
|
|
MetadataHeaders("From", "Subject", "Date").
|
|
Context(ctx).
|
|
Do()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
items := make([]threadItem, 0, len(ordered))
|
|
for _, item := range ordered {
|
|
if item.ID != "" {
|
|
items = append(items, item)
|
|
}
|
|
}
|
|
return items, nil
|
|
}
|