feat(mail): 프로젝트 고객사 도메인 메일 연동(Google Workspace 도메인위임) + 공동 메모
All checks were successful
build-and-push / build (push) Successful in 32s
All checks were successful
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:
parent
ce07aced0f
commit
751aa8ed97
@ -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{
|
||||
|
||||
@ -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"),
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
317
internal/mailsync/mailsync.go
Normal file
317
internal/mailsync/mailsync.go
Normal file
@ -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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user