diff --git a/cmd/server/main.go b/cmd/server/main.go index e2d4fb0..66ce117 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -9,6 +9,7 @@ import ( "spin/internal/config" "spin/internal/db" "spin/internal/httpapi" + "spin/internal/mailsync" "spin/internal/push" "spin/internal/seed" "spin/internal/storage" @@ -46,7 +47,8 @@ func main() { } pusher := push.New(cfg.FCMCredentialsFile) - router := httpapi.NewRouter(gdb, store, cfg, pusher) + mailer := mailsync.New(cfg.GoogleSACredentialsFile) + router := httpapi.NewRouter(gdb, store, cfg, pusher, mailer) addr := ":" + cfg.Port srv := &http.Server{ diff --git a/internal/config/config.go b/internal/config/config.go index db8845a..7552560 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -27,6 +27,10 @@ type Config struct { // FCMCredentialsFile is the path to a Firebase service-account JSON used to // send push notifications to spin-mobile. Empty = push disabled (no-op). FCMCredentialsFile string + // GoogleSACredentialsFile is the path to a Google Workspace service-account + // JSON with DOMAIN-WIDE DELEGATION (gmail.readonly) used to read project + // client-domain mail. Empty = mail integration disabled. + GoogleSACredentialsFile string // 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. @@ -77,7 +81,8 @@ func Load() Config { // Super-admin Keycloak groups (comma-separated). Default: admin // (shared group name across all internal apps, not app-specific). AdminGroups: splitCSV(env("ADMIN_GROUPS", "admin")), - FCMCredentialsFile: env("FCM_CREDENTIALS_FILE", ""), + FCMCredentialsFile: env("FCM_CREDENTIALS_FILE", ""), + GoogleSACredentialsFile: env("GOOGLE_SA_CREDENTIALS_FILE", ""), 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 2889f62..9e33017 100644 --- a/internal/httpapi/handlers_projects.go +++ b/internal/httpapi/handlers_projects.go @@ -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(¬e).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(¬e) + } else { + s.db.Model(¬e).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 { diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 12c17d5..77bf972 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -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) diff --git a/internal/mailsync/mailsync.go b/internal/mailsync/mailsync.go new file mode 100644 index 0000000..aefa00e --- /dev/null +++ b/internal/mailsync/mailsync.go @@ -0,0 +1,317 @@ +// Package mailsync reads Gmail messages for a project's client domain using a +// Google Workspace service account with DOMAIN-WIDE DELEGATION — stdlib only, no +// third-party SDK (mirrors internal/push for the JWT/OAuth dance). +// +// It is OPTIONAL: with no credentials configured the Service is "disabled" and +// list calls return empty. This lets spin run without Google until the Workspace +// admin provisions a service account + domain-wide delegation (gmail.readonly). +// +// For a project the handler passes the set of member emails to impersonate; for +// each we mint a delegated token (sub=member) and search that mailbox for the +// client domain, then aggregate + dedup by RFC822 Message-ID across the team. +package mailsync + +import ( + "context" + "crypto" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "log" + "net/http" + "net/url" + "os" + "sort" + "strings" + "sync" + "time" +) + +const gmailScope = "https://www.googleapis.com/auth/gmail.readonly" + +type serviceAccount struct { + ProjectID string `json:"project_id"` + PrivateKey string `json:"private_key"` + ClientEmail string `json:"client_email"` + TokenURI string `json:"token_uri"` +} + +// Message is a slim mail summary surfaced to the project mail tab. +type Message struct { + ID string `json:"id"` // RFC822 Message-ID (stable dedup key) + ThreadID string `json:"threadId"` + From string `json:"from"` + To string `json:"to"` + Subject string `json:"subject"` + Date string `json:"date"` // raw Date header + Snippet string `json:"snippet"` + Mailbox string `json:"mailbox"` // whose mailbox surfaced it + TS int64 `json:"ts"` // internalDate (ms) for sorting/formatting +} + +type tokenEntry struct { + token string + expires time.Time +} + +// Service holds the delegated service-account credentials and a per-subject +// (impersonated user) access-token cache. +type Service struct { + sa *serviceAccount + key *rsa.PrivateKey + client *http.Client + mu sync.Mutex + tokens map[string]tokenEntry +} + +// New builds a Service from a service-account JSON file path. Empty path or a +// read/parse error yields a disabled Service. +func New(credsPath string) *Service { + if strings.TrimSpace(credsPath) == "" { + log.Printf("mailsync: Gmail disabled (no credentials configured)") + return &Service{} + } + raw, err := os.ReadFile(credsPath) + if err != nil { + log.Printf("mailsync: Gmail disabled (cannot read %s: %v)", credsPath, err) + return &Service{} + } + var sa serviceAccount + if err := json.Unmarshal(raw, &sa); err != nil || sa.PrivateKey == "" || sa.ClientEmail == "" { + log.Printf("mailsync: Gmail disabled (invalid service account JSON)") + return &Service{} + } + key, err := parsePrivateKey(sa.PrivateKey) + if err != nil { + log.Printf("mailsync: Gmail disabled (bad private key: %v)", err) + return &Service{} + } + if sa.TokenURI == "" { + sa.TokenURI = "https://oauth2.googleapis.com/token" + } + log.Printf("mailsync: Gmail enabled (sa=%s)", sa.ClientEmail) + return &Service{sa: &sa, key: key, client: &http.Client{Timeout: 15 * time.Second}, tokens: map[string]tokenEntry{}} +} + +// 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) { + if !s.Enabled() { + return nil, nil + } + domain = strings.TrimSpace(strings.TrimPrefix(domain, "@")) + if domain == "" { + return nil, nil + } + perBox := 25 + q := fmt.Sprintf("(from:%s OR to:%s OR cc:%s) -in:chats", domain, domain, domain) + seen := map[string]bool{} + var all []Message + var firstErr error + for _, box := range mailboxes { + box = strings.TrimSpace(box) + 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) + if err != nil { + continue + } + key := m.ID + if key == "" { + key = box + "/" + id + } + if seen[key] { + continue + } + seen[key] = true + all = append(all, m) + } + } + 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) { + tok, err := s.accessToken(ctx, subject) + if err != nil { + return nil, err + } + 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 + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return nil, fmt.Errorf("gmail list %d for %s", resp.StatusCode, subject) + } + var out struct { + Messages []struct { + ID string `json:"id"` + } `json:"messages"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + ids := make([]string, 0, len(out.Messages)) + for _, m := range out.Messages { + ids = append(ids, m.ID) + } + return ids, nil +} + +func (s *Service) getMeta(ctx context.Context, subject, id string) (Message, error) { + tok, err := s.accessToken(ctx, subject) + if err != nil { + return Message{}, err + } + u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Subject&metadataHeaders=Date&metadataHeaders=Message-ID", id) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := s.client.Do(req) + if err != nil { + return Message{}, err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return Message{}, fmt.Errorf("gmail get %d", resp.StatusCode) + } + var out struct { + ID string `json:"id"` + ThreadID string `json:"threadId"` + Snippet string `json:"snippet"` + InternalDate string `json:"internalDate"` + Payload struct { + Headers []struct { + Name string `json:"name"` + Value string `json:"value"` + } `json:"headers"` + } `json:"payload"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return Message{}, err + } + m := Message{ThreadID: out.ThreadID, Snippet: out.Snippet, Mailbox: subject} + for _, h := range out.Payload.Headers { + switch strings.ToLower(h.Name) { + case "from": + m.From = h.Value + case "to": + m.To = h.Value + case "subject": + m.Subject = h.Value + case "date": + m.Date = h.Value + case "message-id": + m.ID = h.Value + } + } + if m.ID == "" { + m.ID = subject + "/" + out.ID + } + fmt.Sscan(out.InternalDate, &m.TS) + return m, nil +} + +// accessToken returns a cached delegated token for the given subject (impersonated +// user), minting a new one via the service-account JWT grant when expired. +func (s *Service) accessToken(ctx context.Context, subject string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if e, ok := s.tokens[subject]; ok && e.token != "" && time.Now().Before(e.expires.Add(-60*time.Second)) { + return e.token, nil + } + now := time.Now() + claims := map[string]any{ + "iss": s.sa.ClientEmail, + "sub": subject, // domain-wide delegation: act as this Workspace user + "scope": gmailScope, + "aud": s.sa.TokenURI, + "iat": now.Unix(), + "exp": now.Add(time.Hour).Unix(), + } + assertion, err := s.signJWT(claims) + if err != nil { + return "", err + } + form := url.Values{} + form.Set("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") + form.Set("assertion", assertion) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, s.sa.TokenURI, strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := s.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + var out struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return "", err + } + if out.AccessToken == "" { + return "", fmt.Errorf("token exchange failed for %s: %s %s", subject, out.Error, out.ErrorDesc) + } + s.tokens[subject] = tokenEntry{token: out.AccessToken, expires: now.Add(time.Duration(out.ExpiresIn) * time.Second)} + return out.AccessToken, nil +} + +func (s *Service) signJWT(claims map[string]any) (string, error) { + header := map[string]string{"alg": "RS256", "typ": "JWT"} + hb, _ := json.Marshal(header) + cb, _ := json.Marshal(claims) + signingInput := b64(hb) + "." + b64(cb) + h := sha256.Sum256([]byte(signingInput)) + sig, err := rsa.SignPKCS1v15(nil, s.key, crypto.SHA256, h[:]) + if err != nil { + return "", err + } + return signingInput + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func parsePrivateKey(pemStr string) (*rsa.PrivateKey, error) { + block, _ := pem.Decode([]byte(pemStr)) + if block == nil { + return nil, errors.New("no PEM block") + } + if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return k, nil + } + keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + k, ok := keyAny.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("not an RSA private key") + } + return k, nil +} diff --git a/internal/models/models.go b/internal/models/models.go index 146cc7d..1eca2eb 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -28,7 +28,7 @@ func All() []interface{} { &Attendance{}, &LeaveRequest{}, &OvertimeRequest{}, &WorkPolicy{}, &LeaveBalance{}, // slice 3 — projects &Company{}, &Product{}, &Version{}, &Project{}, &ProjectMember{}, - &ClientContact{}, &ProjectTask{}, &TaskComment{}, &Contract{}, &ContractFile{}, &PaymentSplit{}, + &ClientContact{}, &ProjectTask{}, &TaskComment{}, &MailNote{}, &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 67d4d5e..b129513 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -56,6 +56,7 @@ type Project struct { ScopeText string `json:"scopeText"` // 글 계약 범위 ScopeGraphic string `json:"scopeGraphic"` // 그림 계약 범위 PMEmail string `json:"pmEmail"` // 프로젝트 PM + ClientDomain string `json:"clientDomain"` // 고객사 메일 도메인(postfix) — 관련 메일 집계용 Cautions string `json:"cautions"` // 주의사항 (구성원 공개) Status string `json:"status"` // planned | active | hold | done | dropped StartDate string `json:"startDate"` @@ -125,6 +126,20 @@ type TaskComment struct { func (m *TaskComment) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil } +// MailNote is a project's SHARED memo on one client-domain email — any project +// member can edit it (협업 메모). Keyed by (project, RFC822 Message-ID). +type MailNote struct { + Base + ProjectID string `gorm:"index:idx_mailnote_pm,unique" json:"projectId"` + MessageID string `gorm:"index:idx_mailnote_pm,unique" json:"messageId"` + Body string `json:"body"` + LastEditedBy string `json:"lastEditedBy"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +func (m *MailNote) BeforeCreate(*gorm.DB) error { m.ensureID(); return nil } + // Contract holds the [admin-only] commercial terms of a project. BE is the // break-even floor (손해가 안 나는 최소 금액). Exposed ONLY to admins. type Contract struct {