feat(mail): 가시성=단일메일 참조여부로 복귀 + 메일 전문·첨부파일 온디맨드
All checks were successful
build-and-push / build (push) Successful in 33s
All checks were successful
build-and-push / build (push) Successful in 33s
- 가시성을 스레드 단위 → 단일 메일 from/to/cc 기준으로 되돌림(답장도 개별 판정) - mailsync.GetFull(rfc822msgid→format=full): 본문(text 우선/HTML) + 첨부 리스트 파싱 - mailsync.GetAttachment: 첨부 바이트 다운로드(요청자 메일함 impersonate) - GET /mails/full, GET /mails/attachment — 권한: canSeeProject + 단일메일 참여자만(403) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
14e8a62f76
commit
e0cd216800
@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -622,24 +623,11 @@ func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request)
|
||||
notes[n.MessageID] = n
|
||||
}
|
||||
me := s.email(r)
|
||||
// 가시성: 내가 참여한 "스레드 전체"를 노출(원문 + 답장 각각). 한 스레드에서
|
||||
// 한 통이라도 내가 from/to/cc면 그 스레드의 모든 메일이 보인다.
|
||||
myThreads := map[string]bool{}
|
||||
involvedSolo := map[string]bool{}
|
||||
for _, row := range rows {
|
||||
m := mailsync.Message{From: row.FromAddr, To: row.ToAddr, Cc: row.CcAddr}
|
||||
if m.Involves(me) {
|
||||
if row.ThreadID != "" {
|
||||
myThreads[row.ThreadID] = true
|
||||
} else {
|
||||
involvedSolo[row.MessageID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// 가시성: 단일 메일 기준 — 그 메일의 from/to/cc에 내가 있으면 보인다(답장도 각각 개별 판정).
|
||||
items := make([]mailItem, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
visible := involvedSolo[row.MessageID] || (row.ThreadID != "" && myThreads[row.ThreadID])
|
||||
if !visible {
|
||||
m := mailsync.Message{From: row.FromAddr, To: row.ToAddr, Cc: row.CcAddr}
|
||||
if !m.Involves(me) {
|
||||
continue
|
||||
}
|
||||
it := mailItem{ProjectMailMsg: row}
|
||||
@ -653,6 +641,71 @@ func (s *Server) handleListProjectMails(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// involvedRow loads a stored mail row and confirms the requester is a party to it
|
||||
// (per-mail visibility), returning the row + the mailbox to impersonate(=requester).
|
||||
func (s *Server) involvedRow(w http.ResponseWriter, r *http.Request, projectID, messageID string) (models.ProjectMailMsg, bool) {
|
||||
var row models.ProjectMailMsg
|
||||
if err := s.db.Where("project_id = ? AND message_id = ?", projectID, messageID).First(&row).Error; err != nil {
|
||||
writeError(w, http.StatusNotFound, "메일을 찾을 수 없습니다")
|
||||
return row, false
|
||||
}
|
||||
m := mailsync.Message{From: row.FromAddr, To: row.ToAddr, Cc: row.CcAddr}
|
||||
if !m.Involves(s.email(r)) {
|
||||
writeError(w, http.StatusForbidden, "이 메일을 볼 권한이 없습니다")
|
||||
return row, false
|
||||
}
|
||||
return row, true
|
||||
}
|
||||
|
||||
// handleMailFull fetches the full body + attachment list of one mail on demand
|
||||
// (impersonating the requester, who is a party to it).
|
||||
func (s *Server) handleMailFull(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !s.canSeeProject(r, id) {
|
||||
writeError(w, http.StatusForbidden, "권한이 없습니다")
|
||||
return
|
||||
}
|
||||
msgID := r.URL.Query().Get("messageId")
|
||||
if _, ok := s.involvedRow(w, r, id, msgID); !ok {
|
||||
return
|
||||
}
|
||||
if !s.mail.Enabled() {
|
||||
writeError(w, http.StatusServiceUnavailable, "메일 연동이 설정되지 않았습니다")
|
||||
return
|
||||
}
|
||||
full, err := s.mail.GetFull(r.Context(), s.email(r), msgID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, full)
|
||||
}
|
||||
|
||||
// handleMailAttachment streams one attachment's bytes (impersonating the requester).
|
||||
func (s *Server) handleMailAttachment(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if !s.canSeeProject(r, id) || !s.mail.Enabled() {
|
||||
writeError(w, http.StatusForbidden, "권한이 없습니다")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
if _, ok := s.involvedRow(w, r, id, q.Get("messageId")); !ok {
|
||||
return
|
||||
}
|
||||
data, err := s.mail.GetAttachment(r.Context(), s.email(r), q.Get("gmailMsgId"), q.Get("attachmentId"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, err.Error())
|
||||
return
|
||||
}
|
||||
fn := q.Get("filename")
|
||||
if fn == "" {
|
||||
fn = "attachment"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(fn))
|
||||
w.Write(data)
|
||||
}
|
||||
|
||||
// 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")
|
||||
|
||||
@ -132,6 +132,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.Get("/projects/{id}/mails/full", s.handleMailFull)
|
||||
r.Get("/projects/{id}/mails/attachment", s.handleMailAttachment)
|
||||
r.Post("/projects/{id}/mails/sync", s.handleSyncProjectMail)
|
||||
r.Put("/projects/{id}/mail-hide", s.handleHideMail)
|
||||
r.Get("/projects/{id}/mail-notes", s.handleListMailNotes)
|
||||
|
||||
@ -55,6 +55,34 @@ type Message struct {
|
||||
TS int64 `json:"ts"` // internalDate (ms) for sorting/formatting
|
||||
}
|
||||
|
||||
// Attachment is one file on a message.
|
||||
type Attachment struct {
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Size int64 `json:"size"`
|
||||
AttachmentID string `json:"attachmentId"`
|
||||
}
|
||||
|
||||
// FullMessage is the full body + attachment list of one message.
|
||||
type FullMessage struct {
|
||||
GmailID string `json:"gmailId"`
|
||||
Body string `json:"body"`
|
||||
IsHTML bool `json:"isHtml"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
}
|
||||
|
||||
// gmailPart mirrors the Gmail message payload tree (recursive).
|
||||
type gmailPart struct {
|
||||
MimeType string `json:"mimeType"`
|
||||
Filename string `json:"filename"`
|
||||
Body struct {
|
||||
Size int64 `json:"size"`
|
||||
Data string `json:"data"`
|
||||
AttachmentID string `json:"attachmentId"`
|
||||
} `json:"body"`
|
||||
Parts []gmailPart `json:"parts"`
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@ -263,6 +291,117 @@ func (s *Service) getMeta(ctx context.Context, subject, id string) (Message, err
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func decB64(s string) ([]byte, error) {
|
||||
if m := len(s) % 4; m != 0 {
|
||||
s += strings.Repeat("=", 4-m)
|
||||
}
|
||||
return base64.URLEncoding.DecodeString(s)
|
||||
}
|
||||
|
||||
func (s *Service) findGmailID(ctx context.Context, mailbox, rfc822 string) (string, error) {
|
||||
ids, _, err := s.listPage(ctx, mailbox, "rfc822msgid:"+rfc822, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return ids[0], nil
|
||||
}
|
||||
|
||||
// GetFull fetches the full body (text preferred, else HTML) and attachment list of
|
||||
// a message, impersonating the given mailbox (which must contain the message).
|
||||
func (s *Service) GetFull(ctx context.Context, mailbox, rfc822 string) (FullMessage, error) {
|
||||
if !s.Enabled() {
|
||||
return FullMessage{}, fmt.Errorf("mail integration disabled")
|
||||
}
|
||||
gid, err := s.findGmailID(ctx, mailbox, rfc822)
|
||||
if err != nil {
|
||||
return FullMessage{}, err
|
||||
}
|
||||
if gid == "" {
|
||||
return FullMessage{}, fmt.Errorf("메일을 찾을 수 없습니다(메일함에 없음)")
|
||||
}
|
||||
tok, err := s.accessToken(ctx, mailbox)
|
||||
if err != nil {
|
||||
return FullMessage{}, err
|
||||
}
|
||||
u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s?format=full", gid)
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return FullMessage{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return FullMessage{}, fmt.Errorf("gmail get full %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Payload gmailPart `json:"payload"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return FullMessage{}, err
|
||||
}
|
||||
var plain, html string
|
||||
var atts []Attachment
|
||||
var walk func(p gmailPart)
|
||||
walk = func(p gmailPart) {
|
||||
if p.Filename != "" && p.Body.AttachmentID != "" {
|
||||
atts = append(atts, Attachment{Filename: p.Filename, MimeType: p.MimeType, Size: p.Body.Size, AttachmentID: p.Body.AttachmentID})
|
||||
} else if p.Body.Data != "" {
|
||||
if b, e := decB64(p.Body.Data); e == nil {
|
||||
if strings.HasPrefix(p.MimeType, "text/plain") && plain == "" {
|
||||
plain = string(b)
|
||||
} else if strings.HasPrefix(p.MimeType, "text/html") && html == "" {
|
||||
html = string(b)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, c := range p.Parts {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(out.Payload)
|
||||
fm := FullMessage{GmailID: gid, Attachments: atts}
|
||||
if plain != "" {
|
||||
fm.Body = plain
|
||||
} else {
|
||||
fm.Body = html
|
||||
fm.IsHTML = true
|
||||
}
|
||||
return fm, nil
|
||||
}
|
||||
|
||||
// GetAttachment downloads one attachment's bytes, impersonating the mailbox.
|
||||
func (s *Service) GetAttachment(ctx context.Context, mailbox, gmailMsgID, attachmentID string) ([]byte, error) {
|
||||
if !s.Enabled() {
|
||||
return nil, fmt.Errorf("mail integration disabled")
|
||||
}
|
||||
tok, err := s.accessToken(ctx, mailbox)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u := fmt.Sprintf("https://gmail.googleapis.com/gmail/v1/users/me/messages/%s/attachments/%s", gmailMsgID, attachmentID)
|
||||
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 attachment %d", resp.StatusCode)
|
||||
}
|
||||
var out struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decB64(out.Data)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user