feat(mail): 전사 메일함 수집 + 요청자 참여(from/to/cc) 가시성 필터
All checks were successful
build-and-push / build (push) Successful in 32s

- 수집 대상을 프로젝트 팀 → 전사 모든 구성원 메일함으로 변경(프로젝트 캐시)
- Message.Cc 수집 + Involves(email): 요청자가 수신·참조된 메일만 노출
- 공동 메모는 그대로(프로젝트 구성원 공유)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
theorose49 2026-06-30 11:11:23 +09:00
parent 751aa8ed97
commit b4b47e5ed1
2 changed files with 43 additions and 27 deletions

View File

@ -507,38 +507,41 @@ func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request)
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
return 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 v, ok := s.mailCache.Load(id); ok {
if e := v.(*mailCacheEntry); time.Since(e.at) < mailCacheTTL { if e := v.(*mailCacheEntry); time.Since(e.at) < mailCacheTTL {
resp["messages"] = e.msgs agg = e.msgs
writeJSON(w, http.StatusOK, resp)
return
} }
} }
// impersonation set = project members + PM (the team that corresponds with the client) if agg == nil {
var pms []models.ProjectMember // 전사 모든 구성원 메일함을 impersonate해 도메인 메일을 모은다(구성원은 여러
s.db.Where("project_id = ?", id).Find(&pms) // 프로젝트에 걸쳐 있으므로 전사 수집 후 프로젝트 도메인으로 필터하는 편이 단순).
set := map[string]bool{} var mailboxes []string
for _, m := range pms { s.db.Model(&models.Member{}).Where("status <> ?", "inactive").Pluck("email", &mailboxes)
if m.MemberEmail != "" { msgs, err := s.mail.ListForDomain(r.Context(), mailboxes, p.ClientDomain, 200)
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 { if err != nil {
resp["error"] = err.Error() resp["error"] = err.Error()
} }
if msgs == nil { if msgs == nil {
msgs = []mailsync.Message{} msgs = []mailsync.Message{}
} }
resp["messages"] = msgs agg = msgs
s.mailCache.Store(id, &mailCacheEntry{at: time.Now(), msgs: msgs}) s.mailCache.Store(id, &mailCacheEntry{at: time.Now(), msgs: agg})
}
// 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)
}
if len(visible) >= 80 {
break
}
}
resp["messages"] = visible
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
} }

View File

@ -47,6 +47,7 @@ type Message struct {
ThreadID string `json:"threadId"` ThreadID string `json:"threadId"`
From string `json:"from"` From string `json:"from"`
To string `json:"to"` To string `json:"to"`
Cc string `json:"cc"`
Subject string `json:"subject"` Subject string `json:"subject"`
Date string `json:"date"` // raw Date header Date string `json:"date"` // raw Date header
Snippet string `json:"snippet"` Snippet string `json:"snippet"`
@ -54,6 +55,16 @@ type Message struct {
TS int64 `json:"ts"` // internalDate (ms) for sorting/formatting TS int64 `json:"ts"` // internalDate (ms) for sorting/formatting
} }
// Involves reports whether the given email appears in From/To/Cc (case-insensitive).
// Used to gate per-viewer visibility: a member only sees mail they're a party to.
func (m Message) Involves(email string) bool {
if email == "" {
return false
}
hay := strings.ToLower(m.From + " " + m.To + " " + m.Cc)
return strings.Contains(hay, strings.ToLower(email))
}
type tokenEntry struct { type tokenEntry struct {
token string token string
expires time.Time expires time.Time
@ -187,7 +198,7 @@ func (s *Service) getMeta(ctx context.Context, subject, id string) (Message, err
if err != nil { if err != nil {
return Message{}, err 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) u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=metadata&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Cc&metadataHeaders=Subject&metadataHeaders=Date&metadataHeaders=Message-ID", id)
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
req.Header.Set("Authorization", "Bearer "+tok) req.Header.Set("Authorization", "Bearer "+tok)
resp, err := s.client.Do(req) resp, err := s.client.Do(req)
@ -220,6 +231,8 @@ func (s *Service) getMeta(ctx context.Context, subject, id string) (Message, err
m.From = h.Value m.From = h.Value
case "to": case "to":
m.To = h.Value m.To = h.Value
case "cc":
m.Cc = h.Value
case "subject": case "subject":
m.Subject = h.Value m.Subject = h.Value
case "date": case "date":