Files
larksuite-cli/shortcuts/mail/large_attachment.go
chanthuang c13644a247 feat(mail): support large email attachments (#537)
* feat(mail): add large attachment support via medias/upload API

When attachments would cause the EML to exceed the 25MB limit, they are
automatically uploaded to the mail attachment storage (medias/upload_all
with parent_type="email") and a download-link card is injected into the
HTML body, matching the desktop client's exportLargeFileArea style.

Key changes:
- Add classifyAttachments: EML-size-based splitting of normal vs oversized
- Add uploadLargeAttachments: upload via medias API with email MountPoint
- Add buildLargeAttachmentHTML: desktop-aligned card with CDN icons
- Add processLargeAttachments: unified entry point for all compose shortcuts
- Add LargeAttachmentHTML to emlbuilder.Builder for HTML block injection
- Fix 7bit line folding: use RFC 5322 limit (998) instead of incorrect 76
- Integrate into +draft-create, +forward, +reply, +reply-all

Known limitation: recipient access to large attachment links requires
backend support to register tokens with the draft (see progress doc).

Change-Id: If8d5938015cac8bc82de3ea3ff41022950f2571e
Co-Authored-By: AI

* refactor(mail): remove legacy size check, add 3GB limit, integrate +send

- Remove checkAttachmentSizeLimit (replaced by processLargeAttachments)
- Remove 25MB pre-check from validateComposeInlineAndAttachments so that
  large files reach Execute where they are uploaded as large attachments
- Integrate processLargeAttachments into +send shortcut
- Add 3GB single file limit aligned with desktop client
- Clean up unused imports from helpers.go and helpers_test.go

Change-Id: Ie590ad2b58263c075f48b338959b8f5b3f912f85
Co-Authored-By: AI

* feat(mail): quote-aware HTML insertion, +draft-edit support, cleanup emlbuilder

- Add insertBeforeQuoteOrAppend: insert large attachment HTML before the
  quote block (lark-mail-quote) instead of appending to body end, matching
  desktop's exportLargeFileArea placement logic
- Add preprocessLargeAttachmentsForDraftEdit: intercept add_attachment
  patch ops before draft.Apply, upload oversized files, inject HTML into
  snapshot's HTML body Part directly. No changes to draft sub-package.
- Remove LargeAttachmentHTML field/setter/logic from emlbuilder — it was
  business logic (quote-aware insertion) that doesn't belong in a generic
  EML builder. processLargeAttachments now sets the full HTML body via
  bld.HTMLBody() after merging the large attachment card at the right position.
- All compose shortcuts pass htmlBody to processLargeAttachments for
  quote-aware insertion (composedHTMLBody for reply/forward, body for others).

Change-Id: If6e7ed7e77989ab9a8a41a93758f686d72ccf497
Co-Authored-By: AI

* fix(mail): align large attachment HTML IDs with desktop client

- Container ID: lark-mail-large-file-container → large-file-area (matching
  desktop's MAIL_LARGE_FILE_CONTAINER constant)
- Item ID: lark-mail-large-file-item → large-file-item (matching
  desktop's MAIL_LARGE_FILE_ITEM constant)
- Timestamp: truncate to 9 digits (matching TIMESTAMP_CUT_OUT_ID = 9)
- Refactor HTML generation to use template constants for readability

These IDs are used by the desktop client's BigAttachmentPlugin
([id^=large-file-area]) and the server's LargeFileRule to identify and
remove the HTML block when rendering the attachment card UI.

Change-Id: Ib5a77a1a3d60eeb3a05c585f2af0a5ddaacf887b
Co-Authored-By: AI

* docs(mail): document large attachment behavior in skill references

Update --attach parameter descriptions across all compose shortcuts
(+send, +reply, +reply-all, +forward, +draft-create, +draft-edit) to
describe automatic large attachment handling when EML exceeds 25 MB.

Change-Id: I8c30e390c127ea1119cb8c4b83ec636e41fbaf66
Co-Authored-By: AI

* fix(mail): pass signature-injected HTML to processLargeAttachments

When both --signature-id and large attachments are used, the htmlBody
passed to processLargeAttachments must include the already-injected
signature. Previously mail_send and mail_draft_create passed the
original body, causing processLargeAttachments to overwrite the
signature-injected HTML body when inserting the large attachment card.

Use composedHTMLBody variable (same pattern as reply/forward) to
capture the full processed HTML including signature.

Change-Id: I6be330776abca704b10cc3b8bfd5e20838e6e538
Co-Authored-By: AI

* fix(mail): skip draft.Apply when all ops consumed by large attachment preprocessing

When all patch ops are add_attachment targeting oversized files,
preprocessLargeAttachmentsForDraftEdit uploads them and removes the
ops from the patch. The resulting empty patch caused draft.Apply to
fail with "patch ops is required". Now skip Apply when no ops remain.

Change-Id: I8067a54b5f849fa519e8344a7eb10c48f58e54b8
Co-Authored-By: AI

* fix(mail): add X-Lms-Large-Attachment-Ids header in draft-edit large attachment flow

draft-edit's preprocessLargeAttachmentsForDraftEdit uploaded oversized files
and injected HTML cards but never wrote the X-Lms-Large-Attachment-Ids header
into the snapshot, so the mail server could not associate the attachments with
the draft. Merge new token IDs with any existing ones already in the snapshot.

Also extract the duplicated largeAttID struct and header name string into
package-level declarations.

Change-Id: Id256d948ec07e86296157436feefa3c2052af721
Co-Authored-By: AI

* fix(mail): i18n large attachment HTML text aligned with desktop client

Parameterize title and download text in large attachment HTML templates.
Chinese lang uses "来自Lark邮箱的超大附件"/"下载", others use
"Large file from Lark Mail"/"Download", matching desktop's i18n keys
Mail_Attachment_AttachmentFromFeishuMail and Mail_Attachment_Download.

Change-Id: I2aada8d52af41ae77dd7001d24d14e333f12066e
Co-Authored-By: AI

* fix(mail): insert large attachment card before quote wrapper, not inside nested quote

insertBeforeQuoteOrAppend matched id="lark-mail-quote" which can appear
deeply nested inside quoted content from previous replies in a thread.
This caused the card to be placed inside the quote area instead of before
it. Switch to matching the "history-quote-wrapper" class which is the
outermost quote container generated by the CLI.

Change-Id: I720b6d62d719613b411b7ed4b7820a1535bf14bd
Co-Authored-By: AI

* feat(mail): unify large attachment handling in +draft-edit with normal attachments

Extend +draft-edit so that large attachments behave like normal attachments
from the user's perspective: survive body edits, are listed in inspect
output, and are removed via the same remove_attachment op.

Code-wise:
- remove_attachment target now accepts token (for large attachments) in
  addition to part_id / cid; priority part_id > cid > token.
- setBody / setReplyBody auto-preserve the large attachment card in the
  HTML body, mirroring how normal attachments (MIME parts) survive body
  edits. Detection checks only the user-authored region of the value so
  cards inside an appended quote block (from the original quoted message)
  are not mistaken for user-supplied cards.
- --inspect returns large_attachments_summary (token, filename, size) by
  parsing the X-Lms-Large-Attachment-Ids header and the HTML card DOM.
- Well-known Lark HTML/header constants (LargeAttachmentIDsHeader,
  LargeFileContainerIDPrefix, LargeFileItemID, LargeAttachmentTokenAttr)
  moved to the draft package alongside QuoteWrapperClass; the mail package
  consumes them.
- Shared helpers FindHTMLBodyPart and InsertBeforeQuoteOrAppend exported
  from the draft package; mail package switched to consume them, removing
  local duplicates.

Skill reference (lark-mail-draft-edit.md) updated: three locator fields by
attachment type, unified remove_attachment examples, set_body behavior.

Change-Id: Ic064d1a8df0edf1cef6069cd44ec2a7534cd2182
Co-Authored-By: AI

* fix(mail): place signature before large attachment card consistently

When inserting a signature into a draft that already has a large
attachment card, the signature was placed after the card, diverging from
the compose-time layout where the order is [user][sig][card][quote].

Root cause: insertSignatureOp split only at the quote block, so the
"user region" side inadvertently included the card.

Centralize signature placement in draft.PlaceSignatureBeforeSystemTail,
which splits at the earliest system-managed element (card or quote,
whichever comes first). Both edit-time insertSignatureOp and compose-time
injectSignatureIntoBody now share this single source of truth, removing
the duplicated HTML splicing logic.

Change-Id: I234bfebaaa31a32731ebbaa78c6596a72618b7c5
Co-Authored-By: AI

* fix(mail): auto-preserve signature in set_body and set_reply_body

Previously set_body / set_reply_body replaced the entire HTML body,
silently dropping the signature block. The "replace whole body" semantic
treated signature as user-authored content, which is inconsistent with
how attachments (normal + large) and quote blocks survive body edits —
signature is a system-managed element managed via insert_signature /
remove_signature ops.

Unify the mental model: body-edit ops replace user-authored content
only; signature, large attachment card, normal attachments, and (for
set_reply_body) quote block are all auto-preserved. Users can override
by including equivalents in value, or explicitly delete via dedicated
ops (remove_signature, remove_attachment).

- Add ExtractSignatureBlock helper (symmetric to RemoveSignatureHTML).
- Rename autoPreserveLargeAttachmentCard to
  autoPreserveSystemManagedRegions; extract and inject both sig and card
  from old body, respecting user-supplied equivalents in value's
  user-authored region.
- Update skill doc and patch template notes to reflect the new
  semantics consistently.

Change-Id: I96660d2ff06a6c9cdf1b86793c2d89cf9cb09ffe
Co-Authored-By: AI

* fix(mail): use brand-aware display name in large attachment card title

The title "Large file from Lark Mail" / "来自Lark邮箱的超大附件" hard-coded
"Lark" regardless of brand. The desktop client switches between
"Feishu"/"飞书" and "Lark" based on the APP_DISPLAY_NAME i18n
substitution.

Add brandDisplayName(brand, lang) helper:
  - BrandLark    → "Lark"
  - BrandFeishu  → "飞书" (zh) / "Feishu" (en)

Applied to title in buildLargeAttachmentHTML, aligning with the icon CDN
and download URL, which already branch on brand.

Change-Id: I06258b9982b6280a2230193d90a6a88884e10aa3
Co-Authored-By: AI

* style(mail): apply gofmt

CI fast-gate check flagged gofmt-unformatted files. Run gofmt -w on
touched mail files only.

Change-Id: Iec690dc63adfaa54b8f7c85ab5b3ca035476ddbd

* fix(mail): address review feedback on large attachment PR

- Strip <html><head><body> wrapper from xhtml.Render output in
  removeLargeFileItemFromHTML to avoid polluting the HTML body
- Reject plain-text messages with oversized attachments instead of
  silently losing the body content
- Fix attachment count limit in skill doc (100 → 250)
- Remove unused fio/attachFlag params from validateComposeInlineAndAttachments
- Add token escaping test for large attachment HTML builder

Change-Id: Ie589a1f1d204b0aeebc4486b16bb435041793ceb
Co-Authored-By: AI

* fix(mail): recognize server-format X-Lark-Large-Attachment header in draft-edit

When a draft with large attachments is created by the desktop client,
the server returns X-Lark-Large-Attachment (with file_key/file_name/
file_size fields) instead of the CLI-written X-Lms-Large-Attachment-Ids.
Previously CLI only recognized its own header, causing existing large
attachments to be silently dropped when the draft was edited.

- Parse both header formats via IsLargeAttachmentHeader and unified
  largeAttHeaderEntry struct
- Convert server-format entries to CLI-format on save so the server
  can process the update
- Fix inline attachment classification: require non-empty CID to
  classify as inline image (large attachments may have is_inline=true
  but no CID)

Change-Id: Ie7def4fc5923d2cf3446eedfbca4fd8cae44bfac
Co-Authored-By: AI

* fix(mail): skip large attachments in forward URL validation

Large attachments do not have download URLs since they are referenced
by token, not embedded in the EML. Validate only normal attachments
to avoid false "missing download URL" errors when forwarding messages
that contain expired or token-based large attachments.

Change-Id: Ibe3f45390cd3b3cbe6ddd15961dcda4f17aefe4f
Co-Authored-By: AI

* fix(mail): classify forwarded original attachments for large attachment upload

Previously, all original attachments were unconditionally embedded in
the EML before user attachments were processed for large attachment
upload. When original + user attachments together exceeded the 25 MB
EML limit, the build would fail.

Now all attachments (original + user-added) are classified together
via classifyAttachments. Original attachments that push the EML over
the limit are re-uploaded as large attachments with download cards,
matching the compose/reply flow behavior.

Also refactors uploadLargeAttachmentBytes to reuse the shared
common.UploadDriveMediaAll utility (via new Reader field on the
config struct) instead of duplicating the upload logic, and replaces
bare fmt.Errorf with output.ErrValidation for user input errors.

Change-Id: I98d4ad8960cd68e38765b05c94f7786d6a8444c8
Co-Authored-By: AI

* fix(mail): normalize large attachment header on draft edit to prevent loss

Server returns X-Lark-Large-Attachment header on draft readback, but only
recognizes X-Lms-Large-Attachment-Ids on write. Without normalization,
editing a draft with existing large attachments (e.g. adding a small
attachment) would send back the server-format header unchanged, causing
the server to drop the large attachment association.

Add normalizeLargeAttachmentHeader() at the entry of
preprocessLargeAttachmentsForDraftEdit to convert server-format headers
to CLI format before any processing or early return.

Change-Id: Id99a46f29015a32921bfb72a003f766c397787e1
Co-Authored-By: AI

* fix(mail): extract large attachment card from quote on forward

When forwarding a message that contains large attachments, the original
message's download card (large-file-area div) was left inside the
forward quote block. Extract it and place it in the main body area
(after signature, before quote), matching the desktop client behavior.

Change-Id: Iebede35cdf4ed0f65b72bce28ffb18af21ddf668
Co-Authored-By: AI

* fix(mail): use octet-stream for re-embedded attachments and file-based large upload on forward

- Use application/octet-stream instead of original content type when
  re-embedding downloaded attachments in forward EML. Prevents the mail
  server from treating image/* attachments as inline parts.
- Replace in-memory uploadLargeAttachmentBytes with temp-file-based
  uploadLargeAttachments for oversized original attachments. This
  enables multipart upload for files >20MB which the single-part API
  does not support.

Change-Id: Ib02add5710e8b052e47b513ed3d9a688e0f98212
Co-Authored-By: AI

* fix(mail): address PR review — blocked extension bypass, index-based op filtering, plain-text draft guard

1. Move CheckBlockedExtension into statAttachmentFiles so oversized
   attachments are validated before classification, covering compose,
   draft-edit, and forward paths.

2. Replace path-based oversized op filtering with SourceIndex-based
   filtering in preprocessLargeAttachmentsForDraftEdit to avoid
   incorrectly removing duplicate-path normal ops.

3. Add HTML body preflight in preprocessLargeAttachmentsForDraftEdit
   before uploading, so plain-text-only drafts fail early instead of
   silently producing a draft with tokens but no download card.

Change-Id: Ib8771812f50a18f00a40e50149b028b8aaa101fe
Co-Authored-By: AI

* fix(mail): preserve original content type for normal forwarded attachments

The octet-stream override was only needed for the large attachment
upload path (to prevent image/* from being treated as inline by the
drive API). Normal attachments embedded in the EML should retain their
original MIME type so recipients can preview/open them correctly.

Change-Id: Ie40b7c362524a3b82255b58e9bcfd770eacfe911
Co-Authored-By: AI

* fix(mail): reconstruct missing large attachment HTML cards on draft edit

The server strips HTML download cards from the EML body when storing
drafts, so every draft read-back (regardless of creator) lacks them.
Add ensureLargeAttachmentCards which runs before header normalization,
compares server-format header tokens against existing HTML cards via
data-mail-token, and rebuilds only the missing ones. This ensures
external recipients see download links after draft-edit → send.

Also exports ParseLargeAttachmentSummariesFromHeader and
ParseLargeAttachmentItemsFromHTML from the draft package for
cross-package use.

Change-Id: I9cb0f47a9f4582909de24984d9a9f6e366521e62
Co-Authored-By: AI

* feat(mail): support large attachments in plain-text emails

Previously large attachments required an HTML body for the download card.
Now plain-text emails (--plain-text or text/plain-only drafts) get download
info appended as structured text (title + filename + size + URL), with
i18n and brand awareness matching the HTML card.

Changes:
- Add buildLargeAttachmentPlainText and injectLargeAttachmentTextIntoSnapshot
- Add FindTextBodyPart in draft/projection.go
- Update processLargeAttachments to accept textBody parameter
- Update ensureLargeAttachmentCards to handle text/plain body reconstruction
- Update preprocessLargeAttachmentsForDraftEdit to allow text/plain drafts
- Update all callers (send, draft-create, reply, reply-all, forward)

Change-Id: I3b375e2ff34697eeb73a3768ace6d577d1bead3e
Co-Authored-By: AI

* fix(mail): FindBodyPart skips attachment-disposition parts; update skill docs

FindHTMLBodyPart and FindTextBodyPart now skip parts with
Content-Disposition: attachment, preventing .txt/.html file attachments
from being mistakenly treated as the email body.

Also update all lark-mail skill reference docs to reflect that large
attachments now work in both HTML (download card) and plain-text
(download link text) modes.

Change-Id: I1e6da4fd614217dff61304212304b5fd80c8246c
Co-Authored-By: AI

* fix(mail): fix origIdx mismatch, predictable temp files, and attachment count on forward

- Use SourceIndex instead of linear origIdx counter so classifyAttachments
  reordering does not cause content mismatch between normal/oversized loops
- Use os.CreateTemp for temp files instead of predictable names in CWD
- Include original large attachment count in totalCount limit check

Change-Id: Ide5dce14b1efc672687800d77c3853f15dfc191b
Co-Authored-By: AI

* fix(mail): use composed body size and source inline bytes in EML size estimation

estimateEMLBaseSize was using len(body) (raw --body flag) instead of the
actual composed body (which includes quotes, signatures, forward headers).
Source inline images downloaded from the original message were also not
counted. This could cause borderline attachments to be misclassified.

- Use len(composedHTMLBody) + len(composedTextBody) for body size
- Return total downloaded bytes from addInlineImagesToBuilder and pass
  as extraBytes to estimateEMLBaseSize
- Fix applied to all compose shortcuts: send, draft-create, reply,
  reply-all, forward

Change-Id: Ibe6c44e22d40ac51f0a4652d279e66bd92330723
Co-Authored-By: AI

* fix(mail): merge large attachment items into single container on draft edit

When draft-edit had both set_body and add_attachment (oversized), the
ensureLargeAttachmentCards and preprocessLargeAttachmentsForDraftEdit
each created independent large-file-area containers. The subsequent
set_body's autoPreserveSystemManagedRegions only captured the first
container via SplitAtLargeAttachment, discarding the second one.

Fix: injectLargeAttachmentHTMLIntoSnapshot now detects an existing
large-file-area container and appends new items inside it instead of
creating a new container, matching the desktop client's single-container
behavior.

Change-Id: I3d701683053842f1d7bdad34fc4b2ef26ede784e
Co-Authored-By: AI

* fix(mail): strip large attachment card from reply/reply-all quote

Reply and reply-all should not carry over the original email's large
attachment HTML card into the quoted block. Extract the shared
stripLargeAttachmentCard helper (also used by forward) that removes
the card from orig.bodyRaw before quote construction.

- Reply/reply-all: card is discarded (not re-inserted)
- Forward: card is moved to body area before the quote (unchanged)

Change-Id: I5399bb901c120206c7c045bed107f7d68be23bb1
Co-Authored-By: AI

* fix(mail): skip invalid attachments on forward instead of blocking

When forwarding a message with deleted/expired attachments, the forward
flow now automatically removes them instead of either blocking (normal
attachments) or silently including dead references (large attachments).

- Propagate failed_ids from fetchAttachmentURLs into composeSourceMessage
- Skip failed attachments in the forward download loop with a warning
- Remove corresponding large attachment HTML card items from the body
- Extend itemContainsToken to match server-generated href?token= format

Change-Id: I9c0096dcbe96f1d61caa0f6f0b2f8b738fdfa66b
Co-Authored-By: AI

* fix(mail): restore dry-run file preflight and reserve card overhead in classifier

1. Restore file existence and blocked-extension checks in
   validateComposeInlineAndAttachments so --dry-run surfaces local
   path errors before Execute.
2. Reserve 3KB per oversized file in classifyAttachments to account
   for the HTML card / plain-text block injected after classification.

Change-Id: Ib48a75f86a50298413c1f9ab8226e583c0161a8c
Co-Authored-By: AI

* fix(mail): revert classifier overhead reserve for simplicity

The 3KB-per-oversized-file reserve in classifyAttachments addressed
a boundary case that is practically impossible to trigger (requires
Normal attachments to fill to within a few KB of 25MB). Remove it
to keep the classifier simple.

Change-Id: I5148f14ecca1a0dee677a1a2c60ec4efab160ea8
Co-Authored-By: AI

* style(mail): fix gofmt indentation in draft create tests

Change-Id: Ib41aa22f94144f2d47b12675d444aa43cb333a88
Co-Authored-By: AI

* fix(mail): remove temp files in forward, use in-memory upload instead

Replace os.CreateTemp/os.WriteFile/os.Remove with in-memory Data field
on attachmentFile, conforming to the project's forbidigo rule against
temp files in shortcuts. Also remove dead uploadLargeAttachmentBytes.

Change-Id: Ic26e4025eebfa1bac3948438ef185ff3e2f15abb
Co-Authored-By: AI

* test(mail): add tests for validateComposeInlineAndAttachments and fileTypeIcon

Covers all branches: inline+plain-text conflict, inline+non-HTML body,
missing file, blocked extension, valid pass-through, and all file type
icon mappings.

Change-Id: I8b81c1b34010a9ecb7153462a5524e3d7b171de2
Co-Authored-By: AI

* test(mail): improve coverage for large attachment and draft edit functions

Add tests for snapshotEMLBaseSize, flattenSnapshotParts, estimateEMLBaseSize,
normalizeLargeAttachmentHeader, processLargeAttachments error paths,
preprocessLargeAttachmentsForDraftEdit early-return paths, inject edge cases,
buildLargeAttachmentItems, statAttachmentFiles edge cases, and
prettyDraftAddresses.

Change-Id: Ie661e6ebea63512864d97e20135dd89cb9e9304e
Co-Authored-By: AI
2026-04-21 20:56:37 +08:00

860 lines
28 KiB
Go

// 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 = `<div id="large-file-area-%s" style="border: 1px solid #DEE0E3; margin-bottom: 20px;max-width: 400px; min-width: 160px; border-radius: 8px;">` +
`<div style="font-weight: 500; font-size: 16px;line-height: 24px; padding: 8px 16px;background-color: rgb(224, 233, 255); border-top-left-radius: 8px;border-top-right-radius: 8px;">%s</div>` +
`%s` + // items
`</div>`
// %s order: icon URL, filename, file size, preview link, token, download text
largeAttItemTpl = `<div style="border-top: solid 1px #DEE0E3;padding: 12px;box-sizing: border-box;clear: both;overflow: hidden;display: flex;" id="large-file-item">` +
`<div style="float: left; margin-right: 8px; margin-top: 1px; margin-bottom: 1px;">` +
`<img src="%s" height="40" width="40" style="height: 40px;width: 40px;"/>` + // icon URL
`</div>` +
`<div style="overflow: hidden;text-overflow: ellipsis;display: inline-block;width: 290px;float:left; margin-right: 10px;">` +
`<div style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;font-size: 14px;line-height: 22px;color: #1f2329">%s</div>` + // filename
`<div style="font-size: 12px; line-height: 20px; color: #8f959e; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">` +
`<span style="color: #8f959e;vertical-align: middle;">%s</span>` + // file size
`</div>` +
`</div>` +
`<a href="%s" data-mail-token="%s" style="margin: 10px; text-decoration: none; color: rgb(20, 86, 240); white-space: nowrap; cursor: pointer; line-height: 1.5; float: right; text-align: right; font-size: 14px;">%s</a>` + // preview link, token, download text
`</div>`
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("</div>")] + itemsHTML + "</div>"
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),
})
}