Files
larksuite-cli/shortcuts/mail/mail_reply_forward_inline_test.go
qioqio cb301a3d1a feat(mail): add draft preview URL to draft operations (#438)
* feat(mail): add draft preview URL to draft operations

- Add draftPreviewURL helpers for send-preview link generation
- Integrate preview_url output in +draft-create, +draft-edit, +reply,
  +forward, +reply-all shortcuts
- Add unit tests (7 test cases, all passing)

Change-Id: Ie3cbb8f96b308aae225bc69f4c3fc2226af0c230

* fix(mail): derive draft preview url from meta service

Change-Id: Ibd10767bf4e4de7f453fff72487fe25090e14605

* fix: streamline mail draft and send outputs

Change-Id: I75a969af29fa862bdf94947a3aa775d6eebee812

* fix(mail): keep draft reference on create and update

Change-Id: Ie5787cf255ec2347c49f0a271209c1a2e4008fe3

* docs: refine mail draft link guidance for skills

Change-Id: Ieaa5afef310edd5253f07eef06678b7a5db38fc0

* fix(mail): return draft reference for save flows

Change-Id: Ied6031a05bdefecdcf60b09f66c5d3947d849f83

* refactor(mail): unify draft save output handling

Change-Id: I400b8f9df97d614b33da3cbdde410ef615444741

* fix(mail): surface automation disable reason

Change-Id: I23293fe6c2febf248c58ea14c87c05dde49872a1

* feat: flatten mail automation send disable output

Change-Id: I747bf54bc3251387b05d94f87fe61da958d78104

* fix(mail): address review feedback for draft docs and tests

Change-Id: I690df5612f36681c1690645d375c5c5e3ef9ca60

* test(mail): reuse upstream send-scope test factory

Change-Id: I7f73956696c5405d8eb81fcd2128f0e9898ea539

* refactor(mail): merge recall fields into send output helper

Change-Id: I5af612d70b05a3c0d8abbc9561fe76bb83b5b359

* fix(mail): omit raw recall status from send output

Change-Id: I2918226a0eb68a45f6cc4ea997e1c941d8c16d52

* style(mail): format send output tests

Change-Id: I8e0ec37aac48bcda6b5ad948f397d184a2a4d81d

* test(mail): cover draft reference output flows

Change-Id: Idd8abdb84613727a24e3fccb7b329e69566bc890
2026-04-21 20:55:41 +08:00

290 lines
9.0 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/base64"
"os"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
// stubSourceMessageWithInlineImages registers HTTP stubs for a source message.
func stubSourceMessageWithInlineImages(reg *httpmock.Registry, bodyHTML string, allImages []map[string]interface{}) {
// Profile
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"primary_email_address": "me@example.com",
},
},
})
// Message get
atts := allImages
if atts == nil {
atts = []map[string]interface{}{}
}
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/messages/msg_001",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"message": map[string]interface{}{
"message_id": "msg_001",
"thread_id": "thread_001",
"smtp_message_id": "<msg_001@example.com>",
"subject": "Original Subject",
"head_from": map[string]interface{}{"mail_address": "sender@example.com", "name": "Sender"},
"to": []map[string]interface{}{{"mail_address": "me@example.com", "name": "Me"}},
"cc": []interface{}{},
"bcc": []interface{}{},
"body_html": base64.URLEncoding.EncodeToString([]byte(bodyHTML)),
"body_plain_text": base64.URLEncoding.EncodeToString([]byte("plain")),
"internal_date": "1704067200000",
"attachments": atts,
},
},
},
})
// Download URLs
if len(allImages) > 0 {
downloadURLs := make([]map[string]interface{}, 0, len(allImages))
for _, img := range allImages {
id, _ := img["id"].(string)
downloadURLs = append(downloadURLs, map[string]interface{}{
"attachment_id": id,
"download_url": "https://storage.example.com/" + id,
})
}
reg.Register(&httpmock.Stub{
URL: "/attachments/download_url",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"download_urls": downloadURLs,
"failed_ids": []interface{}{},
},
},
})
}
// Image downloads
pngBytes := []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}
for _, img := range allImages {
id, _ := img["id"].(string)
reg.Register(&httpmock.Stub{
URL: "https://storage.example.com/" + id,
RawBody: pngBytes,
})
}
// Draft create
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/drafts",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"draft_id": "draft_001",
"reference": "https://www.feishu.cn/mail?draftId=draft_001",
},
},
})
}
// ---------------------------------------------------------------------------
// +reply with source inline images
// ---------------------------------------------------------------------------
func TestReply_SourceInlineImagesPreserved(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Hello <img src="cid:banner_001" /></p>`,
[]map[string]interface{}{
{"id": "img_001", "filename": "banner.png", "is_inline": true, "cid": "banner_001", "content_type": "image/png"},
},
)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply", "--message-id", "msg_001", "--body", "<p>Thanks!</p>",
}, f, stdout)
if err != nil {
t.Fatalf("reply failed: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["draft_id"] == nil || data["draft_id"] == "" {
t.Fatal("expected draft_id in output")
}
if data["reference"] != "https://www.feishu.cn/mail?draftId=draft_001" {
t.Fatalf("reference = %v", data["reference"])
}
}
func TestReply_SourceOrphanCIDNotBlocked(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
// Source has TWO inline images, but body HTML only references one.
// The unreferenced image should NOT be downloaded or cause an error.
stubSourceMessageWithInlineImages(reg,
`<p>Hello <img src="cid:used_001" /></p>`,
[]map[string]interface{}{
{"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"},
{"id": "img_002", "filename": "unused.png", "is_inline": true, "cid": "unused_002", "content_type": "image/png"},
},
)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply", "--message-id", "msg_001", "--body", "<p>Reply</p>",
}, f, stdout)
if err != nil {
t.Fatalf("reply should succeed even with unreferenced source CID, got: %v", err)
}
}
func TestReply_WithAutoResolveLocalImage(t *testing.T) {
chdirTemp(t)
os.WriteFile("local.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Hello</p>`,
nil,
)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply", "--message-id", "msg_001",
"--body", `<p>See image: <img src="./local.png" /></p>`,
}, f, stdout)
if err != nil {
t.Fatalf("reply with auto-resolved local image failed: %v", err)
}
}
// ---------------------------------------------------------------------------
// +reply-all with source inline images
// ---------------------------------------------------------------------------
func TestReplyAll_SourceOrphanCIDNotBlocked(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Hello <img src="cid:used_001" /></p>`,
[]map[string]interface{}{
{"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"},
{"id": "img_002", "filename": "orphan.png", "is_inline": true, "cid": "orphan_002", "content_type": "image/png"},
},
)
// reply-all also needs self-exclusion profile lookup
reg.Register(&httpmock.Stub{
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"primary_email_address": "me@example.com",
},
},
})
err := runMountedMailShortcut(t, MailReplyAll, []string{
"+reply-all", "--message-id", "msg_001", "--body", "<p>Reply all</p>",
}, f, stdout)
if err != nil {
t.Fatalf("reply-all should succeed with unreferenced source CID, got: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["reference"] != "https://www.feishu.cn/mail?draftId=draft_001" {
t.Fatalf("reference = %v", data["reference"])
}
}
// ---------------------------------------------------------------------------
// +forward with source inline images
// ---------------------------------------------------------------------------
func TestForward_SourceOrphanCIDNotBlocked(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Hello <img src="cid:used_001" /></p>`,
[]map[string]interface{}{
{"id": "img_001", "filename": "used.png", "is_inline": true, "cid": "used_001", "content_type": "image/png"},
{"id": "img_002", "filename": "orphan.png", "is_inline": true, "cid": "orphan_002", "content_type": "image/png"},
},
)
err := runMountedMailShortcut(t, MailForward, []string{
"+forward", "--message-id", "msg_001",
"--to", "alice@example.com",
"--body", "<p>FYI</p>",
}, f, stdout)
if err != nil {
t.Fatalf("forward should succeed with unreferenced source CID, got: %v", err)
}
data := decodeShortcutEnvelopeData(t, stdout)
if data["reference"] != "https://www.feishu.cn/mail?draftId=draft_001" {
t.Fatalf("reference = %v", data["reference"])
}
}
func TestForward_WithAutoResolveLocalImage(t *testing.T) {
chdirTemp(t)
os.WriteFile("chart.png", []byte{0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}, 0o644)
f, stdout, _, reg := mailShortcutTestFactory(t)
stubSourceMessageWithInlineImages(reg,
`<p>Original content</p>`,
nil,
)
err := runMountedMailShortcut(t, MailForward, []string{
"+forward", "--message-id", "msg_001",
"--to", "alice@example.com",
"--body", `<p>See chart: <img src="./chart.png" /></p>`,
}, f, stdout)
if err != nil {
t.Fatalf("forward with auto-resolved local image failed: %v", err)
}
}
// ---------------------------------------------------------------------------
// +reply body auto-resolve does NOT scan quoted content
// ---------------------------------------------------------------------------
func TestReply_QuotedContentNotAutoResolved(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
// Source message body has a relative <img src> — this should NOT be
// auto-resolved because it's in the quoted portion, not the user body.
stubSourceMessageWithInlineImages(reg,
`<p>See <img src="./should-not-resolve.png" /></p>`,
nil,
)
err := runMountedMailShortcut(t, MailReply, []string{
"+reply", "--message-id", "msg_001",
"--body", "<p>Got it</p>",
}, f, stdout)
// Should succeed — the ./should-not-resolve.png in quoted content is
// NOT auto-resolved (file doesn't exist, would fail if scanned).
if err != nil {
if strings.Contains(err.Error(), "should-not-resolve") {
t.Fatalf("auto-resolve incorrectly scanned quoted content: %v", err)
}
t.Fatalf("unexpected error: %v", err)
}
}