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
+139
View File
@@ -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) {