- 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.
357 lines
9.9 KiB
Go
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
|