Files
2dph/bin/mail/sync/gmail.go
T
eSlider 678a1d1dba feat(mail): full Gmail+OnlyOffice sync, import, and brain indexing
- bin/mail/sync.go: async Go sync engine (8 workers, paginated Gmail via
  API + OnlyOffice IMAP); Gmail attachments key off body.attachmentId, not
  MIME partId; ICS sidecars Latin-1->UTF-8 normalized (TestICSToMarkdownNormalizesLatin1)
- bin/mail/import: message.json -> markdown; PDFs via pdftotext -layout
  fast path with docling subprocess fallback for the ~5% textless files
- bin/mail/index_mail: fresh-rebuild indexer (repo corpus + mail) avoiding
  ladybug WAL corruption on bulk-insert into indexed DBs; split from import
- bin/kb/index: keep FTS/VECTOR indexes across incremental runs (drop+recreate
  leaves stale backing tables killing the vector index)
- docs: README/PLAN/AGENTS cover the mail pipeline

Result: 17,835 messages -> 28,918 info leafs, FTS+HNSW healthy.
2026-08-11 21:57:38 +01:00

357 lines
9.9 KiB
Go

package sync
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// GmailCredentials holds the OAuth files produced by the gmail MCP
// (@gongrzhe/server-gmail-autoauth-mcp) auto-auth flow.
type GmailCredentials struct {
CredentialsPath string // ~/.gmail-mcp/credentials.json
KeysPath string // ~/.gmail-mcp/gcp-oauth.keys.json
User string // fixed: the authed account
}
// gmailToken is the JSON shape of credentials.json + refresh response.
type gmailToken struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
Expiry int64 `json:"expiry_date"` // ms epoch
}
type gmailKeys struct {
Installed *gmailKeyBlock `json:"installed"`
Web *gmailKeyBlock `json:"web"`
}
type gmailKeyBlock struct {
ClientID string `json:"client_id"`
ClientSecret string `json:"client_secret"`
}
// GmailClient talks to the Gmail REST API using the OAuth refresh token from
// ~/.gmail-mcp/. Token is refreshed lazily with a mutex-guarded cache.
type GmailClient struct {
creds GmailCredentials
client *http.Client
mu chan struct{}
token *gmailToken
user string
}
func NewGmailClient(creds GmailCredentials) (*GmailClient, error) {
if creds.CredentialsPath == "" {
home, _ := os.UserHomeDir()
creds.CredentialsPath = filepath.Join(home, ".gmail-mcp", "credentials.json")
creds.KeysPath = filepath.Join(home, ".gmail-mcp", "gcp-oauth.keys.json")
}
g := &GmailClient{
creds: creds,
client: &http.Client{Timeout: 60 * time.Second},
mu: make(chan struct{}, 1),
}
g.mu <- struct{}{}
return g, nil
}
// accessToken returns a fresh bearer token, refreshing via the Google token
// endpoint when the cached one is missing or about to expire.
func (g *GmailClient) accessToken(ctx context.Context) (string, error) {
select {
case <-g.mu:
case <-ctx.Done():
return "", ctx.Err()
}
defer func() { g.mu <- struct{}{} }()
if g.token != nil && g.token.AccessToken != "" && g.token.Expiry > time.Now().UnixMilli()+300_000 {
return g.token.AccessToken, nil
}
return g.refreshLocked(ctx)
}
func (g *GmailClient) refreshLocked(ctx context.Context) (string, error) {
cred, err := os.ReadFile(g.creds.CredentialsPath)
if err != nil {
return "", fmt.Errorf("read gmail credentials %s: %w", g.creds.CredentialsPath, err)
}
var t gmailToken
if err := json.Unmarshal(cred, &t); err != nil {
return "", fmt.Errorf("parse gmail credentials: %w", err)
}
if t.RefreshToken == "" {
return "", errors.New("gmail credentials.json has no refresh_token (run the gmail MCP auth flow)")
}
keys, err := os.ReadFile(g.creds.KeysPath)
if err != nil {
return "", fmt.Errorf("read gmail keys %s: %w", g.creds.KeysPath, err)
}
var k gmailKeys
if err := json.Unmarshal(keys, &k); err != nil {
return "", fmt.Errorf("parse gmail keys: %w", err)
}
block := k.Installed
if block == nil {
block = k.Web
}
if block == nil {
return "", errors.New("gmail gcp-oauth.keys.json has no installed/web block")
}
form := url.Values{}
form.Set("client_id", block.ClientID)
form.Set("client_secret", block.ClientSecret)
form.Set("refresh_token", t.RefreshToken)
form.Set("grant_type", "refresh_token")
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://oauth2.googleapis.com/token",
strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := g.client.Do(req)
if err != nil {
return "", fmt.Errorf("gmail token refresh: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
var e struct {
Error string `json:"error"`
Desc string `json:"error_description"`
}
_ = json.Unmarshal(body, &e)
if e.Error == "invalid_grant" {
return "", fmt.Errorf("gmail OAuth token invalid/expired - re-auth via: npx -y @gongrzhe/server-gmail-autoauth-mcp auth (uses ~/.gmail-mcp)")
}
return "", fmt.Errorf("gmail token refresh status %d: %s", resp.StatusCode, truncate(string(body), 300))
}
var out struct {
AccessToken string `json:"access_token"`
ExpiresIn int64 `json:"expires_in"`
}
if err := json.Unmarshal(body, &out); err != nil {
return "", fmt.Errorf("gmail token refresh parse: %w", err)
}
g.token = &gmailToken{
AccessToken: out.AccessToken,
RefreshToken: t.RefreshToken,
Expiry: time.Now().UnixMilli() + out.ExpiresIn*1000,
}
return out.AccessToken, nil
}
// ListIDs returns message ids matching q, walking nextPageToken up to maxIDs
// (0 = unlimited). Thread-level pagination via the messages.list endpoint.
func (g *GmailClient) ListIDs(ctx context.Context, q string, maxIDs int, pageToken string) (ids []string, next string, err error) {
for {
params := url.Values{}
params.Set("q", q)
params.Set("maxResults", "100")
if pageToken != "" {
params.Set("pageToken", pageToken)
}
var out struct {
Messages []struct {
ID string `json:"id"`
} `json:"messages"`
NextPageToken string `json:"nextPageToken"`
}
if err := g.getJSON(ctx, "/gmail/v1/users/me/messages?"+params.Encode(), &out); err != nil {
return nil, "", err
}
for _, m := range out.Messages {
ids = append(ids, m.ID)
if maxIDs > 0 && len(ids) >= maxIDs {
return ids, out.NextPageToken, nil
}
}
if out.NextPageToken == "" {
break
}
pageToken = out.NextPageToken
}
return ids, "", nil
}
// GetMessage fetches a message in format=full and normalizes it.
func (g *GmailClient) GetMessage(ctx context.Context, id string) (*Message, error) {
var raw struct {
ID string `json:"id"`
ThreadID string `json:"threadId"`
InternalDate string `json:"internalDate"` // ms epoch string
Payload gmailPart
}
path := "/gmail/v1/users/me/messages/" + url.PathEscape(id) + "?format=full"
if err := g.getJSON(ctx, path, &raw); err != nil {
return nil, err
}
m := &Message{
Source: "gmail",
ID: raw.ID,
Folder: "gmail",
}
for _, h := range raw.Payload.Headers {
switch strings.ToLower(h.Name) {
case "subject":
m.Subject = h.Value
case "from":
m.From = h.Value
case "to":
m.To = h.Value
case "cc":
m.CC = h.Value
case "bcc":
m.BCC = h.Value
case "message-id":
m.MimeMessageID = h.Value
case "date":
if t, err := time.Parse(time.RFC1123Z, h.Value); err == nil {
m.ReceivedAt = t
}
}
}
if ms, err := parseMS(raw.InternalDate); err == nil {
m.ReceivedAt = ms
}
m.TextBody, m.HTMLBody, m.Attachments = collectParts(raw.Payload, "root", m.ID, 0)
m.HasAttachments = len(m.Attachments) > 0
return m, nil
}
type gmailPart struct {
PartID string `json:"partId"`
MimeType string `json:"mimeType"`
Filename string `json:"filename"`
Body gmailBody `json:"body"`
Headers []gmailHeader `json:"headers"`
Parts []gmailPart `json:"parts"`
}
type gmailHeader struct {
Name string `json:"name"`
Value string `json:"value"`
}
type gmailBody struct {
Size int64 `json:"size"`
Data string `json:"data"`
AttachmentID string `json:"attachmentId"`
}
// collectParts walks the MIME tree: text bodies into plain/html, anything with
// a filename into attachments (returned with base64 ids for later download).
func collectParts(p gmailPart, mime string, msgID string, depth int) (text, html string, atts []Attachment) {
if depth > 16 {
return
}
mt := strings.ToLower(p.MimeType)
if p.Filename != "" && mt != "text/plain" && mt != "text/html" {
pid := p.PartID
if pid == "" {
pid = fmt.Sprintf("%d", depth)
}
// Gmail's attachments API keys off body.attachmentId, not partId.
attID := p.Body.AttachmentID
if attID == "" {
attID = pid
}
atts = append(atts, Attachment{
FileID: msgID + ":" + attID,
FileName: p.Filename,
StoredName: p.Filename,
Size: p.Body.Size,
ContentType: p.MimeType,
})
} else if data, err := base64.URLEncoding.DecodeString(p.Body.Data); err == nil && len(p.Body.Data) > 0 {
s := string(data)
if mt == "text/html" && html == "" {
html = s
} else if (mt == "text/plain" || mt == "") && text == "" {
text = s
}
}
for _, child := range p.Parts {
t, h, a := collectParts(child, mt, msgID, depth+1)
if text == "" {
text = t
}
if html == "" {
html = h
}
atts = append(atts, a...)
}
return
}
// DownloadAttachment fetches an attachment's bytes from the Gmail API.
func (g *GmailClient) DownloadAttachment(ctx context.Context, msgID, attID string) ([]byte, error) {
// attID format is "<msgId>:<partId>"; the API needs the bare attachment id.
partID := attID
if i := strings.Index(attID, ":"); i >= 0 {
partID = attID[i+1:]
}
var out struct {
Data string `json:"data"`
}
path := "/gmail/v1/users/me/messages/" + url.PathEscape(msgID) + "/attachments/" + url.PathEscape(partID)
if err := g.getJSON(ctx, path, &out); err != nil {
return nil, err
}
return base64.URLEncoding.DecodeString(out.Data)
}
func (g *GmailClient) getJSON(ctx context.Context, path string, out any) error {
tok, err := g.accessToken(ctx)
if err != nil {
return err
}
u := "https://gmail.googleapis.com" + path
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+tok)
resp, err := g.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("gmail %s: status %d: %s", path, resp.StatusCode, truncate(string(body), 300))
}
if out != nil {
return json.Unmarshal(body, out)
}
return nil
}
func parseMS(s string) (time.Time, error) {
if s == "" {
return time.Time{}, errors.New("empty")
}
var ms int64
if _, err := fmt.Sscanf(s, "%d", &ms); err != nil {
return time.Time{}, err
}
return time.UnixMilli(ms), nil
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
var _ = bytes.MinRead