mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* 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
290 lines
9.0 KiB
Go
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)
|
|
}
|
|
}
|