- 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:
co-authored by
Claude Opus 4.8
parent
b4b47e5ed1
commit
c865baccd2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user