theorose49 c865baccd2
All checks were successful
build-and-push / build (push) Successful in 33s
feat(mail): DB 저장 + 주기 동기화 + 전체 히스토리 수집 + 메일 숨김
- 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>
2026-06-30 12:44:31 +09:00

99 lines
3.0 KiB
Go

package httpapi
import (
"context"
"log"
"strings"
"sync"
"time"
"spin/internal/mailsync"
"spin/internal/models"
"gorm.io/gorm"
)
// syncInFlight prevents concurrent syncs of the same project.
var syncInFlight sync.Map // projectID -> struct{}
// syncProjectMail pulls the project's client-domain mail across ALL active member
// mailboxes (domain-wide delegation), upserts the headers into ProjectMailMsg, and
// records sync state. full=true pages the entire history; otherwise just the newest
// page per mailbox (cheap periodic top-up).
func syncProjectMail(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, projectID string, full bool) error {
if _, busy := syncInFlight.LoadOrStore(projectID, struct{}{}); busy {
return nil
}
defer syncInFlight.Delete(projectID)
var p models.Project
if err := db.First(&p, "id = ?", projectID).Error; err != nil {
return err
}
domain := strings.TrimSpace(p.ClientDomain)
if !mailer.Enabled() || domain == "" {
return nil
}
var mailboxes []string
db.Model(&models.Member{}).Where("status <> ?", "inactive").Pluck("email", &mailboxes)
perBox := 100 // periodic top-up: newest page per mailbox
if full {
perBox = 0 // whole history
}
msgs, ferr := mailer.FetchForDomain(ctx, mailboxes, domain, perBox)
for _, m := range msgs {
row := models.ProjectMailMsg{ProjectID: projectID, MessageID: m.ID}
// Upsert by (project, message-id); never clobber Hidden/HiddenBy.
db.Where("project_id = ? AND message_id = ?", projectID, m.ID).
Assign(map[string]interface{}{
"thread_id": m.ThreadID, "from_addr": m.From, "to_addr": m.To, "cc_addr": m.Cc,
"subject": m.Subject, "snippet": m.Snippet, "date_hdr": m.Date,
"mailbox": m.Mailbox, "ts": m.TS,
}).
FirstOrCreate(&row)
}
now := time.Now()
var cnt int64
db.Model(&models.ProjectMailMsg{}).Where("project_id = ?", projectID).Count(&cnt)
st := models.ProjectMailState{ProjectID: projectID, LastSyncedAt: &now, Count: int(cnt)}
if ferr != nil {
st.LastError = ferr.Error()
}
db.Save(&st)
return ferr
}
// StartMailSyncLoop periodically syncs every project that has a client domain.
// Projects never synced get a full-history backfill; the rest get a cheap top-up.
func StartMailSyncLoop(ctx context.Context, db *gorm.DB, mailer *mailsync.Service, interval time.Duration) {
if !mailer.Enabled() || interval <= 0 {
log.Printf("mailsync: periodic sync disabled (enabled=%v interval=%s)", mailer.Enabled(), interval)
return
}
log.Printf("mailsync: periodic sync every %s", interval)
go func() {
t := time.NewTicker(interval)
defer t.Stop()
for {
var projects []models.Project
db.Where("client_domain <> ''").Find(&projects)
for _, p := range projects {
var st models.ProjectMailState
full := db.First(&st, "project_id = ?", p.ID).Error != nil
if err := syncProjectMail(ctx, db, mailer, p.ID, full); err != nil {
log.Printf("mailsync: project %s sync error: %v", p.ID, err)
}
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}()
}