theorose49 e0cd216800
All checks were successful
build-and-push / build (push) Successful in 33s
feat(mail): 가시성=단일메일 참조여부로 복귀 + 메일 전문·첨부파일 온디맨드
- 가시성을 스레드 단위 → 단일 메일 from/to/cc 기준으로 되돌림(답장도 개별 판정)
- mailsync.GetFull(rfc822msgid→format=full): 본문(text 우선/HTML) + 첨부 리스트 파싱
- mailsync.GetAttachment: 첨부 바이트 다운로드(요청자 메일함 impersonate)
- GET /mails/full, GET /mails/attachment — 권한: canSeeProject + 단일메일 참여자만(403)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:18:49 +09:00

485 lines
14 KiB
Go

// 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
}
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}
if plain != "" {
fm.Body = plain
} else {
fm.Body = html
fm.IsHTML = true
}
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
}