Skip to content
Merged
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
5 changes: 5 additions & 0 deletions shortcuts/mail/draft/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ type DraftRaw struct {
RawEML string
}

type DraftResult struct {
DraftID string
Reference string
}

type Header struct {
Name string
Value string
Expand Down
40 changes: 33 additions & 7 deletions shortcuts/mail/draft/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,21 +42,34 @@
}, nil
}

func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (string, error) {
func CreateWithRaw(runtime *common.RuntimeContext, mailboxID, rawEML string) (DraftResult, error) {
data, err := runtime.CallAPI("POST", mailboxPath(mailboxID, "drafts"), nil, map[string]interface{}{"raw": rawEML})
Comment thread
qiooo marked this conversation as resolved.
if err != nil {
return "", err
return DraftResult{}, err

Check warning on line 48 in shortcuts/mail/draft/service.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/draft/service.go#L48

Added line #L48 was not covered by tests
}
draftID := extractDraftID(data)
if draftID == "" {
return "", fmt.Errorf("API response missing draft_id")
return DraftResult{}, fmt.Errorf("API response missing draft_id")

Check warning on line 52 in shortcuts/mail/draft/service.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/draft/service.go#L52

Added line #L52 was not covered by tests
}
return draftID, nil
return DraftResult{
DraftID: draftID,
Reference: extractReference(data),
}, nil
}

func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML string) error {
_, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
return err
func UpdateWithRaw(runtime *common.RuntimeContext, mailboxID, draftID, rawEML string) (DraftResult, error) {
data, err := runtime.CallAPI("PUT", mailboxPath(mailboxID, "drafts", draftID), nil, map[string]interface{}{"raw": rawEML})
if err != nil {
return DraftResult{}, err

Check warning on line 63 in shortcuts/mail/draft/service.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/draft/service.go#L63

Added line #L63 was not covered by tests
}
gotDraftID := extractDraftID(data)
if gotDraftID == "" {
gotDraftID = draftID
}
return DraftResult{
DraftID: gotDraftID,
Reference: extractReference(data),
}, nil
}

func Send(runtime *common.RuntimeContext, mailboxID, draftID, sendTime string) (map[string]interface{}, error) {
Expand Down Expand Up @@ -94,3 +107,16 @@
}
return ""
}

func extractReference(data map[string]interface{}) string {
if data == nil {
return ""
}
if ref, ok := data["reference"].(string); ok && strings.TrimSpace(ref) != "" {
return strings.TrimSpace(ref)
}
if draft, ok := data["draft"].(map[string]interface{}); ok {
return extractReference(draft)
}
Comment thread
qiooo marked this conversation as resolved.
return ""

Check warning on line 121 in shortcuts/mail/draft/service.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/mail/draft/service.go#L121

Added line #L121 was not covered by tests
}
133 changes: 133 additions & 0 deletions shortcuts/mail/draft/service_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package draft

import (
"context"
"testing"
"time"

"github.com/spf13/cobra"
"github.com/zalando/go-keyring"

"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)

func draftServiceTestRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) {
t.Helper()
keyring.MockInit()
t.Setenv("HOME", t.TempDir())

cfg := &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_testuser",
UserName: "Test User",
}
token := &auth.StoredUAToken{
UserOpenId: cfg.UserOpenId,
AppId: cfg.AppID,
AccessToken: "test-user-access-token",
RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
}
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
t.Cleanup(func() {
_ = auth.RemoveStoredToken(cfg.AppID, cfg.UserOpenId)
})

factory, _, _, reg := cmdutil.TestFactory(t, cfg)
runtime := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{Use: "test"}, cfg)
runtime.Factory = factory
return runtime, reg
}

func TestExtractReference(t *testing.T) {
t.Run("top-level reference", func(t *testing.T) {
data := map[string]interface{}{"reference": "https://example.com/draft/1"}
if got := extractReference(data); got != "https://example.com/draft/1" {
t.Fatalf("extractReference() = %q, want %q", got, "https://example.com/draft/1")
}
})

t.Run("nested draft reference", func(t *testing.T) {
data := map[string]interface{}{
"draft": map[string]interface{}{
"reference": "https://example.com/draft/2",
},
}
if got := extractReference(data); got != "https://example.com/draft/2" {
t.Fatalf("extractReference() = %q, want %q", got, "https://example.com/draft/2")
}
})

t.Run("missing reference", func(t *testing.T) {
if got := extractReference(nil); got != "" {
t.Fatalf("extractReference(nil) = %q, want empty string", got)
}
})
}

