// Package mailsync reads Gmail messages for a project's client domain using a // Google Workspace service account with DOMAIN-WIDE DELEGATION — stdlib only, no // third-party SDK (mirrors internal/push for the JWT/OAuth dance). // // It is OPTIONAL: with no credentials configured the Service is "disabled" and // list calls return empty. This lets spin run without Google until the Workspace // admin provisions a service account + domain-wide delegation (gmail.readonly). // // For a project the handler passes the set of member emails to impersonate; for // each we mint a delegated token (sub=member) and search that mailbox for the // client domain, then aggregate + dedup by RFC822 Message-ID across the team. package mailsync import ( "context" "crypto" "crypto/rsa" "crypto/sha256" "crypto/x509" "encoding/base64" "encoding/json" "encoding/pem" "errors" "fmt" "log" "net/http" "net/url" "os" "sort" "strings" "sync" "time" ) const gmailScope = "https://www.googleapis.com/auth/gmail.readonly" type serviceAccount struct { ProjectID string `json:"project_id"` PrivateKey string `json:"private_key"` ClientEmail string `json:"client_email"` TokenURI string `json:"token_uri"` } // Message is a slim mail summary surfaced to the project mail tab. type Message struct { ID string `json:"id"` // RFC822 Message-ID (stable dedup key) ThreadID string `json:"threadId"` From string `json:"from"` To string `json:"to"` Cc string `json:"cc"` Subject string `json:"subject"` Date string `json:"date"` // raw Date header Snippet string `json:"snippet"` Mailbox string `json:"mailbox"` // whose mailbox surfaced it TS int64 `json:"ts"` // internalDate (ms) for sorting/formatting } // Attachment is one file on a message. type Attachment struct { Filename string `json:"filename"` MimeType string `json:"mimeType"` Size int64 `json:"size"` AttachmentID string `json:"attachmentId"` } // FullMessage is the full body + attachment list of one message. type FullMessage struct { GmailID string `json:"gmailId"` Body string `json:"body"` IsHTML bool `json:"isHtml"` Attachments []Attachment `json:"attachments"` } // gmailPart mirrors the Gmail message payload tree (recursive). type gmailPart struct { MimeType string `json:"mimeType"` Filename string `json:"filename"` Body struct { Size int64 `json:"size"` Data string `json:"data"` AttachmentID string `json:"attachmentId"` } `json:"body"` Parts []gmailPart `json:"parts"` } // Involves reports whether the given email appears in From/To/Cc (case-insensitive). // Used to gate per-viewer visibility: a member only sees mail they're a party to. func (m Message) Involves(email string) bool { if email == "" { return false } hay := strings.ToLower(m.From + " " + m.To + " " + m.Cc) return strings.Contains(hay, strings.ToLower(email)) } type tokenEntry struct { token string expires time.Time } // Service holds the delegated service-account credentials and a per-subject // (impersonated user) access-token cache. type Service struct { sa *serviceAccount key *rsa.PrivateKey client *http.Client mu sync.Mutex tokens map[string]tokenEntry } // New builds a Service from a service-account JSON file path. Empty path or a // read/parse error yields a disabled Service. func New(credsPath string) *Service { if strings.TrimSpace(credsPath) == "" { log.Printf("mailsync: Gmail disabled (no credentials configured)") return &Service{} } raw, err := os.ReadFile(credsPath) if err != nil { log.Printf("mailsync: Gmail disabled (cannot read %s: %v)", credsPath, err) return &Service{} } var sa serviceAccount if err := json.Unmarshal(raw, &sa); err != nil || sa.PrivateKey == "" || sa.ClientEmail == "" { log.Printf("mailsync: Gmail disabled (invalid service account JSON)") return &Service{} } key, err := parsePrivateKey(sa.PrivateKey) if err != nil { log.Printf("mailsync: Gmail disabled (bad private key: %v)", err) return &Service{} } if sa.TokenURI == "" { sa.TokenURI = "https://oauth2.googleapis.com/token" } log.Printf("mailsync: Gmail enabled (sa=%s)", sa.ClientEmail) return &Service{sa: &sa, key: key, client: &http.Client{Timeout: 15 * time.Second}, tokens: map[string]tokenEntry{}} } // Enabled reports whether real Gmail access is configured. func (s *Service) Enabled() bool { return s != nil && s.sa != nil } // FetchForDomain impersonates each mailbox in turn and pages through its full // history for the client domain (maxPerBox caps pages per mailbox; <=0 = all), // returning the aggregated, de-duplicated, newest-first list. func (s *Service) FetchForDomain(ctx context.Context, mailboxes []string, domain string, maxPerBox int) ([]Message, error) { if !s.Enabled() { return nil, nil } domain = strings.TrimSpace(strings.TrimPrefix(domain, "@")) if domain == "" { return nil, nil } if maxPerBox <= 0 { maxPerBox = 1 << 30 // effectively unbounded (whole history) } q := fmt.Sprintf("(from:%s OR to:%s OR cc:%s) -in:chats", domain, domain, domain) seen := map[string]bool{} var all []Message var firstErr error for _, box := range mailboxes { box = strings.TrimSpace(box) if box == "" { continue } // 도메인 전체 위임은 이 서비스계정이 속한 Workspace 도메인 사용자만 impersonate 가능. // 멤버 목록에 섞인 개인 Gmail(@gmail.com)이나 아직 프로비저닝 안 된 계정은 // token 교환이 unauthorized_client로 실패한다. 그 메일박스만 건너뛰고, 프로젝트 // 전체 동기화를 실패로 마크하지 않는다(나머지 멤버 메일은 정상 수집). if _, err := s.accessToken(ctx, box); err != nil { log.Printf("mailsync: 메일박스 %s 건너뜀 (impersonation 불가): %v", box, err) continue } pageToken := "" fetched := 0 for { ids, next, err := s.listPage(ctx, box, q, pageToken) if err != nil { if firstErr == nil { firstErr = err } break } for _, id := range ids { if fetched >= maxPerBox { break } m, err := s.getMeta(ctx, box, id) if err != nil { continue } key := m.ID if key == "" { key = box + "/" + id } if !seen[key] { seen[key] = true all = append(all, m) } fetched++ } if next == "" || fetched >= maxPerBox { break } pageToken = next } } sort.Slice(all, func(i, j int) bool { return all[i].TS > all[j].TS }) return all, firstErr } func (s *Service) listPage(ctx context.Context, subject, q, pageToken string) (ids []string, next string, err error) { tok, err := s.accessToken(ctx, subject) if err != nil { return nil, "", err } u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=100&q=%s", url.QueryEscape(q)) if pageToken != "" { u += "&pageToken=" + url.QueryEscape(pageToken) } req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req.Header.Set("Authorization", "Bearer "+tok) resp, err := s.client.Do(req) if err != nil { return nil, "", err } defer resp.Body.Close() if resp.StatusCode >= 300 { return nil, "", fmt.Errorf("gmail list %d for %s", resp.StatusCode, subject) } var out struct { Messages []struct { ID string `json:"id"` } `json:"messages"` NextPageToken string `json:"nextPageToken"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, "", err } ids = make([]string, 0, len(out.Messages)) for _, m := range out.Messages { ids = append(ids, m.ID) } return ids, out.NextPageToken, nil } func (s *Service) getMeta(ctx context.Context, subject, id string) (Message, error) { tok, err := s.accessToken(ctx, subject) if err != nil { return Message{}, err } u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Cc&metadataHeaders=Subject&metadataHeaders=Date&metadataHeaders=Message-ID", id) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req.Header.Set("Authorization", "Bearer "+tok) resp, err := s.client.Do(req) if err != nil { return Message{}, err } defer resp.Body.Close() if resp.StatusCode >= 300 { return Message{}, fmt.Errorf("gmail get %d", resp.StatusCode) } var out struct { ID string `json:"id"` ThreadID string `json:"threadId"` Snippet string `json:"snippet"` InternalDate string `json:"internalDate"` Payload struct { Headers []struct { Name string `json:"name"` Value string `json:"value"` } `json:"headers"` } `json:"payload"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return Message{}, err } m := Message{ThreadID: out.ThreadID, Snippet: out.Snippet, Mailbox: subject} for _, h := range out.Payload.Headers { switch strings.ToLower(h.Name) { case "from": m.From = h.Value case "to": m.To = h.Value case "cc": m.Cc = h.Value case "subject": m.Subject = h.Value case "date": m.Date = h.Value case "message-id": m.ID = h.Value } } if m.ID == "" { m.ID = subject + "/" + out.ID } fmt.Sscan(out.InternalDate, &m.TS) return m, nil } func decB64(s string) ([]byte, error) { if m := len(s) % 4; m != 0 { s += strings.Repeat("=", 4-m) } return base64.URLEncoding.DecodeString(s) } func (s *Service) findGmailID(ctx context.Context, mailbox, rfc822 string) (string, error) { ids, _, err := s.listPage(ctx, mailbox, "rfc822msgid:"+rfc822, "") if err != nil { return "", err } if len(ids) == 0 { return "", nil } return ids[0], nil } // GetFull fetches the full body (text preferred, else HTML) and attachment list of // a message, impersonating the given mailbox (which must contain the message). func (s *Service) GetFull(ctx context.Context, mailbox, rfc822 string) (FullMessage, error) { if !s.Enabled() { return FullMessage{}, fmt.Errorf("mail integration disabled") } gid, err := s.findGmailID(ctx, mailbox, rfc822) if err != nil { return FullMessage{}, err } if gid == "" { return FullMessage{}, fmt.Errorf("메일을 찾을 수 없습니다(메일함에 없음)") } tok, err := s.accessToken(ctx, mailbox) if err != nil { return FullMessage{}, err } u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=full", gid) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req.Header.Set("Authorization", "Bearer "+tok) resp, err := s.client.Do(req) if err != nil { return FullMessage{}, err } defer resp.Body.Close() if resp.StatusCode >= 300 { return FullMessage{}, fmt.Errorf("gmail get full %d", resp.StatusCode) } var out struct { Payload gmailPart `json:"payload"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return FullMessage{}, err } var plain, html string var atts []Attachment var walk func(p gmailPart) walk = func(p gmailPart) { if p.Filename != "" && p.Body.AttachmentID != "" { atts = append(atts, Attachment{Filename: p.Filename, MimeType: p.MimeType, Size: p.Body.Size, AttachmentID: p.Body.AttachmentID}) } else if p.Body.Data != "" { if b, e := decB64(p.Body.Data); e == nil { if strings.HasPrefix(p.MimeType, "text/plain") && plain == "" { plain = string(b) } else if strings.HasPrefix(p.MimeType, "text/html") && html == "" { html = string(b) } } } for _, c := range p.Parts { walk(c) } } walk(out.Payload) fm := FullMessage{GmailID: gid, Attachments: atts} // 메일앱처럼 보이도록 HTML 본문 우선, 없으면 텍스트. if html != "" { fm.Body = html fm.IsHTML = true } else { fm.Body = plain } return fm, nil } // GetAttachment downloads one attachment's bytes, impersonating the mailbox. func (s *Service) GetAttachment(ctx context.Context, mailbox, gmailMsgID, attachmentID string) ([]byte, error) { if !s.Enabled() { return nil, fmt.Errorf("mail integration disabled") } tok, err := s.accessToken(ctx, mailbox) if err != nil { return nil, err } u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s/attachments/%s", gmailMsgID, attachmentID) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req.Header.Set("Authorization", "Bearer "+tok) resp, err := s.client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode >= 300 { return nil, fmt.Errorf("gmail attachment %d", resp.StatusCode) } var out struct { Data string `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } return decB64(out.Data) } // accessToken returns a cached delegated token for the given subject (impersonated // user), minting a new one via the service-account JWT grant when expired. func (s *Service) accessToken(ctx context.Context, subject string) (string, error) { s.mu.Lock() defer s.mu.Unlock() if e, ok := s.tokens[subject]; ok && e.token != "" && time.Now().Before(e.expires.Add(-60*time.Second)) { return e.token, nil } now := time.Now() claims := map[string]any{ "iss": s.sa.ClientEmail, "sub": subject, // domain-wide delegation: act as this Workspace user "scope": gmailScope, "aud": s.sa.TokenURI, "iat": now.Unix(), "exp": now.Add(time.Hour).Unix(), } assertion, err := s.signJWT(claims) if err != nil { return "", err } form := url.Values{} form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") form.Set("assertion", assertion) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, s.sa.TokenURI, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := s.client.Do(req) if err != nil { return "", err } defer resp.Body.Close() var out struct { AccessToken string `json:"access_token"` ExpiresIn int `json:"expires_in"` Error string `json:"error"` ErrorDesc string `json:"error_description"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } if out.AccessToken == "" { return "", fmt.Errorf("token exchange failed for %s: %s %s", subject, out.Error, out.ErrorDesc) } s.tokens[subject] = tokenEntry{token: out.AccessToken, expires: now.Add(time.Duration(out.ExpiresIn) * time.Second)} return out.AccessToken, nil } func (s *Service) signJWT(claims map[string]any) (string, error) { header := map[string]string{"alg": "RS256", "typ": "JWT"} hb, _ := json.Marshal(header) cb, _ := json.Marshal(claims) signingInput := b64(hb) + "." + b64(cb) h := sha256.Sum256([]byte(signingInput)) sig, err := rsa.SignPKCS1v15(nil, s.key, crypto.SHA256, h[:]) if err != nil { return "", err } return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil } func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) { block, _ := pem.Decode([]byte(pemStr)) if block == nil { return nil, errors.New("no PEM block") } if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { return k, nil } keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return nil, err } k, ok := keyAny.(*rsa.PrivateKey) if !ok { return nil, errors.New("not an RSA private key") } return k, nil }