feat(mail): 가시성=단일메일 참조여부로 복귀 + 메일 전문·첨부파일 온디맨드
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:
theorose49
2026-06-30 14:18:49 +09:00
co-authored by Claude Opus 4.8
parent 14e8a62f76
commit e0cd216800
3 changed files with 210 additions and 16 deletions
+69 -16
View File
@@ -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")
+2
View File
@@ -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)