// Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT package mail import ( "bytes" "context" "encoding/base64" "encoding/json" "fmt" "net/url" "path/filepath" "strings" "time" "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/shortcuts/common" draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" "github.com/larksuite/cli/shortcuts/mail/emlbuilder" "github.com/larksuite/cli/shortcuts/mail/filecheck" ) // attachmentFile holds metadata about a local file to be attached. type attachmentFile struct { Path string // relative file path as provided by the user FileName string // basename Size int64 // raw file size in bytes SourceIndex int // original index in the caller's list (e.g. patch op index) Data []byte // in-memory content; when non-nil, used instead of Path for upload } // classifiedAttachments is the result of classifyAttachments. type classifiedAttachments struct { Normal []attachmentFile // to be embedded in the EML Oversized []attachmentFile // to be uploaded as large attachments } // largeAttachmentResult holds the upload result for a single large attachment. type largeAttachmentResult struct { FileName string FileSize int64 FileToken string } // MaxLargeAttachmentSize is the maximum allowed size for a single large // attachment, aligned with the desktop client (3 GB). const MaxLargeAttachmentSize = 3 * 1024 * 1024 * 1024 // 3 GB // largeAttID is the JSON element inside the X-Lms-Large-Attachment-Ids header. // The header name itself is defined as draftpkg.LargeAttachmentIDsHeader. type largeAttID struct { ID string `json:"id"` } // estimateBase64EMLSize estimates the EML byte cost of embedding a raw file. // base64 inflates 3 bytes → 4 chars, plus ~200 bytes for MIME part headers. const base64MIMEOverhead = 200 func estimateBase64EMLSize(rawSize int64) int64 { return (rawSize*4+2)/3 + base64MIMEOverhead } // estimateEMLBaseSize estimates the EML size consumed by non-attachment content: // headers (~2KB), body text/HTML, and inline images. Each component is // accounted for with base64 encoding overhead where applicable. // // Parameters: // - bodySize: raw size of the text or HTML body in bytes // - inlineFilePaths: paths of inline image files (will be stat'd for size) // - extraBytes: any additional pre-computed EML bytes (e.g. downloaded // original attachments already loaded in memory for forward) func estimateEMLBaseSize(fio fileio.FileIO, bodySize int64, inlineFilePaths []string, extraBytes int64) int64 { const headerOverhead = 2048 // generous estimate for all headers + MIME structure total := int64(headerOverhead) + estimateBase64EMLSize(bodySize) + extraBytes for _, p := range inlineFilePaths { if info, err := fio.Stat(p); err == nil { total += estimateBase64EMLSize(info.Size()) } } return total } // classifyAttachments splits files into normal (embed in EML) and oversized // (upload separately as large attachments). // // The decision is based on the estimated total EML size: headers + body + // inline images + attachments, all base64-encoded. Files are processed in // the user-specified order. Once a file would push the EML over MaxEMLSize, // it and all subsequent files are classified as oversized. func classifyAttachments(files []attachmentFile, emlBaseSize int64) classifiedAttachments { var result classifiedAttachments accumulated := emlBaseSize overflow := false for _, f := range files { if overflow { result.Oversized = append(result.Oversized, f) continue } cost := estimateBase64EMLSize(f.Size) if accumulated+cost > emlbuilder.MaxEMLSize { overflow = true result.Oversized = append(result.Oversized, f) continue } accumulated += cost result.Normal = append(result.Normal, f) } return result } // statAttachmentFiles stats each path, checks blocked extensions, and returns // attachmentFile metadata. func statAttachmentFiles(fio fileio.FileIO, paths []string) ([]attachmentFile, error) { files := make([]attachmentFile, 0, len(paths)) for _, p := range paths { if strings.TrimSpace(p) == "" { continue } name := filepath.Base(p) if err := filecheck.CheckBlockedExtension(name); err != nil { return nil, err } info, err := fio.Stat(p) if err != nil { return nil, fmt.Errorf("failed to stat attachment %s: %w", p, err) } files = append(files, attachmentFile{ Path: p, FileName: name, Size: info.Size(), }) } return files, nil } // uploadLargeAttachments uploads oversized files to the mail attachment storage // via the medias/upload_* API with parent_type="email". func uploadLargeAttachments(ctx context.Context, runtime *common.RuntimeContext, files []attachmentFile) ([]largeAttachmentResult, error) { if len(files) == 0 { return nil, nil } userOpenId := runtime.UserOpenId() if userOpenId == "" { return nil, fmt.Errorf("large attachment upload requires user identity (user open_id not available)") } results := make([]largeAttachmentResult, 0, len(files)) for _, f := range files { fmt.Fprintf(runtime.IO().ErrOut, "Uploading large attachment: %s (%s)\n", f.FileName, common.FormatSize(f.Size)) var ( fileToken string err error ) if f.Data != nil { fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ FileName: f.FileName, FileSize: f.Size, ParentType: "email", ParentNode: &userOpenId, Reader: bytes.NewReader(f.Data), }) } else if f.Size <= common.MaxDriveMediaUploadSinglePartSize { fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{ FilePath: f.Path, FileName: f.FileName, FileSize: f.Size, ParentType: "email", ParentNode: &userOpenId, }) } else { fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{ FilePath: f.Path, FileName: f.FileName, FileSize: f.Size, ParentType: "email", ParentNode: userOpenId, }) } if err != nil { return nil, fmt.Errorf("failed to upload large attachment %s: %w", f.FileName, err) } results = append(results, largeAttachmentResult{ FileName: f.FileName, FileSize: f.Size, FileToken: fileToken, }) } return results, nil } // buildLargeAttachmentPreviewURL builds the download/preview URL for a large // attachment token. The domain is derived from the CLI's configured endpoint // (e.g. open.feishu.cn → www.feishu.cn). func buildLargeAttachmentPreviewURL(brand core.LarkBrand, fileToken string) string { ep := core.ResolveEndpoints(brand) host := strings.TrimPrefix(ep.Open, "https://") host = strings.TrimPrefix(host, "http://") mainDomain := strings.TrimPrefix(host, "open.") return "https://www." + mainDomain + "/mail/page/attachment?token=" + url.QueryEscape(fileToken) } // buildLargeAttachmentHTML generates the HTML block for large attachments, // matching the desktop client's exportLargeFileArea style. // // Reference: mail-editor/src/plugins/bigAttachment/export.ts // Large attachment HTML templates, matching desktop's exportLargeFileArea // (mail-editor/src/plugins/bigAttachment/export.ts). // // IDs: container = "large-file-area-{9-digit-timestamp}", item = "large-file-item" // Colors: title bg = rgb(224, 233, 255), link = rgb(20, 86, 240) // Layout: float (not flexbox) for email client compatibility const ( // %s order: timestamp, title, items largeAttContainerTpl = `
` + `
%s
` + `%s` + // items `
` // %s order: icon URL, filename, file size, preview link, token, download text largeAttItemTpl = `
` + `
` + `` + // icon URL `
` + `
` + `
%s
` + // filename `
` + `%s` + // file size `
` + `
` + `%s` + // preview link, token, download text `
` iconCDNCN = "https://lf-larkemail.bytetos.com/obj/eden-cn/aultojhaah_npi_spht_ryhs/ljhwZthlaukjlkulzlp/" iconCDNEN = "https://sf16-sg.tiktokcdn.com/obj/eden-sg/aultojhaah_npi_spht_ryhs/ljhwZthlaukjlkulzlp/" ) // brandDisplayName returns the product display name used in mail HTML // text, aligning with the desktop client's APP_DISPLAY_NAME i18n // substitution. // // - BrandLark → "Lark" (same in English and Chinese) // - BrandFeishu → "飞书" for zh languages, "Feishu" for others func brandDisplayName(brand core.LarkBrand, lang string) string { if brand == core.BrandLark { return "Lark" } if strings.HasPrefix(lang, "zh") { return "飞书" } return "Feishu" } func buildLargeAttachmentItems(brand core.LarkBrand, lang string, results []largeAttachmentResult) string { if len(results) == 0 { return "" } downloadText := "Download" if strings.HasPrefix(lang, "zh") { downloadText = "下载" } iconCDN := iconCDNCN if brand == core.BrandLark { iconCDN = iconCDNEN } var items strings.Builder for _, att := range results { fmt.Fprintf(&items, largeAttItemTpl, htmlEscape(iconCDN+fileTypeIcon(att.FileName)), htmlEscape(att.FileName), htmlEscape(common.FormatSize(att.FileSize)), htmlEscape(buildLargeAttachmentPreviewURL(brand, att.FileToken)), htmlEscape(att.FileToken), downloadText, ) } return items.String() } func buildLargeAttachmentHTML(brand core.LarkBrand, lang string, results []largeAttachmentResult) string { if len(results) == 0 { return "" } appName := brandDisplayName(brand, lang) title := "Large file from " + appName + " Mail" if strings.HasPrefix(lang, "zh") { title = "来自" + appName + "邮箱的超大附件" } timestamp := fmt.Sprintf("%d", time.Now().UnixMilli()) if len(timestamp) > 9 { timestamp = timestamp[:9] } return fmt.Sprintf(largeAttContainerTpl, timestamp, title, buildLargeAttachmentItems(brand, lang, results)) } func buildLargeAttachmentPlainText(brand core.LarkBrand, lang string, results []largeAttachmentResult) string { if len(results) == 0 { return "" } appName := brandDisplayName(brand, lang) title := "Large file from " + appName + " Mail" downloadText := "Download" if strings.HasPrefix(lang, "zh") { title = "来自" + appName + "邮箱的超大附件" downloadText = "下载" } var sb strings.Builder sb.WriteString("\n") sb.WriteString(title) sb.WriteString("\n") for i, att := range results { sb.WriteString(att.FileName) sb.WriteString("\n") sb.WriteString(common.FormatSize(att.FileSize)) sb.WriteString("\n") sb.WriteString(downloadText + ": " + buildLargeAttachmentPreviewURL(brand, att.FileToken)) if i < len(results)-1 { sb.WriteString("\n\n") } else { sb.WriteString("\n") } } return sb.String() } // fileTypeIcon returns the CDN icon filename for a given attachment filename, // matching desktop's AttachmentIconPath (mail-editor/src/plugins/bigAttachment/utils.ts). func fileTypeIcon(filename string) string { ext := strings.ToLower(filepath.Ext(filename)) if len(ext) > 0 { ext = ext[1:] // strip leading dot } switch ext { case "doc", "docx": return "icon_file_doc.png" case "pdf": return "icon_file_pdf.png" case "ppt", "pptx": return "icon_file_ppt.png" case "xls", "xlsx": return "icon_file_excel.png" case "zip", "rar", "7z", "tar", "gz": return "icon_file_zip.png" case "png", "jpg", "jpeg", "gif", "bmp", "webp", "svg", "ico", "tiff": return "icon_file_image.png" case "mp4", "avi", "mov", "mkv", "wmv", "flv": return "icon_file_video.png" case "mp3", "wav", "flac", "aac", "ogg", "wma": return "icon_file_audio.png" case "txt": return "icon_file_doc.png" case "eml": return "icon_file_eml.png" case "apk": return "icon_file_android.png" case "psd": return "icon_file_ps.png" case "ai": return "icon_file_ai.png" case "sketch": return "icon_file_sketch.png" case "key", "keynote": return "icon_file_keynote.png" case "numbers": return "icon_file_numbers.png" case "pages": return "icon_file_pages.png" default: return "icon_file_unknow.png" } } // processLargeAttachments is the unified entry point for large attachment // handling across all mail compose shortcuts (draft-create, reply, forward, send). // // Parameters: // - htmlBody: the current HTML body string (for quote-aware insertion); empty for plain-text emails // - textBody: the current text body string; empty for HTML emails // - attachPaths: user-specified attachment file paths (from --attach flag) // - extraEMLBytes: EML bytes already accounted for // - extraAttachCount: number of attachments already added to bld func processLargeAttachments( ctx context.Context, runtime *common.RuntimeContext, bld emlbuilder.Builder, htmlBody string, textBody string, attachPaths []string, extraEMLBytes int64, extraAttachCount int, ) (emlbuilder.Builder, error) { totalCount := extraAttachCount + len(attachPaths) if totalCount > MaxAttachmentCount { return bld, fmt.Errorf("attachment count %d exceeds the limit of %d", totalCount, MaxAttachmentCount) } files, err := statAttachmentFiles(runtime.FileIO(), attachPaths) if err != nil { return bld, err } for _, f := range files { if f.Size > MaxLargeAttachmentSize { return bld, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit", f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024) } } classified := classifyAttachments(files, extraEMLBytes) if len(classified.Oversized) == 0 { for _, f := range classified.Normal { bld = bld.AddFileAttachment(f.Path) } return bld, nil } if htmlBody == "" && textBody == "" { return bld, fmt.Errorf("large attachments require a body; " + "empty messages cannot include the download link") } if runtime.Config == nil || runtime.UserOpenId() == "" { var totalBytes int64 for _, f := range files { totalBytes += f.Size } return bld, fmt.Errorf("total attachment size %.1f MB exceeds the 25 MB EML limit; "+ "large attachment upload requires user identity (--as user)", float64(totalBytes)/1024/1024) } results, err := uploadLargeAttachments(ctx, runtime, classified.Oversized) if err != nil { return bld, err } if htmlBody != "" { largeHTML := buildLargeAttachmentHTML(runtime.Config.Brand, resolveLang(runtime), results) bld = bld.HTMLBody([]byte(draftpkg.InsertBeforeQuoteOrAppend(htmlBody, largeHTML))) } else { largeText := buildLargeAttachmentPlainText(runtime.Config.Brand, resolveLang(runtime), results) bld = bld.TextBody([]byte(textBody + largeText)) } ids := make([]largeAttID, len(results)) for i, r := range results { ids[i] = largeAttID{ID: r.FileToken} } idsJSON, err := json.Marshal(ids) if err != nil { return bld, fmt.Errorf("failed to encode large attachment IDs: %w", err) } bld = bld.Header(draftpkg.LargeAttachmentIDsHeader, base64.StdEncoding.EncodeToString(idsJSON)) for _, f := range classified.Normal { bld = bld.AddFileAttachment(f.Path) } fmt.Fprintf(runtime.IO().ErrOut, " %d normal attachment(s) embedded in EML\n", len(classified.Normal)) fmt.Fprintf(runtime.IO().ErrOut, " %d large attachment(s) uploaded (download links in body)\n", len(classified.Oversized)) return bld, nil } // ensureLargeAttachmentCards checks whether the snapshot's HTML body is missing // download cards for large attachments registered in the header. Drafts read // back from the server may have their HTML cards stripped, even though the // server-format X-Lark-Large-Attachment header still carries file_name and // file_size metadata. This function uses that metadata to reconstruct only the // missing cards/text and injects them into the body (HTML or plain text) // without duplicating entries that are already present. // // Must be called BEFORE normalizeLargeAttachmentHeader, because that // function converts the server-format header to CLI format and discards // file_name/file_size. func ensureLargeAttachmentCards(runtime *common.RuntimeContext, snapshot *draftpkg.DraftSnapshot) { summaries := draftpkg.ParseLargeAttachmentSummariesFromHeader(snapshot.Headers) if len(summaries) == 0 { return } brand := core.BrandFeishu if runtime.Config != nil { brand = runtime.Config.Brand } lang := "zh_cn" if runtime.Factory != nil { lang = resolveLang(runtime) } htmlPart := draftpkg.FindHTMLBodyPart(snapshot.Body) if htmlPart != nil { existingCards := draftpkg.ParseLargeAttachmentItemsFromHTML(string(htmlPart.Body)) var missing []largeAttachmentResult for _, s := range summaries { if _, exists := existingCards[s.Token]; !exists { missing = append(missing, largeAttachmentResult{ FileName: s.FileName, FileSize: s.SizeBytes, FileToken: s.Token, }) } } if len(missing) == 0 { return } injectLargeAttachmentHTMLIntoSnapshot(snapshot, brand, lang, missing) return } textPart := draftpkg.FindTextBodyPart(snapshot.Body) if textPart != nil { bodyText := string(textPart.Body) var missing []largeAttachmentResult for _, s := range summaries { if !strings.Contains(bodyText, s.Token) { missing = append(missing, largeAttachmentResult{ FileName: s.FileName, FileSize: s.SizeBytes, FileToken: s.Token, }) } } if len(missing) == 0 { return } largeText := buildLargeAttachmentPlainText(brand, lang, missing) injectLargeAttachmentTextIntoSnapshot(snapshot, largeText) } } // preprocessLargeAttachmentsForDraftEdit scans a draft-edit patch for // add_attachment ops, classifies the files (normal vs oversized based on // the snapshot's current EML size), uploads oversized files, injects the // large attachment HTML card into the snapshot's HTML body, and returns // the patch with oversized ops removed (normal ops stay for draft.Apply). func preprocessLargeAttachmentsForDraftEdit( ctx context.Context, runtime *common.RuntimeContext, snapshot *draftpkg.DraftSnapshot, patch draftpkg.Patch, ) (draftpkg.Patch, error) { // Reconstruct missing large attachment HTML cards from the server-format // header metadata. Must run before normalizeLargeAttachmentHeader which // discards file_name/file_size. ensureLargeAttachmentCards(runtime, snapshot) // Always normalize server-format headers to CLI format so every code // path below (and every early return) sends the format the server // recognizes on write. normalizeLargeAttachmentHeader(snapshot) // Collect add_attachment ops and their indices. type attachOp struct { index int path string } var attachOps []attachOp for i, op := range patch.Ops { if op.Op == "add_attachment" { attachOps = append(attachOps, attachOp{index: i, path: op.Path}) } } if len(attachOps) == 0 { return patch, nil } // Stat all attachment files. paths := make([]string, len(attachOps)) for i, ao := range attachOps { paths[i] = ao.path } files, err := statAttachmentFiles(runtime.FileIO(), paths) if err != nil { return patch, err } for i := range files { files[i].SourceIndex = attachOps[i].index } // Check 3GB single file limit. for _, f := range files { if f.Size > MaxLargeAttachmentSize { return patch, fmt.Errorf("attachment %s (%.1f GB) exceeds the %.0f GB single file limit", f.FileName, float64(f.Size)/1024/1024/1024, float64(MaxLargeAttachmentSize)/1024/1024/1024) } } // Calculate the snapshot's current EML base size. emlBaseSize := snapshotEMLBaseSize(snapshot) // Classify files. classified := classifyAttachments(files, emlBaseSize) if len(classified.Oversized) == 0 { return patch, nil // all fit, let draft.Apply handle them } // Guard: large attachment requires at least some body part. hasHTML := draftpkg.FindHTMLBodyPart(snapshot.Body) != nil hasText := draftpkg.FindTextBodyPart(snapshot.Body) != nil if !hasHTML && !hasText { return patch, fmt.Errorf("large attachments require a body; " + "empty drafts cannot include the download link") } // Guard: need user identity for upload. if runtime.Config == nil || runtime.UserOpenId() == "" { var totalBytes int64 for _, f := range files { totalBytes += f.Size } return patch, fmt.Errorf("total attachment size %.1f MB exceeds the 25 MB EML limit; "+ "large attachment upload requires user identity (--as user)", float64(totalBytes)/1024/1024) } // Upload oversized files. results, err := uploadLargeAttachments(ctx, runtime, classified.Oversized) if err != nil { return patch, err } if hasHTML { injectLargeAttachmentHTMLIntoSnapshot(snapshot, runtime.Config.Brand, resolveLang(runtime), results) } else { largeText := buildLargeAttachmentPlainText(runtime.Config.Brand, resolveLang(runtime), results) injectLargeAttachmentTextIntoSnapshot(snapshot, largeText) } // Register large attachment tokens, merging with any existing IDs already // present in the snapshot (from a previous draft-create or draft-edit). // The server returns X-Lark-Large-Attachment on readback, so check both // header names. var existingIDs []largeAttID existingIdx := -1 for i, h := range snapshot.Headers { if draftpkg.IsLargeAttachmentHeader(h.Name) { existingIdx = i if decoded, err := base64.StdEncoding.DecodeString(h.Value); err == nil { var raw []json.RawMessage if json.Unmarshal(decoded, &raw) == nil { for _, r := range raw { var entry struct { ID string `json:"id"` FileKey string `json:"file_key"` } if json.Unmarshal(r, &entry) == nil { tok := entry.ID if tok == "" { tok = entry.FileKey } if tok != "" { existingIDs = append(existingIDs, largeAttID{ID: tok}) } } } } } break } } merged := existingIDs for _, r := range results { merged = append(merged, largeAttID{ID: r.FileToken}) } idsJSON, err := json.Marshal(merged) if err != nil { return patch, fmt.Errorf("failed to encode large attachment IDs: %w", err) } headerValue := base64.StdEncoding.EncodeToString(idsJSON) if existingIdx >= 0 { snapshot.Headers[existingIdx].Name = draftpkg.LargeAttachmentIDsHeader snapshot.Headers[existingIdx].Value = headerValue } else { snapshot.Headers = append(snapshot.Headers, draftpkg.Header{ Name: draftpkg.LargeAttachmentIDsHeader, Value: headerValue, }) } // Remove oversized ops from the patch (keep normal ones for draft.Apply). oversizedIndices := make(map[int]bool, len(classified.Oversized)) for _, f := range classified.Oversized { oversizedIndices[f.SourceIndex] = true } var filteredOps []draftpkg.PatchOp for i, op := range patch.Ops { if oversizedIndices[i] { continue // skip oversized, already uploaded } filteredOps = append(filteredOps, op) } patch.Ops = filteredOps fmt.Fprintf(runtime.IO().ErrOut, " %d normal attachment(s) in patch\n", len(classified.Normal)) fmt.Fprintf(runtime.IO().ErrOut, " %d large attachment(s) uploaded (download links in body)\n", len(classified.Oversized)) return patch, nil } // snapshotEMLBaseSize estimates the current EML size of a draft snapshot by // summing all part bodies (base64 encoded) plus a header overhead. func snapshotEMLBaseSize(snapshot *draftpkg.DraftSnapshot) int64 { const headerOverhead = 2048 var total int64 = headerOverhead for _, p := range flattenSnapshotParts(snapshot.Body) { total += estimateBase64EMLSize(int64(len(p.Body))) } return total } // flattenSnapshotParts recursively collects all parts in the MIME tree. func flattenSnapshotParts(root *draftpkg.Part) []*draftpkg.Part { if root == nil { return nil } out := []*draftpkg.Part{root} for _, child := range root.Children { out = append(out, flattenSnapshotParts(child)...) } return out } // injectLargeAttachmentHTMLIntoSnapshot adds large attachment items to the // snapshot's HTML body. When the body already contains a large-file-area // container, new items are appended inside that container (maintaining a // single container, matching the desktop client). Otherwise a new // container is created and inserted before the quote block (or appended). func injectLargeAttachmentHTMLIntoSnapshot(snapshot *draftpkg.DraftSnapshot, brand core.LarkBrand, lang string, results []largeAttachmentResult) { if len(results) == 0 { return } htmlPart := draftpkg.FindHTMLBodyPart(snapshot.Body) if htmlPart == nil { if snapshot.Body != nil { return } snapshot.Body = &draftpkg.Part{ MediaType: "text/html", Body: []byte(buildLargeAttachmentHTML(brand, lang, results)), Dirty: true, } return } currentHTML := string(htmlPart.Body) if draftpkg.HTMLContainsLargeAttachment(currentHTML) { itemsHTML := buildLargeAttachmentItems(brand, lang, results) before, card, after := draftpkg.SplitAtLargeAttachment(currentHTML) merged := card[:len(card)-len("")] + itemsHTML + "" htmlPart.Body = []byte(before + merged + after) } else { fullHTML := buildLargeAttachmentHTML(brand, lang, results) htmlPart.Body = []byte(draftpkg.InsertBeforeQuoteOrAppend(currentHTML, fullHTML)) } htmlPart.Dirty = true } func injectLargeAttachmentTextIntoSnapshot(snapshot *draftpkg.DraftSnapshot, largeText string) { textPart := draftpkg.FindTextBodyPart(snapshot.Body) if textPart == nil { if snapshot.Body != nil { return } snapshot.Body = &draftpkg.Part{ MediaType: "text/plain", Body: []byte(largeText), Dirty: true, } return } textPart.Body = append(textPart.Body, []byte(largeText)...) textPart.Dirty = true } // normalizeLargeAttachmentHeader converts server-format X-Lark-Large-Attachment // headers to CLI-format X-Lms-Large-Attachment-Ids and removes all server-format // headers. This ensures the PUT update always sends the format the server // recognizes for write operations. func normalizeLargeAttachmentHeader(snapshot *draftpkg.DraftSnapshot) { cliIdx := -1 var serverIdxs []int seen := make(map[string]bool) var serverTokens []largeAttID for i, h := range snapshot.Headers { if !draftpkg.IsLargeAttachmentHeader(h.Name) { continue } if strings.EqualFold(h.Name, draftpkg.LargeAttachmentIDsHeader) { cliIdx = i continue } serverIdxs = append(serverIdxs, i) decoded, err := base64.StdEncoding.DecodeString(h.Value) if err != nil { continue } var raw []json.RawMessage if json.Unmarshal(decoded, &raw) != nil { continue } for _, r := range raw { var entry struct { ID string `json:"id"` FileKey string `json:"file_key"` } if json.Unmarshal(r, &entry) == nil { tok := entry.ID if tok == "" { tok = entry.FileKey } if tok != "" && !seen[tok] { seen[tok] = true serverTokens = append(serverTokens, largeAttID{ID: tok}) } } } } if len(serverIdxs) == 0 { return } // Remove server-format headers in reverse order to preserve indices. for j := len(serverIdxs) - 1; j >= 0; j-- { idx := serverIdxs[j] snapshot.Headers = append(snapshot.Headers[:idx], snapshot.Headers[idx+1:]...) if cliIdx > idx { cliIdx-- } } // If a CLI-format header exists, it is authoritative — keep it as-is. if cliIdx >= 0 { return } // No CLI header — convert server tokens into one. if len(serverTokens) == 0 { return } idsJSON, err := json.Marshal(serverTokens) if err != nil { return } snapshot.Headers = append(snapshot.Headers, draftpkg.Header{ Name: draftpkg.LargeAttachmentIDsHeader, Value: base64.StdEncoding.EncodeToString(idsJSON), }) }