diff --git a/cmd/server/main.go b/cmd/server/main.go index 66ce117..8124679 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -49,6 +49,7 @@ func main() { pusher := push.New(cfg.FCMCredentialsFile) mailer := mailsync.New(cfg.GoogleSACredentialsFile) router := httpapi.NewRouter(gdb, store, cfg, pusher, mailer) + httpapi.StartMailSyncLoop(context.Background(), gdb, mailer, cfg.MailSyncInterval) addr := ":" + cfg.Port srv := &http.Server{ diff --git a/internal/config/config.go b/internal/config/config.go index 7552560..05c788c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -4,6 +4,7 @@ import ( "net/url" "os" "strings" + "time" ) // Config holds all runtime configuration loaded from environment variables. @@ -31,6 +32,9 @@ type Config struct { // JSON with DOMAIN-WIDE DELEGATION (gmail.readonly) used to read project // client-domain mail. Empty = mail integration disabled. GoogleSACredentialsFile string + // MailSyncInterval is how often the background mail sync runs (MAIL_SYNC_INTERVAL, + // e.g. "15m"). 0 disables periodic sync (on-demand backfill still works). + MailSyncInterval time.Duration // LogoutURL is the common SSO logout path injected by infra (LOGOUT_URL): // ends the oauth2-proxy session then redirects to Keycloak end-session. // The static frontend reads it via /me. Defaults to the infra-common value. @@ -48,6 +52,15 @@ func env(key, def string) string { return def } +// parseDur parses a Go duration string (e.g. "15m"); invalid/"0" yields 0 (disabled). +func parseDur(s string) time.Duration { + d, err := time.ParseDuration(strings.TrimSpace(s)) + if err != nil { + return 0 + } + return d +} + // firstEnv returns the first non-empty env var among keys, else def. func firstEnv(def string, keys ...string) string { for _, k := range keys { @@ -83,6 +96,7 @@ func Load() Config { AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")), FCMCredentialsFile: env("FCM_CREDENTIALS_FILE", ""), GoogleSACredentialsFile: env("GOOGLE_SA_CREDENTIALS_FILE", ""), + MailSyncInterval: parseDur(env("MAIL_SYNC_INTERVAL", "15m")), LogoutURL: env("LOGOUT_URL", "/oauth2/sign_out?rd=https%3A%2F%2Fauth.special-partners.com%2Frealms%2Fsp%2Fprotocol%2Fopenid-connect%2Flogout"), } diff --git a/internal/httpapi/handlers_projects.go b/internal/httpapi/handlers_projects.go index 08bc1cf..fbcef63 100644 --- a/internal/httpapi/handlers_projects.go +++ b/internal/httpapi/handlers_projects.go @@ -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) { diff --git a/internal/httpapi/mail_sync.go b/internal/httpapi/mail_sync.go new file mode 100644 index 0000000..44f45d4 --- /dev/null +++ b/internal/httpapi/mail_sync.go @@ -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: + } + } + }() +} diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 77bf972..d23e7b6 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -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 diff --git a/internal/mailsync/mailsync.go b/internal/mailsync/mailsync.go index 53e661b..cd34e80 100644 --- a/internal/mailsync/mailsync.go +++ b/internal/mailsync/mailsync.go @@ -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) { diff --git a/internal/models/models.go b/internal/models/models.go index 1eca2eb..744c001 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -28,7 +28,8 @@ func All() []interface{} { &Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{}, // slice 3 — projects &Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{}, - &ClientContact{}, &ProjectTask{}, &TaskComment{}, &MailNote{}, &Contract{}, &ContractFile{}, &PaymentSplit{}, + &ClientContact{}, &ProjectTask{}, &TaskComment{}, &MailNote{}, &ProjectMailMsg{}, &ProjectMailState{}, + &Contract{}, &ContractFile{}, &PaymentSplit{}, // slice 4 — incentive &IncentiveConfig{}, &PaymentStage{}, &UserIncentive{}, &QuarterlySettlement{}, // slice 5 — accounting diff --git a/internal/models/project.go b/internal/models/project.go index b129513..b69fefa 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -140,6 +140,37 @@ type MailNote struct { func (m *MailNote) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil } +// ProjectMailMsg is a stored mail header for a project's client domain (본문 미저장, +// 헤더/스니펫만). 동기화 시 (project, Message-ID)로 upsert. Hidden=프로젝트에서 숨김. +type ProjectMailMsg struct { + Base + ProjectID string `gorm:"index:idx_pmm,unique" json:"projectId"` + MessageID string `gorm:"index:idx_pmm,unique" json:"messageId"` + ThreadID string `json:"threadId"` + FromAddr string `json:"from"` + ToAddr string `json:"to"` + CcAddr string `json:"cc"` + Subject string `json:"subject"` + Snippet string `json:"snippet"` + DateHdr string `json:"date"` + Mailbox string `json:"mailbox"` + TS int64 `gorm:"index" json:"ts"` + Hidden bool `json:"hidden"` + HiddenBy string `json:"hiddenBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (m *ProjectMailMsg) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil } + +// ProjectMailState tracks the last mail sync per project. +type ProjectMailState struct { + ProjectID string `gorm:"primaryKey" json:"projectId"` + LastSyncedAt *time.Time `json:"lastSyncedAt"` + LastError string `json:"lastError"` + Count int `json:"count"` +} + // Contract holds the [admin-only] commercial terms of a project. BE is the // break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins. type Contract struct {