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
+81 -39
View File
@@ -1,6 +1,7 @@
package httpapi
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -477,16 +478,16 @@ func (s *Server) handleDeleteTaskComment(w http.ResponseWriter, r *http.Request)
// ---- project mail (Google Workspace 도메인 위임) + 공동 메모 -----------------
type mailCacheEntry struct {
at time.Time
msgs []mailsync.Message
type mailItem struct {
models.ProjectMailMsg
Note string `json:"note"` // 공동 메모 본문
NoteEditedBy string `json:"noteEditedBy"` // 메모 마지막 수정자
}
const mailCacheTTL = 3 * time.Minute
// handleListProjectMails aggregates client-domain mail across the project team's
// mailboxes (service account + domain-wide delegation). Disabled/empty-domain
// projects return an empty list with flags so the UI can explain.
// handleListProjectMails returns the stored client-domain mail for the project,
// each with its shared memo, filtered to messages the requester is a party to
// (from/to/cc). Hidden flag travels along so the UI can collapse hidden items.
// A never-synced project triggers an async full backfill.
func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
@@ -501,50 +502,91 @@ func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request)
resp := map[string]any{
"enabled": s.mail.Enabled(),
"domain": p.ClientDomain,
"messages": []mailsync.Message{},
"messages": []mailItem{},
}
if !s.mail.Enabled() || strings.TrimSpace(p.ClientDomain) == "" {
if strings.TrimSpace(p.ClientDomain) == "" {
writeJSON(w, http.StatusOK, resp)
return
}
// The cache holds the COMPANY-WIDE aggregate for the domain; we then filter
// per-viewer below (a member only sees mail they're a party to).
var agg []mailsync.Message
if v, ok := s.mailCache.Load(id); ok {
if e := v.(*mailCacheEntry); time.Since(e.at) < mailCacheTTL {
agg = e.msgs
}
var st models.ProjectMailState
synced := s.db.First(&st, "project_id = ?", id).Error == nil
if synced && st.LastSyncedAt != nil {
resp["lastSyncedAt"] = st.LastSyncedAt
}
if agg == nil {
// 전사 모든 구성원 메일함을 impersonate해 도메인 메일을 모은다(구성원은 여러
// 프로젝트에 걸쳐 있으므로 전사 수집 후 프로젝트 도메인으로 필터하는 편이 단순).
var mailboxes []string
s.db.Model(&models.Member{}).Where("status <> ?", "inactive").Pluck("email", &mailboxes)
msgs, err := s.mail.ListForDomain(r.Context(), mailboxes, p.ClientDomain, 200)
if err != nil {
resp["error"] = err.Error()
}
if msgs == nil {
msgs = []mailsync.Message{}
}
agg = msgs
s.mailCache.Store(id, &mailCacheEntry{at: time.Now(), msgs: agg})
if st.LastError != "" {
resp["error"] = st.LastError
}
// First ever view → kick off full backfill in the background (only if mail
// integration is configured). Already-stored mail is shown regardless.
if !synced && s.mail.Enabled() {
resp["syncing"] = true
go syncProjectMail(context.Background(), s.db, s.mail, id, true)
}
var rows []models.ProjectMailMsg
s.db.Where("project_id = ?", id).Order("ts desc").Find(&rows)
notes := map[string]models.MailNote{}
var nl []models.MailNote
s.db.Where("project_id = ?", id).Find(&nl)
for _, n := range nl {
notes[n.MessageID] = n
}
// per-viewer visibility: only mail the requester sent/received/was CC'd on.
me := s.email(r)
visible := make([]mailsync.Message, 0, len(agg))
for _, m := range agg {
if m.Involves(me) {
visible = append(visible, m)
items := make([]mailItem, 0, len(rows))
for _, row := range rows {
m := mailsync.Message{ID: row.MessageID, From: row.FromAddr, To: row.ToAddr, Cc: row.CcAddr}
if !m.Involves(me) {
continue
}
if len(visible) >= 80 {
break
it := mailItem{ProjectMailMsg: row}
if n, ok := notes[row.MessageID]; ok {
it.Note = n.Body
it.NoteEditedBy = n.LastEditedBy
}
items = append(items, it)
}
resp["messages"] = visible
resp["messages"] = items
writeJSON(w, http.StatusOK, resp)
}
// handleSyncProjectMail forces a full resync (async). Any project member may run it.
func (s *Server) handleSyncProjectMail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
go syncProjectMail(context.Background(), s.db, s.mail, id, true)
writeJSON(w, http.StatusAccepted, map[string]bool{"started": true})
}
// handleHideMail toggles project-level visibility of one mail (declutter). Any
// project member may hide/unhide; it applies for the whole project.
func (s *Server) handleHideMail(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var in struct {
MessageID string `json:"messageId"`
Hidden bool `json:"hidden"`
}
if err := decodeJSON(r, &in); err != nil || strings.TrimSpace(in.MessageID) == "" {
writeError(w, http.StatusBadRequest, "messageId가 필요합니다")
return
}
by := ""
if in.Hidden {
by = s.email(r)
}
s.db.Model(&models.ProjectMailMsg{}).
Where("project_id = ? AND message_id = ?", id, in.MessageID).
Updates(map[string]interface{}{"hidden": in.Hidden, "hidden_by": by})
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleListMailNotes(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
+98
View File
@@ -0,0 +1,98 @@
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:
}
}
}()
}
+7 -7
View File
@@ -3,7 +3,6 @@ package httpapi
import (
"encoding/json"
"net/http"
"sync"
"spin/internal/config"
"spin/internal/mailsync"
@@ -18,12 +17,11 @@ import (
// Server bundles dependencies shared by all handlers.
type Server struct {
db *gorm.DB
store *storage.Storage
cfg config.Config
push *push.Sender
mail *mailsync.Service
mailCache sync.Map // projectID -> *mailCacheEntry (short-TTL aggregated mail)
db *gorm.DB
store *storage.Storage
cfg config.Config
push *push.Sender
mail *mailsync.Service
}
// NewRouter wires up the chi router and mounts the /api routes for every module.
@@ -133,6 +131,8 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *p
r.Delete("/comments/{cId}", s.handleDeleteTaskComment)
// project mail (Google Workspace 도메인 위임) + 공동 메모
r.Get("/projects/{id}/mails", s.handleListProjectMails)
r.Post("/projects/{id}/mails/sync", s.handleSyncProjectMail)
r.Put("/projects/{id}/mail-hide", s.handleHideMail)
r.Get("/projects/{id}/mail-notes", s.handleListMailNotes)
r.Put("/projects/{id}/mail-notes", s.handlePutMailNote)
// admin-only commercial block