feat(mail): DB 저장 + 주기 동기화 + 전체 히스토리 수집 + 메일 숨김
build-and-push / build (push) Successful in 33s

- ProjectMailMsg(헤더 저장)·ProjectMailState(동기화 상태) 모델, AutoMigrate 등록
- mailsync.FetchForDomain: nextPageToken 따라 전체 히스토리 페이지네이션(maxPerBox=0=전부)
- 백그라운드 주기 동기화 StartMailSyncLoop(MAIL_SYNC_INTERVAL 기본 15m, 0=비활성)
  · 미동기화 프로젝트=full 백필, 이후=최신 페이지 top-up
- GET /mails는 DB에서 읽어 참여자(from/to/cc) 필터 + 공동 메모 인라인 결합 + lastSyncedAt
- POST /mails/sync(강제 풀싱크), PUT /mail-hide(프로젝트 단위 숨김)
- mailCache 제거(DB가 캐시), config MailSyncInterval

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49
2026-06-30 12:44:31 +09:00
co-authored by Claude Opus 4.8
parent b4b47e5ed1
commit c865baccd2
8 changed files with 281 additions and 79 deletions
+47 -32
View File
@@ -112,9 +112,10 @@ func New(credsPath string) *Service {
// Enabled reports whether real Gmail access is configured.
func (s *Service) Enabled() bool { return s != nil && s.sa != nil }
// ListForDomain impersonates each mailbox in turn, searches it for the client
// domain, and returns the aggregated, de-duplicated, newest-first list (<= max).
func (s *Service) ListForDomain(ctx context.Context, mailboxes []string, domain string, max int) ([]Message, error) {
// 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
}
@@ -122,7 +123,9 @@ func (s *Service) ListForDomain(ctx context.Context, mailboxes []string, domain
if domain == "" {
return nil, nil
}
perBox := 25
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
@@ -132,65 +135,77 @@ func (s *Service) ListForDomain(ctx context.Context, mailboxes []string, domain
if box == "" {
continue
}
ids, err := s.listIDs(ctx, box, q, perBox)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
for _, id := range ids {
m, err := s.getMeta(ctx, box, id)
pageToken := ""
fetched := 0
for {
ids, next, err := s.listPage(ctx, box, q, pageToken)
if err != nil {
continue
if firstErr == nil {
firstErr = err
}
break
}
key := m.ID
if key == "" {
key = box + "/" + id
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 seen[key] {
continue
if next == "" || fetched >= maxPerBox {
break
}
seen[key] = true
all = append(all, m)
pageToken = next
}
}
sort.Slice(all, func(i, j int) bool { return all[i].TS > all[j].TS })
if max > 0 && len(all) > max {
all = all[:max]
}
return all, firstErr
}
func (s *Service) listIDs(ctx context.Context, subject, q string, max int) ([]string, error) {
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
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)
}
u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults=%d&q=%s", max, url.QueryEscape(q))
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
return nil, "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("gmail list %d for %s", resp.StatusCode, subject)
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
return nil, "", err
}
ids := make([]string, 0, len(out.Messages))
ids = make([]string, 0, len(out.Messages))
for _, m := range out.Messages {
ids = append(ids, m.ID)
}
return ids, nil
return ids, out.NextPageToken, nil
}
func (s *Service) getMeta(ctx context.Context, subject, id string) (Message, error) {