feat(mail): 프로젝트 고객사 도메인 메일 연동(Google Workspace 도메인위임) + 공동 메모
build-and-push / build (push) Successful in 32s

- Project.ClientDomain 필드, MailNote(프로젝트 구성원 공동 메모) 모델
- internal/mailsync: 서비스계정+도메인위임으로 팀 메일함을 도메인 검색·집계(stdlib만, push 패턴 재사용)
  · GOOGLE_SA_CREDENTIALS_FILE 미설정 시 비활성(graceful)
- GET /projects/{id}/mails (3분 캐시), GET/PUT /projects/{id}/mail-notes
- fix: handlePatchProject map-key Updates가 camelCase 멀티워드 필드(consultingType·
  scopeText·pmEmail·clientDomain·날짜)를 컬럼에 못 맞춰 저장 실패하던 버그 → snakeKeys 변환

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49
2026-06-30 10:41:26 +09:00
co-authored by Claude Opus 4.8
parent ce07aced0f
commit 751aa8ed97
7 changed files with 495 additions and 9 deletions
+139
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"spin/internal/mailsync"
"spin/internal/models"
"github.com/go-chi/chi/v5"
@@ -228,6 +229,9 @@ func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
return
}
delete(patch, "id")
// JSON 키(camelCase)를 DB 컬럼(snake_case)으로 변환해야 멀티워드 필드
// (consultingType·scopeText·pmEmail·clientDomain·startDate 등)가 저장된다.
patch = snakeKeys(patch)
if err := s.db.Model(&p).Updates(patch).Error; err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
@@ -236,6 +240,28 @@ func (s *Server) handlePatchProject(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, p)
}
// snakeKeys converts camelCase JSON map keys to snake_case DB column names so
// GORM Updates(map) targets the right columns. Safe for camelCase keys (no
// leading/consecutive capitals), which is how all our JSON tags are written.
func snakeKeys(m map[string]interface{}) map[string]interface{} {
out := make(map[string]interface{}, len(m))
for k, v := range m {
var b strings.Builder
for i, r := range k {
if r >= 'A' && r <= 'Z' {
if i > 0 {
b.WriteByte('_')
}
b.WriteRune(r - 'A' + 'a')
} else {
b.WriteRune(r)
}
}
out[b.String()] = v
}
return out
}
func (s *Server) handleDeleteProject(w http.ResponseWriter, r *http.Request) {
if !s.requireAdmin(w, r) {
return
@@ -384,6 +410,7 @@ func (s *Server) handlePatchTask(w http.ResponseWriter, r *http.Request) {
}
}
}
patch = snakeKeys(patch)
s.db.Model(&t).Updates(patch)
s.db.First(&t, "id = ?", t.ID)
writeJSON(w, http.StatusOK, t)
@@ -448,6 +475,118 @@ func (s *Server) handleDeleteTaskComment(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// ---- project mail (Google Workspace 도메인 위임) + 공동 메모 -----------------
type mailCacheEntry struct {
at time.Time
msgs []mailsync.Message
}
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.
func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var p models.Project
if err := s.db.First(&p, "id = ?", id).Error; err != nil {
writeError(w, http.StatusNotFound, "프로젝트를 찾을 수 없습니다")
return
}
resp := map[string]any{
"enabled": s.mail.Enabled(),
"domain": p.ClientDomain,
"messages": []mailsync.Message{},
}
if !s.mail.Enabled() || strings.TrimSpace(p.ClientDomain) == "" {
writeJSON(w, http.StatusOK, resp)
return
}
if v, ok := s.mailCache.Load(id); ok {
if e := v.(*mailCacheEntry); time.Since(e.at) < mailCacheTTL {
resp["messages"] = e.msgs
writeJSON(w, http.StatusOK, resp)
return
}
}
// impersonation set = project members + PM (the team that corresponds with the client)
var pms []models.ProjectMember
s.db.Where("project_id = ?", id).Find(&pms)
set := map[string]bool{}
for _, m := range pms {
if m.MemberEmail != "" {
set[m.MemberEmail] = true
}
}
if p.PMEmail != "" {
set[p.PMEmail] = true
}
mailboxes := make([]string, 0, len(set))
for e := range set {
mailboxes = append(mailboxes, e)
}
msgs, err := s.mail.ListForDomain(r.Context(), mailboxes, p.ClientDomain, 60)
if err != nil {
resp["error"] = err.Error()
}
if msgs == nil {
msgs = []mailsync.Message{}
}
resp["messages"] = msgs
s.mailCache.Store(id, &mailCacheEntry{at: time.Now(), msgs: msgs})
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleListMailNotes(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if !s.canSeeProject(r, id) {
writeError(w, http.StatusForbidden, "권한이 없습니다")
return
}
var out []models.MailNote
s.db.Where("project_id = ?", id).Find(&out)
writeJSON(w, http.StatusOK, out)
}
// handlePutMailNote upserts the shared memo for one email. Any project member may
// edit it; the message id travels in the body (Message-ID contains <>@ chars).
func (s *Server) handlePutMailNote(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"`
Body string `json:"body"`
}
if err := decodeJSON(r, &in); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if strings.TrimSpace(in.MessageID) == "" {
writeError(w, http.StatusBadRequest, "messageId가 필요합니다")
return
}
var note models.MailNote
err := s.db.Where("project_id = ? AND message_id = ?", id, in.MessageID).First(&note).Error
note.Body = in.Body
note.LastEditedBy = s.email(r)
if err != nil { // not found → create
note.ProjectID = id
note.MessageID = in.MessageID
s.db.Create(&note)
} else {
s.db.Model(&note).Updates(map[string]interface{}{"body": note.Body, "last_edited_by": note.LastEditedBy})
}
writeJSON(w, http.StatusOK, note)
}
func (s *Server) handleDeleteTask(w http.ResponseWriter, r *http.Request) {
var t models.ProjectTask
if err := s.db.First(&t, "id = ?", chi.URLParam(r, "tId")).Error; err != nil {
+14 -6
View File
@@ -3,8 +3,10 @@ package httpapi
import (
"encoding/json"
"net/http"
"sync"
"spin/internal/config"
"spin/internal/mailsync"
"spin/internal/push"
"spin/internal/storage"
@@ -16,15 +18,17 @@ import (
// Server bundles dependencies shared by all handlers.
type Server struct {
db *gorm.DB
store *storage.Storage
cfg config.Config
push *push.Sender
db *gorm.DB
store *storage.Storage
cfg config.Config
push *push.Sender
mail *mailsync.Service
mailCache sync.Map // projectID -> *mailCacheEntry (short-TTL aggregated mail)
}
// NewRouter wires up the chi router and mounts the /api routes for every module.
func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *push.Sender) http.Handler {
s := &Server{db: db, store: store, cfg: cfg, push: pusher}
func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *push.Sender, mailer *mailsync.Service) http.Handler {
s := &Server{db: db, store: store, cfg: cfg, push: pusher, mail: mailer}
r := chi.NewRouter()
r.Use(middleware.RequestID)
@@ -127,6 +131,10 @@ func NewRouter(db *gorm.DB, store *storage.Storage, cfg config.Config, pusher *p
r.Get("/tasks/{tId}/comments", s.handleListTaskComments)
r.Post("/tasks/{tId}/comments", s.handleCreateTaskComment)
r.Delete("/comments/{cId}", s.handleDeleteTaskComment)
// project mail (Google Workspace 도메인 위임) + 공동 메모
r.Get("/projects/{id}/mails", s.handleListProjectMails)
r.Get("/projects/{id}/mail-notes", s.handleListMailNotes)
r.Put("/projects/{id}/mail-notes", s.handlePutMailNote)
// admin-only commercial block
r.Get("/projects/{id}/contract", s.handleGetContract)
r.Put("/projects/{id}/contract", s.handlePutContract)