Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions shortcuts/mail/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2620,3 +2620,45 @@ func validateBotMailboxNotMe(runtime *common.RuntimeContext) error {
}
return nil
}

// validateMessageIDs parses and validates the existing +messages comma-separated
// flag format. Unlike splitByComma, it keeps empty entries so "id1,,id2" fails
// locally. It intentionally does not enforce the server-side single-call limit:
// fetchFullMessages chunks backend requests into batches of 20.
func validateMessageIDs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
return nil, output.ErrValidation("--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if part != id {
return nil, output.ErrValidation("--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateBatchGetMessageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
return nil, output.ErrValidation("--message-ids entry %d (%q): duplicate message ID is not allowed", i+1, id)
}
seen[id] = struct{}{}
ids = append(ids, id)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return ids, nil
}

func validateBatchGetMessageID(id string, index int) error {
if strings.Trim(id, "0123456789") == "" {
return output.ErrValidation("--message-ids entry %d (%q): numeric primary IDs are not supported by mail +messages; pass the Open API message_id from mail output", index+1, id)
}
decoded, err := base64.URLEncoding.DecodeString(id)
if err != nil || len(decoded) == 0 {
return output.ErrValidation("--message-ids entry %d (%q): expected a base64url Open API mail message_id from mail output", index+1, id)
}
return nil
}
20 changes: 12 additions & 8 deletions shortcuts/mail/mail_messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import (
"context"

"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)

Expand All @@ -19,7 +18,8 @@
}

// MailMessages is the `+messages` shortcut: batch-fetch full content for
// up to 20 message IDs in a single call, preserving request order.
// multiple message IDs, chunking backend calls into batches of 20 while
// preserving request order.
var MailMessages = common.Shortcut{
Service: "mail",
Command: "+messages",
Expand All @@ -30,16 +30,20 @@
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Default: "me", Desc: "email address (default: me)"},
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. Example: "id1,id2,id3"`, Required: true},
{Name: "message-ids", Desc: `Required. Comma-separated email message IDs. The CLI auto-chunks backend requests in batches of 20. Example: "id1,id2,id3"`, Required: true},
{Name: "html", Type: "bool", Default: "true", Desc: "Whether to return HTML body (false returns plain text only to save bandwidth)"},
{Name: "print-output-schema", Type: "bool", Desc: "Print output field reference (run this first to learn field names before parsing output)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateBotMailboxNotMe(runtime)
if err := validateBotMailboxNotMe(runtime); err != nil {
return err
}
_, err := validateMessageIDs(runtime.Str("message-ids"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(runtime)
messageIDs := splitByComma(runtime.Str("message-ids"))
messageIDs, _ := validateMessageIDs(runtime.Str("message-ids"))

Check warning on line 46 in shortcuts/mail/mail_messages.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_messages.go#L46

Added line #L46 was not covered by tests
body := map[string]interface{}{
"format": messageGetFormat(runtime.Bool("html")),
"message_ids": []string{"<message_id_1>", "<message_id_2>"},
Expand All @@ -59,9 +63,9 @@
}
mailboxID := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailboxID)
messageIDs := splitByComma(runtime.Str("message-ids"))
if len(messageIDs) == 0 {
return output.ErrValidation("--message-ids is required; provide one or more message IDs separated by commas")
messageIDs, err := validateMessageIDs(runtime.Str("message-ids"))
if err != nil {
return err

Check warning on line 68 in shortcuts/mail/mail_messages.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/mail_messages.go#L68

Added line #L68 was not covered by tests
}
html := runtime.Bool("html")

Expand Down
92 changes: 92 additions & 0 deletions shortcuts/mail/mail_messages_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package mail

import (
"encoding/base64"
"encoding/json"
"fmt"
"reflect"
"strings"
"testing"

"github.com/larksuite/cli/internal/httpmock"
)

func TestMailMessagesExecuteChunksMoreThanTwentyIDs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 21)
for i := range ids {
ids[i] = base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("biz-%03d", i)))
}

reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[:20]),
Body: batchGetMessagesResponse(ids[:20]),
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/batch_get",
BodyFilter: requestMessageIDsEqual(ids[20:]),
Body: batchGetMessagesResponse(ids[20:]),
})

err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--message-ids", strings.Join(ids, ","),
}, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}

out := decodeShortcutEnvelopeData(t, stdout)
if got := int(out["total"].(float64)); got != len(ids) {
t.Fatalf("total = %d, want %d; stdout=%s", got, len(ids), stdout.String())
}
messages, ok := out["messages"].([]interface{})
if !ok {
t.Fatalf("messages has unexpected type %T", out["messages"])
}
if len(messages) != len(ids) {
t.Fatalf("messages length = %d, want %d", len(messages), len(ids))
}
for i, item := range messages {
msg, ok := item.(map[string]interface{})
if !ok {
t.Fatalf("messages[%d] has unexpected type %T", i, item)
}
if got := msg["message_id"]; got != ids[i] {
t.Fatalf("messages[%d].message_id = %v, want %s", i, got, ids[i])
}
}
}

func requestMessageIDsEqual(want []string) func([]byte) bool {
return func(body []byte) bool {
var payload struct {
MessageIDs []string `json:"message_ids"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return false
}
return reflect.DeepEqual(payload.MessageIDs, want)
}
}

