bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
284 lines
8.7 KiB
Go
284 lines
8.7 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/larksuite/cli/internal/output"
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
)
|
|
|
|
const mailMessageManageBatchSize = 20
|
|
|
|
var messageManageSystemLabels = map[string]string{
|
|
"UNREAD": "UNREAD",
|
|
"IMPORTANT": "IMPORTANT",
|
|
"OTHER": "OTHER",
|
|
"FLAGGED": "FLAGGED",
|
|
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
|
|
}
|
|
|
|
var messageManageSystemFolders = map[string]string{
|
|
"INBOX": "INBOX",
|
|
"SENT": "SENT",
|
|
"SPAM": "SPAM",
|
|
"ARCHIVE": "ARCHIVED",
|
|
"ARCHIVED": "ARCHIVED",
|
|
}
|
|
|
|
type messageManageSummary struct {
|
|
SuccessMessageIDs []string `json:"success_message_ids"`
|
|
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
|
|
}
|
|
|
|
type messageManageFailure struct {
|
|
MessageID string `json:"message_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
|
|
type validationAPIPlan struct {
|
|
Method string `json:"method"`
|
|
Path string `json:"path"`
|
|
WillValidate bool `json:"will_validate"`
|
|
}
|
|
|
|
func normalizeMessageManageIDs(raw []string) ([]string, error) {
|
|
if len(raw) == 0 {
|
|
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
|
}
|
|
parts, err := splitMessageManageIDTokens(raw)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ids := make([]string, 0, len(parts))
|
|
seen := make(map[string]struct{}, len(parts))
|
|
for i, part := range parts {
|
|
if part == "" {
|
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
|
}
|
|
id := strings.TrimSpace(part)
|
|
if id == "" {
|
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
|
|
}
|
|
if id != part {
|
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
|
|
}
|
|
if err := validateMessageManageID(id, i); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
ids = append(ids, id)
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
|
|
}
|
|
return ids, nil
|
|
}
|
|
|
|
func splitMessageManageIDTokens(raw []string) ([]string, error) {
|
|
parts := make([]string, 0, len(raw))
|
|
for i, token := range raw {
|
|
for _, r := range token {
|
|
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
|
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
|
|
}
|
|
}
|
|
parts = append(parts, strings.Split(token, ",")...)
|
|
}
|
|
return parts, nil
|
|
}
|
|
|
|
func validateMessageManageID(id string, index int) error {
|
|
if len(id) < 16 {
|
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
|
|
}
|
|
if strings.Trim(id, "0123456789") == "" {
|
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
|
|
}
|
|
for _, r := range id {
|
|
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
|
|
}
|
|
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
|
continue
|
|
}
|
|
switch r {
|
|
case '+', '/', '=', '_', '-':
|
|
continue
|
|
default:
|
|
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
|
|
labels := make([]string, 0, len(raw))
|
|
custom := make([]string, 0, len(raw))
|
|
seen := make(map[string]struct{}, len(raw))
|
|
for i, part := range raw {
|
|
id := strings.TrimSpace(part)
|
|
if id == "" {
|
|
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
|
|
}
|
|
if id != part {
|
|
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
|
|
}
|
|
normalized := id
|
|
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
|
|
normalized = system
|
|
} else {
|
|
custom = append(custom, id)
|
|
}
|
|
if _, ok := seen[normalized]; ok {
|
|
continue
|
|
}
|
|
seen[normalized] = struct{}{}
|
|
labels = append(labels, normalized)
|
|
}
|
|
if len(labels) > 20 {
|
|
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
|
|
}
|
|
return labels, custom, nil
|
|
}
|
|
|
|
func validateLabelIntersection(add, remove []string) error {
|
|
removeSet := make(map[string]struct{}, len(remove))
|
|
for _, id := range remove {
|
|
removeSet[id] = struct{}{}
|
|
}
|
|
for _, id := range add {
|
|
if _, ok := removeSet[id]; ok {
|
|
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeMessageManageFolder(raw string) (string, bool, error) {
|
|
if raw == "" {
|
|
return "", false, nil
|
|
}
|
|
folder := strings.TrimSpace(raw)
|
|
if folder == "" {
|
|
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
|
|
}
|
|
if folder != raw {
|
|
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
|
|
}
|
|
if strings.EqualFold(folder, "TRASH") {
|
|
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
|
|
}
|
|
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
|
|
return system, false, nil
|
|
}
|
|
return folder, true, nil
|
|
}
|
|
|
|
func chunkMessageManageIDs(ids []string) [][]string {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
|
|
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
|
|
end := start + mailMessageManageBatchSize
|
|
if end > len(ids) {
|
|
end = len(ids)
|
|
}
|
|
chunks = append(chunks, ids[start:end])
|
|
}
|
|
return chunks
|
|
}
|
|
|
|
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
if err := validateLabelReadScope(rt); err != nil {
|
|
return err
|
|
}
|
|
seen := map[string]struct{}{}
|
|
for _, id := range ids {
|
|
if _, ok := seen[id]; ok {
|
|
continue
|
|
}
|
|
seen[id] = struct{}{}
|
|
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
|
|
return mailDecorateProblemMessage(err, "label not found: %s", id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
|
|
if id == "" {
|
|
return nil
|
|
}
|
|
if err := validateFolderReadScope(rt); err != nil {
|
|
return err
|
|
}
|
|
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
|
|
return mailDecorateProblemMessage(err, "folder not found: %s", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
|
|
body := map[string]interface{}{"message_ids": ids}
|
|
if len(addLabels) > 0 {
|
|
body["add_label_ids"] = addLabels
|
|
}
|
|
if len(removeLabels) > 0 {
|
|
body["remove_label_ids"] = removeLabels
|
|
}
|
|
if addFolder != "" {
|
|
body["add_folder"] = addFolder
|
|
}
|
|
return body
|
|
}
|
|
|
|
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
|
|
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
|
|
seenLabels := map[string]struct{}{}
|
|
for _, id := range customLabels {
|
|
if _, ok := seenLabels[id]; ok {
|
|
continue
|
|
}
|
|
seenLabels[id] = struct{}{}
|
|
plans = append(plans, validationAPIPlan{
|
|
Method: "GET",
|
|
Path: mailboxPath(mailboxID, "labels", id),
|
|
WillValidate: true,
|
|
})
|
|
}
|
|
if customFolder != "" {
|
|
plans = append(plans, validationAPIPlan{
|
|
Method: "GET",
|
|
Path: mailboxPath(mailboxID, "folders", customFolder),
|
|
WillValidate: true,
|
|
})
|
|
}
|
|
return plans
|
|
}
|
|
|
|
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
|
|
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
|
|
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
|
|
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
|
|
if noAPICalls {
|
|
fmt.Fprintln(w, "No changes requested; no API calls were made.")
|
|
}
|
|
for _, item := range summary.FailedMessageIDs {
|
|
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
|
|
}
|
|
})
|
|
}
|