func TestCreateWithRawReturnsDraftResultWithReference(t *testing.T) {
runtime, reg := draftServiceTestRuntime(t)

reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/mail/v1/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",
},
},
})

got, err := CreateWithRaw(runtime, "me", "raw-eml")
if err != nil {
t.Fatalf("CreateWithRaw() error = %v", err)
}
if got.DraftID != "draft_001" {
t.Fatalf("DraftID = %q, want %q", got.DraftID, "draft_001")
}
if got.Reference != "https://www.feishu.cn/mail?draftId=draft_001" {
t.Fatalf("Reference = %q, want %q", got.Reference, "https://www.feishu.cn/mail?draftId=draft_001")
}
}

func TestUpdateWithRawFallsBackToInputDraftIDAndReturnsReference(t *testing.T) {
runtime, reg := draftServiceTestRuntime(t)

reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/mail/v1/user_mailboxes/me/drafts/draft_002",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"reference": "https://www.feishu.cn/mail?draftId=draft_002",
},
},
})

got, err := UpdateWithRaw(runtime, "me", "draft_002", "raw-eml")
if err != nil {
t.Fatalf("UpdateWithRaw() error = %v", err)
}
if got.DraftID != "draft_002" {
t.Fatalf("DraftID = %q, want fallback %q", got.DraftID, "draft_002")
}
if got.Reference != "https://www.feishu.cn/mail?draftId=draft_002" {
t.Fatalf("Reference = %q, want %q", got.Reference, "https://www.feishu.cn/mail?draftId=draft_002")
}
}
53 changes: 36 additions & 17 deletions shortcuts/mail/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,42 @@ func normalizeMessageID(id string) string {
return strings.TrimSpace(trimmed)
}

func buildDraftSendOutput(resData map[string]interface{}, mailboxID string) map[string]interface{} {
out := map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}
if recallStatus, ok := resData["recall_status"].(string); ok && recallStatus == "available" {
messageID, _ := resData["message_id"].(string)
out["recall_available"] = true
out["recall_tip"] = fmt.Sprintf(
`This message can be recalled within 24 hours. To recall: lark-cli mail user_mailbox.sent_messages recall --params '{"user_mailbox_id":"%s","message_id":"%s"}'`,
mailboxID, messageID)
}
if automationDisable, ok := resData["automation_send_disable"]; ok {
if automation, ok := automationDisable.(map[string]interface{}); ok {
if reason, ok := automation["reason"].(string); ok && strings.TrimSpace(reason) != "" {
out["automation_send_disable_reason"] = strings.TrimSpace(reason)
}
if reference, ok := automation["reference"].(string); ok && strings.TrimSpace(reference) != "" {
out["automation_send_disable_reference"] = strings.TrimSpace(reference)
}
}
}
return out
}

func buildDraftSavedOutput(draftResult draftpkg.DraftResult, mailboxID string) map[string]interface{} {
out := map[string]interface{}{
"draft_id": draftResult.DraftID,
"tip": fmt.Sprintf(`draft saved. To send: lark-cli mail user_mailbox.drafts send --params '{"user_mailbox_id":"%s","draft_id":"%s"}'`, mailboxID, draftResult.DraftID),
}
if draftResult.Reference != "" {
out["reference"] = draftResult.Reference
}
return out
}

func normalizeInlineCID(cid string) string {
trimmed := strings.TrimSpace(cid)
if len(trimmed) >= 4 && strings.EqualFold(trimmed[:4], "cid:") {
Expand Down Expand Up @@ -2009,23 +2045,6 @@ func validateConfirmSendScope(runtime *common.RuntimeContext) error {
return nil
}

// buildSendResult builds the output map for a successful send, including
// recall tip if the backend indicates the message is recallable.
func buildSendResult(resData map[string]interface{}, mailboxID string) map[string]interface{} {
result := map[string]interface{}{
"message_id": resData["message_id"],
"thread_id": resData["thread_id"],
}
if recallStatus, ok := resData["recall_status"].(string); ok && recallStatus == "available" {
messageID, _ := resData["message_id"].(string)
result["recall_available"] = true
result["recall_tip"] = fmt.Sprintf(
`This message can be recalled within 24 hours. To recall: lark-cli mail user_mailbox.sent_messages recall --params '{"user_mailbox_id":"%s","message_id":"%s"}'`,
mailboxID, messageID)
}
return result
}

// validateFolderReadScope checks that the user's token includes the
// mail:user_mailbox.folder:read scope. Called on-demand by listMailboxFolders
// before hitting the folders API. System folders are resolved locally and
Expand Down
14 changes: 11 additions & 3 deletions shortcuts/mail/mail_draft_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,22 @@ var MailDraftCreate = common.Shortcut{
if err != nil {
return err
}
draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
draftResult, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML)
if err != nil {
return fmt.Errorf("create draft failed: %w", err)
}
out := map[string]interface{}{"draft_id": draftID}
out := map[string]interface{}{"draft_id": draftResult.DraftID}
if draftResult.Reference != "" {
out["reference"] = draftResult.Reference
}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintln(w, "Draft created.")
fmt.Fprintf(w, "draft_id: %s\n", draftID)
// Intentionally keep +draft-create output minimal: unlike reply/forward/send
// draft-save flows, it does not add a follow-up send tip.
fmt.Fprintf(w, "draft_id: %s\n", draftResult.DraftID)
if reference, _ := out["reference"].(string); reference != "" {
fmt.Fprintf(w, "reference: %s\n", reference)
}
})
return nil
},
Expand Down
48 changes: 48 additions & 0 deletions shortcuts/mail/mail_draft_create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -198,3 +199,50 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) {
t.Fatal("plain-text mode should not resolve local images")
}
}

func TestMailDraftCreatePrettyOutputsReference(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)

reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/user_mailboxes/me/profile",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"primary_email_address": "me@example.com",
},
},
})
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",
},
},
})

err := runMountedMailShortcut(t, MailDraftCreate, []string{
"+draft-create",
"--subject", "hello",
"--body", "world",
"--format", "pretty",
}, f, stdout)
if err != nil {
t.Fatalf("draft create failed: %v", err)
}

out := stdout.String()
if !strings.Contains(out, "Draft created.") {
t.Fatalf("expected pretty output header, got: %s", out)
}
if !strings.Contains(out, "draft_id: draft_001") {
t.Fatalf("expected draft_id in pretty output, got: %s", out)
}
if !strings.Contains(out, "reference: https://www.feishu.cn/mail?draftId=draft_001") {
t.Fatalf("expected reference in pretty output, got: %s", out)
}
}
13 changes: 10 additions & 3 deletions shortcuts/mail/mail_draft_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,18 +119,25 @@ var MailDraftEdit = common.Shortcut{
if err != nil {
return output.ErrValidation("serialize draft failed: %v", err)
}
if err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized); err != nil {
updateResult, err := draftpkg.UpdateWithRaw(runtime, mailboxID, draftID, serialized)
if err != nil {
return fmt.Errorf("update draft failed: %w", err)
}
projection := draftpkg.Project(snapshot)
out := map[string]interface{}{
"draft_id": draftID,
"draft_id": updateResult.DraftID,
"warning": "This edit flow has no optimistic locking. If the same draft is changed concurrently, the last writer wins.",
"projection": projection,
}
if updateResult.Reference != "" {
out["reference"] = updateResult.Reference
}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintln(w, "Draft updated.")
fmt.Fprintf(w, "draft_id: %s\n", draftID)
fmt.Fprintf(w, "draft_id: %s\n", updateResult.DraftID)
if reference, _ := out["reference"].(string); reference != "" {
fmt.Fprintf(w, "reference: %s\n", reference)
}
if projection.Subject != "" {
fmt.Fprintf(w, "subject: %s\n", sanitizeForTerminal(projection.Subject))
}
Expand Down
Loading
Loading