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{} // isSyncing reports whether a sync is currently running for the project. func isSyncing(projectID string) bool { _, ok := syncInFlight.Load(projectID) return ok } // 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: } } }() }