From e0cd2168001d18442cfac05ef4cc2864715ad72b Mon Sep 17 00:00:00 2001 From: theorose49 Date: Tue, 30 Jun 2026 14:18:49 +0900 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=EA=B0=80=EC=8B=9C=EC=84=B1=3D?= =?UTF-8?q?=EB=8B=A8=EC=9D=BC=EB=A9=94=EC=9D=BC=20=EC=B0=B8=EC=A1=B0?= =?UTF-8?q?=EC=97=AC=EB=B6=80=EB=A1=9C=20=EB=B3=B5=EA=B7=80=20+=20?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=A0=84=EB=AC=B8=C2=B7=EC=B2=A8=EB=B6=80?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=98=A8=EB=94=94=EB=A7=A8=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 가시성을 스레드 단위 → 단일 메일 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) --- internal/httpapi/handlers_projects.go | 85 +++++++++++++--- internal/httpapi/router.go | 2 + internal/mailsync/mailsync.go | 139 ++++++++++++++++++++++++++ 3 files changed, 210 insertions(+), 16 deletions(-) diff --git a/internal/httpapi/handlers_projects.go b/internal/httpapi/handlers_projects.go index ea428af..f0bb186 100644 --- a/internal/httpapi/handlers_projects.go +++ b/internal/httpapi/handlers_projects.go @@ -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") diff --git a/internal/httpapi/router.go b/internal/httpapi/router.go index 6ac4ce2..2d003df 100644 --- a/internal/httpapi/router.go +++ b/internal/httpapi/router.go @@ -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) diff --git a/internal/mailsync/mailsync.go b/internal/mailsync/mailsync.go index cd34e80..f3a7d93 100644 --- a/internal/mailsync/mailsync.go +++ b/internal/mailsync/mailsync.go @@ -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) {