mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +08:00
* 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
1259 lines
42 KiB
Go
1259 lines
42 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/vfs/localfileio"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
|
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
|
|
)
|
|
|
|
func TestEstimateBase64EMLSize(t *testing.T) {
|
|
// 3 bytes raw → 4 bytes base64 + ~200 overhead
|
|
got := estimateBase64EMLSize(3)
|
|
if got != 4+base64MIMEOverhead {
|
|
t.Errorf("estimateBase64EMLSize(3) = %d, want %d", got, 4+base64MIMEOverhead)
|
|
}
|
|
|
|
// 0 bytes raw → just overhead
|
|
got = estimateBase64EMLSize(0)
|
|
if got != base64MIMEOverhead {
|
|
t.Errorf("estimateBase64EMLSize(0) = %d, want %d", got, base64MIMEOverhead)
|
|
}
|
|
}
|
|
|
|
func TestClassifyAttachments_AllFit(t *testing.T) {
|
|
files := []attachmentFile{
|
|
{Path: "a.txt", FileName: "a.txt", Size: 1024},
|
|
{Path: "b.txt", FileName: "b.txt", Size: 2048},
|
|
}
|
|
result := classifyAttachments(files, 0)
|
|
if len(result.Normal) != 2 {
|
|
t.Fatalf("expected 2 normal, got %d", len(result.Normal))
|
|
}
|
|
if len(result.Oversized) != 0 {
|
|
t.Fatalf("expected 0 oversized, got %d", len(result.Oversized))
|
|
}
|
|
}
|
|
|
|
func TestClassifyAttachments_Overflow(t *testing.T) {
|
|
// emlBaseSize = 24MB, first file 500KB fits, second 2MB overflows
|
|
emlBase := int64(24 * 1024 * 1024)
|
|
files := []attachmentFile{
|
|
{Path: "small.txt", FileName: "small.txt", Size: 500 * 1024}, // ~667KB base64, fits
|
|
{Path: "medium.txt", FileName: "medium.txt", Size: 2 * 1024 * 1024}, // ~2.67MB base64, overflows
|
|
}
|
|
result := classifyAttachments(files, emlBase)
|
|
if len(result.Normal) != 1 || result.Normal[0].FileName != "small.txt" {
|
|
t.Fatalf("expected 1 normal (small.txt), got %d: %+v", len(result.Normal), result.Normal)
|
|
}
|
|
if len(result.Oversized) != 1 || result.Oversized[0].FileName != "medium.txt" {
|
|
t.Fatalf("expected 1 oversized (medium.txt), got %d: %+v", len(result.Oversized), result.Oversized)
|
|
}
|
|
}
|
|
|
|
func TestClassifyAttachments_SubsequentAlsoOversized(t *testing.T) {
|
|
// Once overflow triggers, all subsequent files are oversized even if they'd individually fit.
|
|
emlBase := int64(24 * 1024 * 1024)
|
|
files := []attachmentFile{
|
|
{Path: "big.bin", FileName: "big.bin", Size: 2 * 1024 * 1024}, // overflows
|
|
{Path: "tiny.txt", FileName: "tiny.txt", Size: 100}, // would fit alone, but comes after overflow
|
|
}
|
|
result := classifyAttachments(files, emlBase)
|
|
if len(result.Normal) != 0 {
|
|
t.Fatalf("expected 0 normal, got %d", len(result.Normal))
|
|
}
|
|
if len(result.Oversized) != 2 {
|
|
t.Fatalf("expected 2 oversized, got %d", len(result.Oversized))
|
|
}
|
|
}
|
|
|
|
func TestClassifyAttachments_PreservesOrder(t *testing.T) {
|
|
files := []attachmentFile{
|
|
{Path: "c.txt", FileName: "c.txt", Size: 100},
|
|
{Path: "a.txt", FileName: "a.txt", Size: 200},
|
|
{Path: "b.txt", FileName: "b.txt", Size: 50},
|
|
}
|
|
result := classifyAttachments(files, 0)
|
|
if len(result.Normal) != 3 {
|
|
t.Fatalf("expected 3 normal, got %d", len(result.Normal))
|
|
}
|
|
// Order must match input
|
|
if result.Normal[0].FileName != "c.txt" || result.Normal[1].FileName != "a.txt" || result.Normal[2].FileName != "b.txt" {
|
|
t.Fatalf("order not preserved: %v", result.Normal)
|
|
}
|
|
}
|
|
|
|
func TestMaxLargeAttachmentSize(t *testing.T) {
|
|
// 3GB constant should match desktop client
|
|
expected := int64(3 * 1024 * 1024 * 1024)
|
|
if MaxLargeAttachmentSize != expected {
|
|
t.Errorf("MaxLargeAttachmentSize = %d, want %d (3 GB)", MaxLargeAttachmentSize, expected)
|
|
}
|
|
}
|
|
|
|
func TestBuildLargeAttachmentPreviewURL(t *testing.T) {
|
|
tests := []struct {
|
|
brand core.LarkBrand
|
|
token string
|
|
want string
|
|
}{
|
|
{core.BrandFeishu, "abc123", "https://www.feishu.cn/mail/page/attachment?token=abc123"},
|
|
{core.BrandLark, "xyz789", "https://www.larksuite.com/mail/page/attachment?token=xyz789"},
|
|
}
|
|
for _, tt := range tests {
|
|
got := buildLargeAttachmentPreviewURL(tt.brand, tt.token)
|
|
if got != tt.want {
|
|
t.Errorf("buildLargeAttachmentPreviewURL(%s, %s) = %q, want %q", tt.brand, tt.token, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildLargeAttachmentHTML(t *testing.T) {
|
|
results := []largeAttachmentResult{
|
|
{FileName: "report.pdf", FileSize: 50 * 1024 * 1024, FileToken: "tok_abc"},
|
|
{FileName: "data.zip", FileSize: 100 * 1024 * 1024, FileToken: "tok_xyz"},
|
|
}
|
|
html := buildLargeAttachmentHTML(core.BrandFeishu, "en_us", results)
|
|
|
|
// Check it contains the container ID prefix
|
|
if !strings.Contains(html, "large-file-area-") {
|
|
t.Error("missing container ID")
|
|
}
|
|
// Check file names are present
|
|
if !strings.Contains(html, "report.pdf") {
|
|
t.Error("missing filename report.pdf")
|
|
}
|
|
if !strings.Contains(html, "data.zip") {
|
|
t.Error("missing filename data.zip")
|
|
}
|
|
// Check tokens are embedded as data attributes
|
|
if !strings.Contains(html, `data-mail-token="tok_abc"`) {
|
|
t.Error("missing data-mail-token for tok_abc")
|
|
}
|
|
// Check download links
|
|
if !strings.Contains(html, "www.feishu.cn/mail/page/attachment?token=tok_abc") {
|
|
t.Error("missing download link for tok_abc")
|
|
}
|
|
if !strings.Contains(html, ">Download<") {
|
|
t.Error("missing English download text")
|
|
}
|
|
}
|
|
|
|
func TestBuildLargeAttachmentHTML_BrandAwareTitle(t *testing.T) {
|
|
results := []largeAttachmentResult{{FileName: "a.pdf", FileSize: 1024, FileToken: "tok"}}
|
|
|
|
cases := []struct {
|
|
brand core.LarkBrand
|
|
lang string
|
|
wantTitle string
|
|
}{
|
|
{core.BrandFeishu, "zh_cn", "来自飞书邮箱的超大附件"},
|
|
{core.BrandFeishu, "en_us", "Large file from Feishu Mail"},
|
|
{core.BrandLark, "zh_cn", "来自Lark邮箱的超大附件"},
|
|
{core.BrandLark, "en_us", "Large file from Lark Mail"},
|
|
}
|
|
for _, tc := range cases {
|
|
html := buildLargeAttachmentHTML(tc.brand, tc.lang, results)
|
|
if !strings.Contains(html, tc.wantTitle) {
|
|
t.Errorf("brand=%s lang=%s: missing title %q\nhtml: %s", tc.brand, tc.lang, tc.wantTitle, html)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBrandDisplayName(t *testing.T) {
|
|
cases := []struct {
|
|
brand core.LarkBrand
|
|
lang string
|
|
want string
|
|
}{
|
|
{core.BrandFeishu, "zh_cn", "飞书"},
|
|
{core.BrandFeishu, "en_us", "Feishu"},
|
|
{core.BrandFeishu, "", "Feishu"},
|
|
{core.BrandLark, "zh_cn", "Lark"},
|
|
{core.BrandLark, "en_us", "Lark"},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := brandDisplayName(tc.brand, tc.lang); got != tc.want {
|
|
t.Errorf("brandDisplayName(%s, %q) = %q, want %q", tc.brand, tc.lang, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuildLargeAttachmentHTML_Empty(t *testing.T) {
|
|
html := buildLargeAttachmentHTML(core.BrandFeishu, "en_us", nil)
|
|
if html != "" {
|
|
t.Errorf("expected empty string for nil results, got %q", html)
|
|
}
|
|
}
|
|
|
|
func TestBuildLargeAttachmentHTML_EscapesSpecialChars(t *testing.T) {
|
|
results := []largeAttachmentResult{
|
|
{FileName: `file<script>alert("xss")</script>.txt`, FileSize: 100, FileToken: `tok"inject`},
|
|
}
|
|
html := buildLargeAttachmentHTML(core.BrandFeishu, "en_us", results)
|
|
if strings.Contains(html, "<script>") {
|
|
t.Error("HTML injection: <script> not escaped")
|
|
}
|
|
if !strings.Contains(html, "<script>") {
|
|
t.Error("expected escaped <script> tag")
|
|
}
|
|
if strings.Contains(html, `data-mail-token="tok"inject"`) {
|
|
t.Error("token attribute injection: quote not escaped")
|
|
}
|
|
if !strings.Contains(html, `data-mail-token="tok"inject"`) {
|
|
t.Error("expected escaped quote in token attribute")
|
|
}
|
|
}
|
|
|
|
func TestInsertBeforeQuoteOrAppend_WithQuote(t *testing.T) {
|
|
body := `<p>Hello</p><div id="lark-mail-quote-cli123" class="history-quote-wrapper"><div>quoted content</div></div>`
|
|
block := `<div id="lark-mail-large-file-container">CARD</div>`
|
|
result := draftpkg.InsertBeforeQuoteOrAppend(body, block)
|
|
|
|
// Block should appear before the quote
|
|
cardIdx := strings.Index(result, "CARD")
|
|
quoteIdx := strings.Index(result, "lark-mail-quote-cli123")
|
|
if cardIdx < 0 || quoteIdx < 0 {
|
|
t.Fatalf("missing card or quote in result: %s", result)
|
|
}
|
|
if cardIdx > quoteIdx {
|
|
t.Errorf("card should be before quote, but card@%d > quote@%d", cardIdx, quoteIdx)
|
|
}
|
|
// Original body text should still be before the card
|
|
helloIdx := strings.Index(result, "Hello")
|
|
if helloIdx > cardIdx {
|
|
t.Errorf("body text should be before card, but hello@%d > card@%d", helloIdx, cardIdx)
|
|
}
|
|
}
|
|
|
|
func TestInsertBeforeQuoteOrAppend_NestedQuoteIDs(t *testing.T) {
|
|
// Simulate a reply to a multi-reply thread: the outermost wrapper has
|
|
// class="history-quote-wrapper" but the inner quoted content contains
|
|
// deeper lark-mail-quote IDs from the original thread.
|
|
body := `<p>My reply</p>` +
|
|
`<div class="history-quote-wrapper"><div data-html-block="quote">` +
|
|
`<div><div><div id="lark-mail-quote-aaa">` +
|
|
`previous reply` +
|
|
`<div id="lark-mail-quote-bbb">original message</div>` +
|
|
`</div></div></div></div></div>`
|
|
block := `<div id="large-file-area-123">CARD</div>`
|
|
result := draftpkg.InsertBeforeQuoteOrAppend(body, block)
|
|
|
|
cardIdx := strings.Index(result, "CARD")
|
|
wrapperIdx := strings.Index(result, "history-quote-wrapper")
|
|
replyIdx := strings.Index(result, "My reply")
|
|
if cardIdx < 0 || wrapperIdx < 0 {
|
|
t.Fatalf("missing card or wrapper in result: %s", result)
|
|
}
|
|
// Card should be BEFORE the wrapper, not inside it
|
|
if cardIdx > wrapperIdx {
|
|
t.Errorf("card should be before quote wrapper, but card@%d > wrapper@%d", cardIdx, wrapperIdx)
|
|
}
|
|
// Body text should be before the card
|
|
if replyIdx > cardIdx {
|
|
t.Errorf("body text should be before card, but reply@%d > card@%d", replyIdx, cardIdx)
|
|
}
|
|
}
|
|
|
|
func TestInsertBeforeQuoteOrAppend_NoQuote(t *testing.T) {
|
|
body := `<p>Hello world</p>`
|
|
block := `<div>CARD</div>`
|
|
result := draftpkg.InsertBeforeQuoteOrAppend(body, block)
|
|
if !strings.HasSuffix(result, block) {
|
|
t.Errorf("without quote, block should be appended to end, got: %s", result)
|
|
}
|
|
}
|
|
|
|
func TestInsertBeforeQuoteOrAppend_EmptyBody(t *testing.T) {
|
|
result := draftpkg.InsertBeforeQuoteOrAppend("", "<div>CARD</div>")
|
|
if result != "<div>CARD</div>" {
|
|
t.Errorf("empty body should just return block, got: %s", result)
|
|
}
|
|
}
|
|
|
|
// encodeServerHeader builds a base64-encoded X-Lark-Large-Attachment value.
|
|
func encodeServerHeader(entries []map[string]interface{}) string {
|
|
b, _ := json.Marshal(entries)
|
|
return base64.StdEncoding.EncodeToString(b)
|
|
}
|
|
|
|
func TestEnsureLargeAttachmentCards_InjectsMissingCards(t *testing.T) {
|
|
headerVal := encodeServerHeader([]map[string]interface{}{
|
|
{"file_key": "tok_aaa", "file_name": "report.pdf", "file_size": 50 * 1024 * 1024},
|
|
{"file_key": "tok_bbb", "file_name": "data.zip", "file_size": 100 * 1024 * 1024},
|
|
})
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: headerVal},
|
|
},
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/html",
|
|
Body: []byte("<p>Hello</p>"),
|
|
},
|
|
}
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, &core.CliConfig{Brand: core.BrandFeishu})
|
|
ensureLargeAttachmentCards(rt, snapshot)
|
|
|
|
html := string(snapshot.Body.Body)
|
|
if !strings.Contains(html, "report.pdf") {
|
|
t.Error("missing card for report.pdf")
|
|
}
|
|
if !strings.Contains(html, "data.zip") {
|
|
t.Error("missing card for data.zip")
|
|
}
|
|
if !strings.Contains(html, `data-mail-token="tok_aaa"`) {
|
|
t.Error("missing data-mail-token for tok_aaa")
|
|
}
|
|
if !strings.Contains(html, `data-mail-token="tok_bbb"`) {
|
|
t.Error("missing data-mail-token for tok_bbb")
|
|
}
|
|
// Original body should still be present.
|
|
if !strings.Contains(html, "<p>Hello</p>") {
|
|
t.Error("original body content lost")
|
|
}
|
|
}
|
|
|
|
func TestEnsureLargeAttachmentCards_NoDuplicateWhenCardExists(t *testing.T) {
|
|
headerVal := encodeServerHeader([]map[string]interface{}{
|
|
{"file_key": "tok_aaa", "file_name": "report.pdf", "file_size": 50 * 1024 * 1024},
|
|
})
|
|
existingCard := `<div id="large-file-area-123456789" style="border:1px solid #DEE0E3;">` +
|
|
`<div>Title</div>` +
|
|
`<div style="border-top:solid 1px #DEE0E3;" id="large-file-item">` +
|
|
`<div><div>report.pdf</div><div><span>50.0 MB</span></div></div>` +
|
|
`<a href="https://example.com" data-mail-token="tok_aaa">Download</a>` +
|
|
`</div></div>`
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: headerVal},
|
|
},
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/html",
|
|
Body: []byte("<p>Hello</p>" + existingCard),
|
|
},
|
|
}
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, &core.CliConfig{Brand: core.BrandFeishu})
|
|
originalHTML := string(snapshot.Body.Body)
|
|
ensureLargeAttachmentCards(rt, snapshot)
|
|
|
|
// HTML should remain unchanged — no duplicate card injected.
|
|
if string(snapshot.Body.Body) != originalHTML {
|
|
t.Errorf("HTML was modified when card already existed.\nbefore: %s\nafter: %s", originalHTML, string(snapshot.Body.Body))
|
|
}
|
|
}
|
|
|
|
func TestEnsureLargeAttachmentCards_PartialMissing(t *testing.T) {
|
|
headerVal := encodeServerHeader([]map[string]interface{}{
|
|
{"file_key": "tok_aaa", "file_name": "report.pdf", "file_size": 50 * 1024 * 1024},
|
|
{"file_key": "tok_bbb", "file_name": "data.zip", "file_size": 100 * 1024 * 1024},
|
|
})
|
|
existingCard := `<div id="large-file-area-123456789">` +
|
|
`<div>Title</div>` +
|
|
`<div id="large-file-item">` +
|
|
`<a data-mail-token="tok_aaa">Download</a>` +
|
|
`</div></div>`
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: headerVal},
|
|
},
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/html",
|
|
Body: []byte("<p>Hello</p>" + existingCard),
|
|
},
|
|
}
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, &core.CliConfig{Brand: core.BrandFeishu})
|
|
ensureLargeAttachmentCards(rt, snapshot)
|
|
|
|
html := string(snapshot.Body.Body)
|
|
// tok_bbb should be injected.
|
|
if !strings.Contains(html, `data-mail-token="tok_bbb"`) {
|
|
t.Error("missing card for tok_bbb")
|
|
}
|
|
if !strings.Contains(html, "data.zip") {
|
|
t.Error("missing filename data.zip in card")
|
|
}
|
|
// tok_aaa's existing card should remain (present exactly once).
|
|
count := strings.Count(html, `data-mail-token="tok_aaa"`)
|
|
if count != 1 {
|
|
t.Errorf("tok_aaa card count: got %d, want 1", count)
|
|
}
|
|
}
|
|
|
|
func TestEnsureLargeAttachmentCards_NoServerHeader(t *testing.T) {
|
|
// Only CLI-format header — no server-format metadata to reconstruct from.
|
|
cliVal := base64.StdEncoding.EncodeToString([]byte(`[{"id":"tok_aaa"}]`))
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.LargeAttachmentIDsHeader, Value: cliVal},
|
|
},
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/html",
|
|
Body: []byte("<p>Hello</p>"),
|
|
},
|
|
}
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
originalHTML := string(snapshot.Body.Body)
|
|
ensureLargeAttachmentCards(rt, snapshot)
|
|
|
|
if string(snapshot.Body.Body) != originalHTML {
|
|
t.Error("HTML should not be modified when only CLI-format header is present")
|
|
}
|
|
}
|
|
|
|
func TestEnsureLargeAttachmentCards_PlainTextBodyInjectsDownloadInfo(t *testing.T) {
|
|
headerVal := encodeServerHeader([]map[string]interface{}{
|
|
{"file_key": "tok_aaa", "file_name": "report.pdf", "file_size": 1024},
|
|
})
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: headerVal},
|
|
},
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/plain",
|
|
Body: []byte("plain text body"),
|
|
},
|
|
}
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, &core.CliConfig{Brand: core.BrandFeishu})
|
|
ensureLargeAttachmentCards(rt, snapshot)
|
|
|
|
body := string(snapshot.Body.Body)
|
|
if !strings.Contains(body, "plain text body") {
|
|
t.Error("original text should be preserved")
|
|
}
|
|
if !strings.Contains(body, "report.pdf") {
|
|
t.Error("plain text should contain filename")
|
|
}
|
|
if !strings.Contains(body, "tok_aaa") {
|
|
t.Error("plain text should contain download link with token")
|
|
}
|
|
if draftpkg.FindHTMLBodyPart(snapshot.Body) != nil {
|
|
t.Error("should not create an HTML part when text/plain body already exists")
|
|
}
|
|
}
|
|
|
|
func TestEnsureLargeAttachmentCards_PlainTextNoDuplicate(t *testing.T) {
|
|
headerVal := encodeServerHeader([]map[string]interface{}{
|
|
{"file_key": "tok_aaa", "file_name": "report.pdf", "file_size": 1024},
|
|
})
|
|
bodyWithToken := "plain text body\nDownload: https://www.feishu.cn/mail/page/attachment?token=tok_aaa\n"
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: headerVal},
|
|
},
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/plain",
|
|
Body: []byte(bodyWithToken),
|
|
},
|
|
}
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, &core.CliConfig{Brand: core.BrandFeishu})
|
|
ensureLargeAttachmentCards(rt, snapshot)
|
|
|
|
if string(snapshot.Body.Body) != bodyWithToken {
|
|
t.Error("body should not be modified when token already present")
|
|
}
|
|
}
|
|
|
|
func TestBuildLargeAttachmentPlainText(t *testing.T) {
|
|
results := []largeAttachmentResult{
|
|
{FileName: "report.pdf", FileSize: 26214400, FileToken: "tok_aaa"},
|
|
{FileName: "video.mp4", FileSize: 314572800, FileToken: "tok_bbb"},
|
|
}
|
|
text := buildLargeAttachmentPlainText(core.BrandFeishu, "zh_cn", results)
|
|
if !strings.Contains(text, "来自飞书邮箱的超大附件") {
|
|
t.Error("should contain Chinese title for Feishu brand")
|
|
}
|
|
if !strings.Contains(text, "report.pdf") {
|
|
t.Error("should contain first filename")
|
|
}
|
|
if !strings.Contains(text, "video.mp4") {
|
|
t.Error("should contain second filename")
|
|
}
|
|
if !strings.Contains(text, "25.0 MB") {
|
|
t.Error("should contain file size display")
|
|
}
|
|
if !strings.Contains(text, "tok_aaa") {
|
|
t.Error("should contain first token in URL")
|
|
}
|
|
if !strings.Contains(text, "tok_bbb") {
|
|
t.Error("should contain second token in URL")
|
|
}
|
|
if !strings.Contains(text, "下载:") {
|
|
t.Error("should contain Chinese download label")
|
|
}
|
|
|
|
textEN := buildLargeAttachmentPlainText(core.BrandLark, "en_us", results)
|
|
if !strings.Contains(textEN, "Large file from Lark Mail") {
|
|
t.Error("should contain English title for Lark brand")
|
|
}
|
|
if !strings.Contains(textEN, "Download:") {
|
|
t.Error("should contain English download label")
|
|
}
|
|
}
|
|
|
|
func TestInjectLargeAttachmentTextIntoSnapshot(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/plain",
|
|
Body: []byte("hello"),
|
|
},
|
|
}
|
|
injectLargeAttachmentTextIntoSnapshot(snapshot, "\nattachment info\n")
|
|
got := string(snapshot.Body.Body)
|
|
if got != "hello\nattachment info\n" {
|
|
t.Errorf("got %q", got)
|
|
}
|
|
if !snapshot.Body.Dirty {
|
|
t.Error("should mark part as dirty")
|
|
}
|
|
}
|
|
|
|
func TestInjectLargeAttachmentTextIntoSnapshot_NilBody(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{}
|
|
injectLargeAttachmentTextIntoSnapshot(snapshot, "attachment info\n")
|
|
if snapshot.Body == nil {
|
|
t.Fatal("should create body")
|
|
}
|
|
if snapshot.Body.MediaType != "text/plain" {
|
|
t.Errorf("MediaType = %q, want text/plain", snapshot.Body.MediaType)
|
|
}
|
|
if string(snapshot.Body.Body) != "attachment info\n" {
|
|
t.Errorf("body = %q", string(snapshot.Body.Body))
|
|
}
|
|
}
|
|
|
|
func TestInjectLargeAttachmentTextIntoSnapshot_ExistingHTMLBody(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/html",
|
|
Body: []byte("<p>hello</p>"),
|
|
},
|
|
}
|
|
injectLargeAttachmentTextIntoSnapshot(snapshot, "\nattachment info\n")
|
|
if string(snapshot.Body.Body) != "<p>hello</p>" {
|
|
t.Error("should not modify existing non-text body")
|
|
}
|
|
}
|
|
|
|
func TestBuildLargeAttachmentPlainText_Empty(t *testing.T) {
|
|
text := buildLargeAttachmentPlainText(core.BrandFeishu, "zh_cn", nil)
|
|
if text != "" {
|
|
t.Error("should return empty string for no results")
|
|
}
|
|
}
|
|
|
|
func TestStatAttachmentFiles_BlockedExtension(t *testing.T) {
|
|
chdirTemp(t)
|
|
fio := &localfileio.LocalFileIO{}
|
|
|
|
blocked := []string{"malware.exe", "script.js", "payload.ps1", "trojan.bat"}
|
|
for _, name := range blocked {
|
|
os.WriteFile(name, []byte("content"), 0o644)
|
|
}
|
|
|
|
for _, name := range blocked {
|
|
t.Run(name, func(t *testing.T) {
|
|
_, err := statAttachmentFiles(fio, []string{name})
|
|
if err == nil {
|
|
t.Fatalf("expected blocked extension error for %q", name)
|
|
}
|
|
if !strings.Contains(err.Error(), "not allowed") {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// Allowed extensions should pass.
|
|
allowed := []string{"report.pdf", "data.csv", "photo.png"}
|
|
for _, name := range allowed {
|
|
os.WriteFile(name, []byte("content"), 0o644)
|
|
}
|
|
for _, name := range allowed {
|
|
t.Run(name, func(t *testing.T) {
|
|
files, err := statAttachmentFiles(fio, []string{name})
|
|
if err != nil {
|
|
t.Fatalf("expected %q to be allowed, got: %v", name, err)
|
|
}
|
|
if len(files) != 1 || files[0].FileName != name {
|
|
t.Fatalf("unexpected result: %+v", files)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInjectLargeAttachmentHTML_MergesIntoExistingContainer(t *testing.T) {
|
|
existingCard := `<div id="large-file-area-123456789" style="border: 1px solid #DEE0E3;">` +
|
|
`<div style="font-weight: 500;">来自飞书邮箱的超大附件</div>` +
|
|
`<div id="large-file-item"><a data-mail-token="tok_old">下载</a></div>` +
|
|
`</div>`
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/html",
|
|
Body: []byte("<p>Hello</p>" + existingCard),
|
|
},
|
|
}
|
|
newResults := []largeAttachmentResult{
|
|
{FileName: "new_file.txt", FileSize: 26214400, FileToken: "tok_new"},
|
|
}
|
|
injectLargeAttachmentHTMLIntoSnapshot(snapshot, core.BrandFeishu, "zh_cn", newResults)
|
|
|
|
html := string(snapshot.Body.Body)
|
|
|
|
// Should still have only one large-file-area container.
|
|
containerCount := strings.Count(html, "large-file-area-")
|
|
if containerCount != 1 {
|
|
t.Errorf("expected 1 container, got %d", containerCount)
|
|
}
|
|
|
|
// Old item should still be present.
|
|
if !strings.Contains(html, `data-mail-token="tok_old"`) {
|
|
t.Error("lost existing card tok_old")
|
|
}
|
|
// New item should be present inside the same container.
|
|
if !strings.Contains(html, `data-mail-token="tok_new"`) {
|
|
t.Error("missing new card tok_new")
|
|
}
|
|
if !strings.Contains(html, "new_file.txt") {
|
|
t.Error("missing filename new_file.txt")
|
|
}
|
|
// Original body content preserved.
|
|
if !strings.Contains(html, "<p>Hello</p>") {
|
|
t.Error("original body lost")
|
|
}
|
|
}
|
|
|
|
func TestInjectLargeAttachmentHTML_CreatesContainerWhenNoneExists(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/html",
|
|
Body: []byte("<p>Hello</p>"),
|
|
},
|
|
}
|
|
results := []largeAttachmentResult{
|
|
{FileName: "file.txt", FileSize: 1024, FileToken: "tok_a"},
|
|
}
|
|
injectLargeAttachmentHTMLIntoSnapshot(snapshot, core.BrandFeishu, "zh_cn", results)
|
|
|
|
html := string(snapshot.Body.Body)
|
|
if !strings.Contains(html, "large-file-area-") {
|
|
t.Error("should create a new container")
|
|
}
|
|
if !strings.Contains(html, `data-mail-token="tok_a"`) {
|
|
t.Error("missing card for tok_a")
|
|
}
|
|
if !strings.Contains(html, "<p>Hello</p>") {
|
|
t.Error("original body lost")
|
|
}
|
|
}
|
|
|
|
func TestInjectLargeAttachmentHTML_TwoInjectionsProduceSingleContainer(t *testing.T) {
|
|
// Simulates the draft-edit flow: ensureLargeAttachmentCards injects
|
|
// the first batch, then preprocessLargeAttachmentsForDraftEdit injects
|
|
// newly uploaded attachments. Both should end up in one container.
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{
|
|
MediaType: "text/html",
|
|
Body: []byte("<p>body</p>"),
|
|
},
|
|
}
|
|
brand := core.BrandFeishu
|
|
lang := "zh_cn"
|
|
|
|
// First injection (from ensureLargeAttachmentCards)
|
|
injectLargeAttachmentHTMLIntoSnapshot(snapshot, brand, lang, []largeAttachmentResult{
|
|
{FileName: "old.txt", FileSize: 27262976, FileToken: "tok_old"},
|
|
})
|
|
// Second injection (from preprocessLargeAttachmentsForDraftEdit)
|
|
injectLargeAttachmentHTMLIntoSnapshot(snapshot, brand, lang, []largeAttachmentResult{
|
|
{FileName: "new.txt", FileSize: 26214400, FileToken: "tok_new"},
|
|
})
|
|
|
|
html := string(snapshot.Body.Body)
|
|
|
|
containerCount := strings.Count(html, "large-file-area-")
|
|
if containerCount != 1 {
|
|
t.Errorf("expected 1 container after two injections, got %d\nhtml: %s", containerCount, html)
|
|
}
|
|
if !strings.Contains(html, `data-mail-token="tok_old"`) {
|
|
t.Error("missing first injection card tok_old")
|
|
}
|
|
if !strings.Contains(html, `data-mail-token="tok_new"`) {
|
|
t.Error("missing second injection card tok_new")
|
|
}
|
|
if !strings.Contains(html, "old.txt") {
|
|
t.Error("missing filename old.txt")
|
|
}
|
|
if !strings.Contains(html, "new.txt") {
|
|
t.Error("missing filename new.txt")
|
|
}
|
|
}
|
|
|
|
func TestFlattenSnapshotParts(t *testing.T) {
|
|
t.Run("nil root", func(t *testing.T) {
|
|
got := flattenSnapshotParts(nil)
|
|
if len(got) != 0 {
|
|
t.Errorf("expected nil, got %d parts", len(got))
|
|
}
|
|
})
|
|
t.Run("single part", func(t *testing.T) {
|
|
root := &draftpkg.Part{MediaType: "text/html", Body: []byte("hello")}
|
|
got := flattenSnapshotParts(root)
|
|
if len(got) != 1 {
|
|
t.Fatalf("expected 1 part, got %d", len(got))
|
|
}
|
|
if string(got[0].Body) != "hello" {
|
|
t.Errorf("got body %q", string(got[0].Body))
|
|
}
|
|
})
|
|
t.Run("nested multipart", func(t *testing.T) {
|
|
leaf1 := &draftpkg.Part{MediaType: "text/plain", Body: []byte("text")}
|
|
leaf2 := &draftpkg.Part{MediaType: "text/html", Body: []byte("<p>html</p>")}
|
|
leaf3 := &draftpkg.Part{MediaType: "image/png", Body: []byte("png-data")}
|
|
mid := &draftpkg.Part{MediaType: "multipart/alternative", Children: []*draftpkg.Part{leaf1, leaf2}}
|
|
root := &draftpkg.Part{MediaType: "multipart/mixed", Children: []*draftpkg.Part{mid, leaf3}}
|
|
got := flattenSnapshotParts(root)
|
|
if len(got) != 5 {
|
|
t.Fatalf("expected 5 parts (root+mid+3 leaves), got %d", len(got))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSnapshotEMLBaseSize(t *testing.T) {
|
|
t.Run("nil body", func(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{}
|
|
got := snapshotEMLBaseSize(snapshot)
|
|
if got != 2048 {
|
|
t.Errorf("expected 2048 (header overhead only), got %d", got)
|
|
}
|
|
})
|
|
t.Run("single text part", func(t *testing.T) {
|
|
body := make([]byte, 300)
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{MediaType: "text/plain", Body: body},
|
|
}
|
|
got := snapshotEMLBaseSize(snapshot)
|
|
expected := int64(2048) + estimateBase64EMLSize(300)
|
|
if got != expected {
|
|
t.Errorf("expected %d, got %d", expected, got)
|
|
}
|
|
})
|
|
t.Run("multipart with children", func(t *testing.T) {
|
|
textBody := make([]byte, 100)
|
|
htmlBody := make([]byte, 500)
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{
|
|
MediaType: "multipart/alternative",
|
|
Children: []*draftpkg.Part{
|
|
{MediaType: "text/plain", Body: textBody},
|
|
{MediaType: "text/html", Body: htmlBody},
|
|
},
|
|
},
|
|
}
|
|
got := snapshotEMLBaseSize(snapshot)
|
|
expected := int64(2048) + estimateBase64EMLSize(0) + estimateBase64EMLSize(100) + estimateBase64EMLSize(500)
|
|
if got != expected {
|
|
t.Errorf("expected %d, got %d", expected, got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestEstimateEMLBaseSize(t *testing.T) {
|
|
chdirTemp(t)
|
|
fio := &localfileio.LocalFileIO{}
|
|
|
|
t.Run("no inline files", func(t *testing.T) {
|
|
got := estimateEMLBaseSize(fio, 1000, nil, 0)
|
|
expected := int64(2048) + estimateBase64EMLSize(1000)
|
|
if got != expected {
|
|
t.Errorf("expected %d, got %d", expected, got)
|
|
}
|
|
})
|
|
t.Run("with inline files", func(t *testing.T) {
|
|
os.WriteFile("img.png", make([]byte, 5000), 0o644)
|
|
got := estimateEMLBaseSize(fio, 200, []string{"img.png"}, 0)
|
|
expected := int64(2048) + estimateBase64EMLSize(200) + estimateBase64EMLSize(5000)
|
|
if got != expected {
|
|
t.Errorf("expected %d, got %d", expected, got)
|
|
}
|
|
})
|
|
t.Run("with extra bytes", func(t *testing.T) {
|
|
got := estimateEMLBaseSize(fio, 100, nil, 3000)
|
|
expected := int64(2048) + estimateBase64EMLSize(100) + 3000
|
|
if got != expected {
|
|
t.Errorf("expected %d, got %d", expected, got)
|
|
}
|
|
})
|
|
t.Run("missing inline file ignored", func(t *testing.T) {
|
|
got := estimateEMLBaseSize(fio, 100, []string{"nonexistent.png"}, 0)
|
|
expected := int64(2048) + estimateBase64EMLSize(100)
|
|
if got != expected {
|
|
t.Errorf("expected %d (missing file should be skipped), got %d", expected, got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNormalizeLargeAttachmentHeader(t *testing.T) {
|
|
t.Run("no large attachment headers", func(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: "Subject", Value: "test"},
|
|
},
|
|
}
|
|
normalizeLargeAttachmentHeader(snapshot)
|
|
if len(snapshot.Headers) != 1 {
|
|
t.Errorf("headers should be unchanged, got %d", len(snapshot.Headers))
|
|
}
|
|
})
|
|
t.Run("server header converted to CLI format", func(t *testing.T) {
|
|
serverVal := encodeServerHeader([]map[string]interface{}{
|
|
{"file_key": "tok_a", "file_name": "a.pdf", "file_size": 1024},
|
|
})
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: serverVal},
|
|
},
|
|
}
|
|
normalizeLargeAttachmentHeader(snapshot)
|
|
|
|
found := false
|
|
for _, h := range snapshot.Headers {
|
|
if h.Name == draftpkg.ServerLargeAttachmentHeader {
|
|
t.Error("server header should have been removed")
|
|
}
|
|
if h.Name == draftpkg.LargeAttachmentIDsHeader {
|
|
found = true
|
|
decoded, _ := base64.StdEncoding.DecodeString(h.Value)
|
|
var ids []largeAttID
|
|
json.Unmarshal(decoded, &ids)
|
|
if len(ids) != 1 || ids[0].ID != "tok_a" {
|
|
t.Errorf("expected [{id:tok_a}], got %+v", ids)
|
|
}
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("CLI-format header not created")
|
|
}
|
|
})
|
|
t.Run("CLI header takes precedence over server header", func(t *testing.T) {
|
|
serverVal := encodeServerHeader([]map[string]interface{}{
|
|
{"file_key": "tok_server"},
|
|
})
|
|
cliIDs, _ := json.Marshal([]largeAttID{{ID: "tok_cli"}})
|
|
cliVal := base64.StdEncoding.EncodeToString(cliIDs)
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.LargeAttachmentIDsHeader, Value: cliVal},
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: serverVal},
|
|
},
|
|
}
|
|
normalizeLargeAttachmentHeader(snapshot)
|
|
|
|
for _, h := range snapshot.Headers {
|
|
if h.Name == draftpkg.ServerLargeAttachmentHeader {
|
|
t.Error("server header should have been removed")
|
|
}
|
|
if h.Name == draftpkg.LargeAttachmentIDsHeader {
|
|
if h.Value != cliVal {
|
|
t.Error("CLI header value should be preserved as-is")
|
|
}
|
|
}
|
|
}
|
|
})
|
|
t.Run("multiple server headers deduped", func(t *testing.T) {
|
|
val1 := encodeServerHeader([]map[string]interface{}{{"file_key": "tok_a"}})
|
|
val2 := encodeServerHeader([]map[string]interface{}{{"file_key": "tok_a"}, {"file_key": "tok_b"}})
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: val1},
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: val2},
|
|
},
|
|
}
|
|
normalizeLargeAttachmentHeader(snapshot)
|
|
|
|
var cliHeader *draftpkg.Header
|
|
for i := range snapshot.Headers {
|
|
if snapshot.Headers[i].Name == draftpkg.LargeAttachmentIDsHeader {
|
|
cliHeader = &snapshot.Headers[i]
|
|
}
|
|
if snapshot.Headers[i].Name == draftpkg.ServerLargeAttachmentHeader {
|
|
t.Error("server header should have been removed")
|
|
}
|
|
}
|
|
if cliHeader == nil {
|
|
t.Fatal("CLI header not created")
|
|
}
|
|
decoded, _ := base64.StdEncoding.DecodeString(cliHeader.Value)
|
|
var ids []largeAttID
|
|
json.Unmarshal(decoded, &ids)
|
|
if len(ids) != 2 {
|
|
t.Errorf("expected 2 deduped tokens, got %d: %+v", len(ids), ids)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestInjectLargeAttachmentHTML_EmptyResults(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{MediaType: "text/html", Body: []byte("<p>hello</p>")},
|
|
}
|
|
original := string(snapshot.Body.Body)
|
|
injectLargeAttachmentHTMLIntoSnapshot(snapshot, core.BrandFeishu, "zh_cn", nil)
|
|
if string(snapshot.Body.Body) != original {
|
|
t.Error("empty results should not modify body")
|
|
}
|
|
}
|
|
|
|
func TestInjectLargeAttachmentHTML_NilBodyCreatesNew(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{}
|
|
results := []largeAttachmentResult{
|
|
{FileName: "file.txt", FileSize: 1024, FileToken: "tok_a"},
|
|
}
|
|
injectLargeAttachmentHTMLIntoSnapshot(snapshot, core.BrandFeishu, "zh_cn", results)
|
|
if snapshot.Body == nil {
|
|
t.Fatal("should create body part")
|
|
}
|
|
if snapshot.Body.MediaType != "text/html" {
|
|
t.Errorf("MediaType = %q, want text/html", snapshot.Body.MediaType)
|
|
}
|
|
if !strings.Contains(string(snapshot.Body.Body), "tok_a") {
|
|
t.Error("body should contain the token")
|
|
}
|
|
if !snapshot.Body.Dirty {
|
|
t.Error("should mark part as dirty")
|
|
}
|
|
}
|
|
|
|
func TestInjectLargeAttachmentHTML_SkipsWhenNonNilBodyButNoHTMLPart(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{MediaType: "text/plain", Body: []byte("text only")},
|
|
}
|
|
original := string(snapshot.Body.Body)
|
|
injectLargeAttachmentHTMLIntoSnapshot(snapshot, core.BrandFeishu, "zh_cn",
|
|
[]largeAttachmentResult{{FileName: "f.txt", FileSize: 100, FileToken: "tok"}})
|
|
if string(snapshot.Body.Body) != original {
|
|
t.Error("should not modify text/plain body when looking for HTML part")
|
|
}
|
|
}
|
|
|
|
func TestInjectLargeAttachmentText_EmptyNilBody(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{MediaType: "text/html", Body: []byte("<p>html</p>")},
|
|
}
|
|
original := string(snapshot.Body.Body)
|
|
injectLargeAttachmentTextIntoSnapshot(snapshot, "\nattachment\n")
|
|
if string(snapshot.Body.Body) != original {
|
|
t.Error("should not modify when text part not found but body exists")
|
|
}
|
|
}
|
|
|
|
func TestStatAttachmentFiles_EmptyAndWhitespace(t *testing.T) {
|
|
fio := &localfileio.LocalFileIO{}
|
|
files, err := statAttachmentFiles(fio, []string{"", " ", ""})
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(files) != 0 {
|
|
t.Errorf("expected 0 files for empty/whitespace paths, got %d", len(files))
|
|
}
|
|
}
|
|
|
|
func TestStatAttachmentFiles_FileNotFound(t *testing.T) {
|
|
chdirTemp(t)
|
|
fio := &localfileio.LocalFileIO{}
|
|
_, err := statAttachmentFiles(fio, []string{"nonexistent.txt"})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing file")
|
|
}
|
|
if !strings.Contains(err.Error(), "failed to stat") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestBuildLargeAttachmentItems(t *testing.T) {
|
|
t.Run("empty results", func(t *testing.T) {
|
|
got := buildLargeAttachmentItems(core.BrandFeishu, "en_us", nil)
|
|
if got != "" {
|
|
t.Errorf("expected empty string, got %q", got)
|
|
}
|
|
})
|
|
t.Run("Chinese download text", func(t *testing.T) {
|
|
results := []largeAttachmentResult{
|
|
{FileName: "doc.pdf", FileSize: 1024, FileToken: "tok1"},
|
|
}
|
|
got := buildLargeAttachmentItems(core.BrandFeishu, "zh_cn", results)
|
|
if !strings.Contains(got, "下载") {
|
|
t.Error("should contain Chinese download text")
|
|
}
|
|
if !strings.Contains(got, iconCDNCN) {
|
|
t.Error("should use CN icon CDN for Feishu brand")
|
|
}
|
|
})
|
|
t.Run("English with Lark brand uses EN CDN", func(t *testing.T) {
|
|
results := []largeAttachmentResult{
|
|
{FileName: "doc.pdf", FileSize: 1024, FileToken: "tok1"},
|
|
}
|
|
got := buildLargeAttachmentItems(core.BrandLark, "en_us", results)
|
|
if !strings.Contains(got, "Download") {
|
|
t.Error("should contain English download text")
|
|
}
|
|
if !strings.Contains(got, iconCDNEN) {
|
|
t.Error("should use EN icon CDN for Lark brand")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestClassifyAttachments_EmptyFiles(t *testing.T) {
|
|
result := classifyAttachments(nil, 0)
|
|
if len(result.Normal) != 0 || len(result.Oversized) != 0 {
|
|
t.Errorf("expected empty result for nil files, got normal=%d oversized=%d",
|
|
len(result.Normal), len(result.Oversized))
|
|
}
|
|
}
|
|
|
|
func TestProcessLargeAttachments_AttachmentCountLimit(t *testing.T) {
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
bld := emlbuilder.New()
|
|
paths := make([]string, MaxAttachmentCount+1)
|
|
for i := range paths {
|
|
paths[i] = "file.txt"
|
|
}
|
|
_, err := processLargeAttachments(nil, rt, bld, "<p>body</p>", "", paths, 0, 0)
|
|
if err == nil || !strings.Contains(err.Error(), "exceeds the limit") {
|
|
t.Fatalf("expected count limit error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessLargeAttachments_ExtraAttachCountLimit(t *testing.T) {
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
bld := emlbuilder.New()
|
|
_, err := processLargeAttachments(nil, rt, bld, "<p>body</p>", "", []string{"a.txt"}, 0, MaxAttachmentCount)
|
|
if err == nil || !strings.Contains(err.Error(), "exceeds the limit") {
|
|
t.Fatalf("expected count limit error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessLargeAttachments_FileStatError(t *testing.T) {
|
|
chdirTemp(t)
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
bld := emlbuilder.New()
|
|
_, err := processLargeAttachments(nil, rt, bld, "<p>body</p>", "", []string{"nonexistent.pdf"}, 0, 0)
|
|
if err == nil || !strings.Contains(err.Error(), "stat") {
|
|
t.Fatalf("expected stat error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessLargeAttachments_AllFitNormal(t *testing.T) {
|
|
chdirTemp(t)
|
|
os.WriteFile("small.txt", make([]byte, 1024), 0o644)
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
bld := emlbuilder.New().WithFileIO(rt.FileIO())
|
|
result, err := processLargeAttachments(nil, rt, bld, "<p>body</p>", "", []string{"small.txt"}, 0, 0)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
_ = result
|
|
}
|
|
|
|
func TestProcessLargeAttachments_OversizedEmptyBody(t *testing.T) {
|
|
chdirTemp(t)
|
|
os.WriteFile("huge.zip", make([]byte, 100), 0o644)
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
bld := emlbuilder.New().WithFileIO(rt.FileIO())
|
|
_, err := processLargeAttachments(nil, rt, bld, "", "", []string{"huge.zip"}, emlbuilder.MaxEMLSize, 0)
|
|
if err == nil || !strings.Contains(err.Error(), "require a body") {
|
|
t.Fatalf("expected empty body error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessLargeAttachments_OversizedNoIdentity(t *testing.T) {
|
|
chdirTemp(t)
|
|
os.WriteFile("huge.zip", make([]byte, 100), 0o644)
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
bld := emlbuilder.New().WithFileIO(rt.FileIO())
|
|
_, err := processLargeAttachments(nil, rt, bld, "<p>body</p>", "", []string{"huge.zip"}, emlbuilder.MaxEMLSize, 0)
|
|
if err == nil || !strings.Contains(err.Error(), "user identity") {
|
|
t.Fatalf("expected user identity error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPreprocessLargeAttachments_NoAddAttachmentOps(t *testing.T) {
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{MediaType: "text/html", Body: []byte("<p>hello</p>")},
|
|
}
|
|
patch := draftpkg.Patch{
|
|
Ops: []draftpkg.PatchOp{
|
|
{Op: "set_subject", Value: "test"},
|
|
},
|
|
}
|
|
result, err := preprocessLargeAttachmentsForDraftEdit(nil, rt, snapshot, patch)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result.Ops) != 1 || result.Ops[0].Op != "set_subject" {
|
|
t.Errorf("expected patch to pass through unchanged, got %+v", result.Ops)
|
|
}
|
|
}
|
|
|
|
func TestPreprocessLargeAttachments_AllFitReturnsUnchanged(t *testing.T) {
|
|
chdirTemp(t)
|
|
os.WriteFile("small.pdf", make([]byte, 1024), 0o644)
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{MediaType: "text/html", Body: []byte("<p>hello</p>")},
|
|
}
|
|
patch := draftpkg.Patch{
|
|
Ops: []draftpkg.PatchOp{
|
|
{Op: "add_attachment", Path: "small.pdf"},
|
|
{Op: "set_subject", Value: "test"},
|
|
},
|
|
}
|
|
result, err := preprocessLargeAttachmentsForDraftEdit(nil, rt, snapshot, patch)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(result.Ops) != 2 {
|
|
t.Errorf("expected 2 ops (all fit, nothing removed), got %d", len(result.Ops))
|
|
}
|
|
}
|
|
|
|
func TestPreprocessLargeAttachments_StatError(t *testing.T) {
|
|
chdirTemp(t)
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{MediaType: "text/html", Body: []byte("<p>hello</p>")},
|
|
}
|
|
patch := draftpkg.Patch{
|
|
Ops: []draftpkg.PatchOp{
|
|
{Op: "add_attachment", Path: "nonexistent.pdf"},
|
|
},
|
|
}
|
|
_, err := preprocessLargeAttachmentsForDraftEdit(nil, rt, snapshot, patch)
|
|
if err == nil || !strings.Contains(err.Error(), "stat") {
|
|
t.Fatalf("expected stat error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPreprocessLargeAttachments_OversizedEmptyBody(t *testing.T) {
|
|
chdirTemp(t)
|
|
// Create a file large enough that when base64-encoded it exceeds MaxEMLSize - header overhead
|
|
fileSize := emlbuilder.MaxEMLSize // guaranteed to overflow
|
|
os.WriteFile("big.zip", make([]byte, fileSize), 0o644)
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
// Empty snapshot has no HTML or text body part
|
|
snapshot := &draftpkg.DraftSnapshot{}
|
|
patch := draftpkg.Patch{
|
|
Ops: []draftpkg.PatchOp{
|
|
{Op: "add_attachment", Path: "big.zip"},
|
|
},
|
|
}
|
|
_, err := preprocessLargeAttachmentsForDraftEdit(nil, rt, snapshot, patch)
|
|
if err == nil || !strings.Contains(err.Error(), "require a body") {
|
|
t.Fatalf("expected empty body error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPreprocessLargeAttachments_OversizedNoIdentity(t *testing.T) {
|
|
chdirTemp(t)
|
|
os.WriteFile("big.zip", make([]byte, 100), 0o644)
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
// Create a snapshot whose EML base is already at the limit
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Body: &draftpkg.Part{
|
|
MediaType: "multipart/mixed",
|
|
Children: []*draftpkg.Part{
|
|
{MediaType: "text/html", Body: make([]byte, emlbuilder.MaxEMLSize)},
|
|
},
|
|
},
|
|
}
|
|
patch := draftpkg.Patch{
|
|
Ops: []draftpkg.PatchOp{
|
|
{Op: "add_attachment", Path: "big.zip"},
|
|
},
|
|
}
|
|
_, err := preprocessLargeAttachmentsForDraftEdit(nil, rt, snapshot, patch)
|
|
if err == nil || !strings.Contains(err.Error(), "user identity") {
|
|
t.Fatalf("expected user identity error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestPreprocessLargeAttachments_NormalizesHeaders(t *testing.T) {
|
|
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
|
|
serverVal := encodeServerHeader([]map[string]interface{}{
|
|
{"file_key": "tok_old", "file_name": "old.pdf", "file_size": 1024},
|
|
})
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
Headers: []draftpkg.Header{
|
|
{Name: draftpkg.ServerLargeAttachmentHeader, Value: serverVal},
|
|
},
|
|
Body: &draftpkg.Part{MediaType: "text/html", Body: []byte("<p>hello</p>")},
|
|
}
|
|
patch := draftpkg.Patch{} // no add_attachment ops
|
|
_, err := preprocessLargeAttachmentsForDraftEdit(nil, rt, snapshot, patch)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// After processing, server header should be normalized to CLI format
|
|
for _, h := range snapshot.Headers {
|
|
if h.Name == draftpkg.ServerLargeAttachmentHeader {
|
|
t.Error("server header should have been normalized away")
|
|
}
|
|
}
|
|
found := false
|
|
for _, h := range snapshot.Headers {
|
|
if h.Name == draftpkg.LargeAttachmentIDsHeader {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("CLI-format header should have been created")
|
|
}
|
|
}
|
|
|
|
func TestFileTypeIcon(t *testing.T) {
|
|
cases := []struct {
|
|
filename string
|
|
want string
|
|
}{
|
|
{"report.pdf", "icon_file_pdf.png"},
|
|
{"doc.docx", "icon_file_doc.png"},
|
|
{"slides.pptx", "icon_file_ppt.png"},
|
|
{"data.xlsx", "icon_file_excel.png"},
|
|
{"archive.zip", "icon_file_zip.png"},
|
|
{"archive.7z", "icon_file_zip.png"},
|
|
{"photo.png", "icon_file_image.png"},
|
|
{"photo.JPEG", "icon_file_image.png"},
|
|
{"video.mp4", "icon_file_video.png"},
|
|
{"song.mp3", "icon_file_audio.png"},
|
|
{"notes.txt", "icon_file_doc.png"},
|
|
{"mail.eml", "icon_file_eml.png"},
|
|
{"app.apk", "icon_file_android.png"},
|
|
{"design.psd", "icon_file_ps.png"},
|
|
{"logo.ai", "icon_file_ai.png"},
|
|
{"mockup.sketch", "icon_file_sketch.png"},
|
|
{"deck.key", "icon_file_keynote.png"},
|
|
{"budget.numbers", "icon_file_numbers.png"},
|
|
{"letter.pages", "icon_file_pages.png"},
|
|
{"random.xyz", "icon_file_unknow.png"},
|
|
{"noext", "icon_file_unknow.png"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.filename, func(t *testing.T) {
|
|
got := fileTypeIcon(tc.filename)
|
|
if got != tc.want {
|
|
t.Errorf("fileTypeIcon(%q) = %q, want %q", tc.filename, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|