func batchGetMessagesResponse(ids []string) map[string]interface{} {
messages := make([]map[string]interface{}, 0, len(ids))
for _, id := range ids {
messages = append(messages, map[string]interface{}{
"message_id": id,
"subject": id,
})
}
return map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"messages": messages,
},
}
}
89 changes: 87 additions & 2 deletions shortcuts/mail/mail_shortcut_validation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package mail

import (
"encoding/base64"
"os"
"strings"
"testing"
Expand Down Expand Up @@ -133,7 +134,7 @@ func TestMailMessageUserMailboxMePassesValidation(t *testing.T) {
func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--message-ids", "msg_xxx",
"+messages", "--as", "bot", "--message-ids", validMessageIDForTest("biz-x"),
}, f, stdout)
assertValidationError(t, err, "does not support --mailbox me")
}
Expand All @@ -142,7 +143,7 @@ func TestMailMessagesBotDefaultMailboxMeReturnsValidationError(t *testing.T) {
func TestMailMessagesBotExplicitMailboxPassesValidation(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, MailMessages, []string{
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", "msg_xxx",
"+messages", "--as", "bot", "--mailbox", "alice@example.com", "--message-ids", validMessageIDForTest("biz-x"),
}, f, stdout)
assertValidatePasses(t, err)
}
Expand Down Expand Up @@ -182,3 +183,87 @@ func TestMailTriageBotExplicitMailboxPassesValidation(t *testing.T) {
}, f, stdout)
assertValidatePasses(t, err)
}

// --- message_ids validation tests (S2) ---

func validMessageIDForTest(s string) string {
return base64.URLEncoding.EncodeToString([]byte(s))
}

func TestValidateMessageIDsAcceptsValidIDs(t *testing.T) {
_, err := validateMessageIDs(validMessageIDForTest("biz-001") + "," + validMessageIDForTest("biz-002"))
if err != nil {
t.Fatalf("expected nil error for valid IDs, got: %v", err)
}
}

func TestValidateMessageIDsRejectsEmpty(t *testing.T) {
_, err := validateMessageIDs("")
assertValidationError(t, err, "--message-ids is required")
_, err = validateMessageIDs(" ")
assertValidationError(t, err, "--message-ids is required")
}

func TestValidateMessageIDsAcceptsMoreThanSingleBackendBatch(t *testing.T) {
ids := make([]string, 21)
for i := range ids {
ids[i] = validMessageIDForTest(string(rune('a' + i)))
}
_, err := validateMessageIDs(strings.Join(ids, ","))
if err != nil {
t.Fatalf("expected nil error for more than one backend batch, got: %v", err)
}
}

func TestValidateMessageIDsRejectsEmptyEntry(t *testing.T) {
_, err := validateMessageIDs(validMessageIDForTest("biz-1") + ",," + validMessageIDForTest("biz-2"))
assertValidationError(t, err, "entry 2 is empty")
}

func TestValidateMessageIDsRejectsLeadingOrTrailingWhitespace(t *testing.T) {
id1 := validMessageIDForTest("biz-1")
id2 := validMessageIDForTest("biz-2")
_, err := validateMessageIDs(id1 + ", " + id2)
assertValidationError(t, err, "must not contain leading or trailing whitespace")
_, err = validateMessageIDs(" " + id1 + "," + id2)
assertValidationError(t, err, "must not contain leading or trailing whitespace")
}

func TestValidateMessageIDsRejectsDuplicateIDs(t *testing.T) {
id := validMessageIDForTest("biz-1")
_, err := validateMessageIDs(id + "," + id)
assertValidationError(t, err, "duplicate message ID is not allowed")
}

func TestValidateMessageIDsRejectsJSONLikeInput(t *testing.T) {
_, err := validateMessageIDs(`["id1","id2"]`)
assertValidationError(t, err, "expected a base64url")
}

func TestValidateMessageIDsRejectsColonJoinedInput(t *testing.T) {
_, err := validateMessageIDs("id1:id2")
assertValidationError(t, err, "expected a base64url")
}

func TestValidateMessageIDsRejectsNumericPrimaryID(t *testing.T) {
_, err := validateMessageIDs("123456789")
assertValidationError(t, err, "numeric primary IDs are not supported")
}

func TestValidateMessageIDsAcceptsExactlyTwenty(t *testing.T) {
ids := make([]string, 20)
for i := range ids {
ids[i] = validMessageIDForTest(string(rune('A' + i)))
}
_, err := validateMessageIDs(strings.Join(ids, ","))
if err != nil {
t.Fatalf("expected nil error for exactly 20 IDs, got: %v", err)
}
}

func TestValidateMessageIDRejectsInvalidBase64(t *testing.T) {
_, err := validateMessageIDs("msg 1")
assertValidationError(t, err, "expected a base64url")
_, err = validateMessageIDs("not-base64!")
assertValidationError(t, err, "expected a base64url")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading