mirror of
https://github.com/larksuite/cli.git
synced 2026-07-09 02:14:02 +08:00
* feat(ics): add RFC 5545 iCalendar generator and parser Add shortcuts/mail/ics package: - builder.go: generates METHOD:REQUEST ICS with VEVENT, ORGANIZER, ATTENDEE, DTSTART/DTEND with timezone, UID, and X-LARK-MAIL-DRAFT - parser.go: parses ICS into ParsedEvent struct, detects IsLarkDraft - Handles CN quoting, control-char sanitization, email validation, line folding per RFC 5545, and TZID edge cases Change-Id: I01d13285a57a5a4de50891c54d655efa8423c3c1 * feat(mail): support calendar events in emails - Add --event-summary/start/end/location flags to +send, +reply, +reply-all, +forward, +draft-create - Build ICS and embed as text/calendar in multipart/alternative - Validate event time range and enforce --event/--send-time mutual exclusion (extracted into validateEventSendTimeExclusion) - CalendarBody() in emlbuilder places ICS correctly - Exclude BCC from ATTENDEE list Change-Id: Icf9e49ababebc4e8fcf36760ab613c64938c2744 * feat(mail): X-LARK-MAIL-DRAFT and read-only calendar guard - ics.Build() writes X-LARK-MAIL-DRAFT:TRUE so Feishu client recognizes CLI-created calendar events as editable - ics.ParseEvent() detects IsLarkDraft field - +draft-edit rejects --set-event-* on calendars without X-LARK-MAIL-DRAFT marker (read-only after send) - Export FindPartByMediaType from draft package for cross-package use - Add set_calendar/remove_calendar patch ops with full test coverage Change-Id: I7d547a4b40880e8d4ee3fecf68864d6ea89e66cd * feat(mail): forward preserves original calendar ICS When forwarding an email that contains a calendar event (body_calendar), pass through the original ICS bytes as text/calendar part if no new --event-* flags are specified. Change-Id: I67d2e82604eaf969cee8c7e0bedcf32198d12d57 * docs(mail): document calendar invitation feature - Add --event-* params to +send, +reply, +reply-all, +forward, +draft-create, +draft-edit reference docs - Add calendar_event output section to +message reference - Add calendar invitation workflow to skill-template/domains/mail.md - Regenerate SKILL.md via gen-skills Change-Id: Iccacd06990d91e1cf3beb896d5b772d27e5e29ff * fix(mail): reject --set-event-start/end/location without --set-event-summary Change-Id: Icb651ff28ede526ff96b22e7b304b7bdea86d01f Co-Authored-By: AI * fix(mail): include --event-location in validateEventFlags; fix stale comment Change-Id: I2f47016b6bfa11957dfe2c8c499cf36737efba53 Co-Authored-By: AI * fix(mail): clear stale headers when wrapping single-leaf body in multipart/alternative Change-Id: I29fe883c9151570f7939d372523b128cbea0b1ed Co-Authored-By: AI * fix(mail): add method=REQUEST to text/calendar MIME part created by set_calendar Change-Id: I4d23674e20e4c42adab36385ff5ee8bb6d97625d Co-Authored-By: AI * fix(mail): use post-edit recipients for ICS attendees when --set-to combined with --set-event-* Change-Id: I659e06635dd043f798d2f2e90d7dbca6e13d7f3d Co-Authored-By: AI * fix(mail): cover add_recipient/remove_recipient in ICS attendee resolution Extract effectiveRecipients() to replay all three recipient op types (set_recipients, add_recipient, remove_recipient) before building the ICS for set_calendar, so patch-file recipient changes are reflected in ATTENDEE metadata. Change-Id: I3a7a55f96df8fac7d924a4dbeedd5b3d0d9d443c Co-Authored-By: AI * fix(mail): derive method= from ICS body in writeCalendarPart instead of hardcoding REQUEST Passthrough ICS (e.g. forwarded METHOD:CANCEL) previously emitted a Content-Type with method=REQUEST, disagreeing with the body. Now extractICSMethod() scans the ICS for METHOD: and falls back to REQUEST when absent, keeping existing behavior for our own generated ICS. Change-Id: I4bf6c3755a189a436c2d26b082372d9f838f4051 Co-Authored-By: AI * fix(mail): normalize calendar_event start/end to UTC in output Callers expect RFC 3339 UTC strings; source ICS with TZID offsets previously emitted +08:00 instead of Z. Change-Id: I88bd4b925f8fc3b4f569e41712ae58ab50d94a2f Co-Authored-By: AI * fix(mail): make ICS parser case-insensitive and handle parameterized property names RFC 5545 §3.1 allows any case and optional parameters on all property names. Unify UID/SUMMARY/LOCATION/DTSTART/etc. to compare via strings.ToUpper(name) and add HasPrefix checks for the NAME; form, consistent with how ORGANIZER and ATTENDEE were already handled. Change-Id: I7dc642dd210a3256f2189a901a2d9518ea284815 Co-Authored-By: AI
271 lines
8.2 KiB
Go
271 lines
8.2 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/internal/httpmock"
|
|
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
|
)
|
|
|
|
func TestBuildDraftSendOutputIncludesOptionalFields(t *testing.T) {
|
|
got := buildDraftSendOutput(map[string]interface{}{
|
|
"message_id": "msg_001",
|
|
"thread_id": "thread_001",
|
|
"recall_status": "available",
|
|
"automation_send_disable": map[string]interface{}{
|
|
"reason": "Automation send is disabled by your mailbox setting",
|
|
"reference": "https://open.larksuite.com/mail/settings/automation",
|
|
},
|
|
}, "me")
|
|
|
|
if got["message_id"] != "msg_001" {
|
|
t.Fatalf("message_id = %v", got["message_id"])
|
|
}
|
|
if got["thread_id"] != "thread_001" {
|
|
t.Fatalf("thread_id = %v", got["thread_id"])
|
|
}
|
|
if _, ok := got["recall_status"]; ok {
|
|
t.Fatalf("recall_status should be omitted, got %#v", got["recall_status"])
|
|
}
|
|
if got["recall_available"] != true {
|
|
t.Fatalf("recall_available = %v", got["recall_available"])
|
|
}
|
|
if got["recall_tip"] == "" {
|
|
t.Fatalf("recall_tip should be populated")
|
|
}
|
|
if _, ok := got["automation_send_disable"]; ok {
|
|
t.Fatalf("automation_send_disable should be omitted, got %#v", got["automation_send_disable"])
|
|
}
|
|
if got["automation_send_disable_reason"] != "Automation send is disabled by your mailbox setting" {
|
|
t.Fatalf("automation_send_disable_reason = %v", got["automation_send_disable_reason"])
|
|
}
|
|
if got["automation_send_disable_reference"] != "https://open.larksuite.com/mail/settings/automation" {
|
|
t.Fatalf("automation_send_disable_reference = %v", got["automation_send_disable_reference"])
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftSendOutputOmitsOptionalFieldsWhenUnavailable(t *testing.T) {
|
|
got := buildDraftSendOutput(map[string]interface{}{
|
|
"message_id": "msg_002",
|
|
"thread_id": "thread_002",
|
|
}, "me")
|
|
|
|
if got["message_id"] != "msg_002" {
|
|
t.Fatalf("message_id = %v", got["message_id"])
|
|
}
|
|
if got["thread_id"] != "thread_002" {
|
|
t.Fatalf("thread_id = %v", got["thread_id"])
|
|
}
|
|
if _, ok := got["recall_available"]; ok {
|
|
t.Fatalf("recall_available should be omitted, got %#v", got["recall_available"])
|
|
}
|
|
if _, ok := got["recall_tip"]; ok {
|
|
t.Fatalf("recall_tip should be omitted, got %#v", got["recall_tip"])
|
|
}
|
|
if _, ok := got["automation_send_disable_reason"]; ok {
|
|
t.Fatalf("automation_send_disable_reason should be omitted, got %#v", got["automation_send_disable_reason"])
|
|
}
|
|
if _, ok := got["automation_send_disable_reference"]; ok {
|
|
t.Fatalf("automation_send_disable_reference should be omitted, got %#v", got["automation_send_disable_reference"])
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftSavedOutputIncludesReferenceOnlyWhenPresent(t *testing.T) {
|
|
withReference := buildDraftSavedOutput(draftpkg.DraftResult{
|
|
DraftID: "draft_001",
|
|
Reference: "https://www.feishu.cn/mail?draftId=draft_001",
|
|
}, "me")
|
|
if withReference["draft_id"] != "draft_001" {
|
|
t.Fatalf("draft_id = %v", withReference["draft_id"])
|
|
}
|
|
if withReference["reference"] != "https://www.feishu.cn/mail?draftId=draft_001" {
|
|
t.Fatalf("reference = %v", withReference["reference"])
|
|
}
|
|
if withReference["tip"] == "" {
|
|
t.Fatalf("tip should be populated")
|
|
}
|
|
|
|
withoutReference := buildDraftSavedOutput(draftpkg.DraftResult{
|
|
DraftID: "draft_002",
|
|
}, "me")
|
|
if withoutReference["draft_id"] != "draft_002" {
|
|
t.Fatalf("draft_id = %v", withoutReference["draft_id"])
|
|
}
|
|
if _, ok := withoutReference["reference"]; ok {
|
|
t.Fatalf("reference should be omitted, got %#v", withoutReference["reference"])
|
|
}
|
|
if withoutReference["tip"] == "" {
|
|
t.Fatalf("tip should be populated")
|
|
}
|
|
}
|
|
|
|
func TestMailSendConfirmSendOutputsAutomationDisable(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(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",
|
|
},
|
|
},
|
|
})
|
|
reg.Register(&httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/user_mailboxes/me/drafts/draft_001/send",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{
|
|
"message_id": "msg_001",
|
|
"thread_id": "thread_001",
|
|
"automation_send_disable": map[string]interface{}{
|
|
"reason": "Automation send is disabled by your mailbox setting",
|
|
"reference": "https://open.larksuite.com/mail/settings/automation",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
err := runMountedMailShortcut(t, MailSend, []string{
|
|
"+send",
|
|
"--to", "alice@example.com",
|
|
"--subject", "hello",
|
|
"--body", "world",
|
|
"--confirm-send",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("send failed: %v", err)
|
|
}
|
|
|
|
data := decodeShortcutEnvelopeData(t, stdout)
|
|
if data["message_id"] != "msg_001" {
|
|
t.Fatalf("message_id = %v", data["message_id"])
|
|
}
|
|
if data["thread_id"] != "thread_001" {
|
|
t.Fatalf("thread_id = %v", data["thread_id"])
|
|
}
|
|
if _, ok := data["automation_send_disable"]; ok {
|
|
t.Fatalf("automation_send_disable should be omitted, got %#v", data["automation_send_disable"])
|
|
}
|
|
if data["automation_send_disable_reason"] != "Automation send is disabled by your mailbox setting" {
|
|
t.Fatalf("automation_send_disable_reason = %v", data["automation_send_disable_reason"])
|
|
}
|
|
if data["automation_send_disable_reference"] != "https://open.larksuite.com/mail/settings/automation" {
|
|
t.Fatalf("automation_send_disable_reference = %v", data["automation_send_disable_reference"])
|
|
}
|
|
}
|
|
|
|
func TestMailSendSaveDraftOutputsReference(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(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, MailSend, []string{
|
|
"+send",
|
|
"--to", "alice@example.com",
|
|
"--subject", "hello",
|
|
"--body", "world",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("save draft failed: %v", err)
|
|
}
|
|
|
|
data := decodeShortcutEnvelopeData(t, stdout)
|
|
if data["draft_id"] != "draft_001" {
|
|
t.Fatalf("draft_id = %v", data["draft_id"])
|
|
}
|
|
if data["reference"] != "https://www.feishu.cn/mail?draftId=draft_001" {
|
|
t.Fatalf("reference = %v", data["reference"])
|
|
}
|
|
}
|
|
|
|
func TestMailSend_WithCalendarEventEmbedded(t *testing.T) {
|
|
f, stdout, _, reg := mailShortcutTestFactoryWithSendScope(t)
|
|
|
|
draftsStub := &httpmock.Stub{
|
|
Method: "POST",
|
|
URL: "/user_mailboxes/me/drafts",
|
|
Body: map[string]interface{}{
|
|
"code": 0,
|
|
"data": map[string]interface{}{"draft_id": "draft_cal_001"},
|
|
},
|
|
}
|
|
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(draftsStub)
|
|
|
|
err := runMountedMailShortcut(t, MailSend, []string{
|
|
"+send",
|
|
"--to", "alice@example.com",
|
|
"--subject", "Team Sync",
|
|
"--body", "<p>Please join us</p>",
|
|
"--event-summary", "Team Sync",
|
|
"--event-start", "2026-05-10T10:00+08:00",
|
|
"--event-end", "2026-05-10T11:00+08:00",
|
|
}, f, stdout)
|
|
if err != nil {
|
|
t.Fatalf("mail send with calendar failed: %v", err)
|
|
}
|
|
|
|
var reqBody map[string]interface{}
|
|
if err := json.Unmarshal(draftsStub.CapturedBody, &reqBody); err != nil {
|
|
t.Fatalf("unmarshal captured body: %v", err)
|
|
}
|
|
raw, _ := reqBody["raw"].(string)
|
|
decoded, decErr := base64.URLEncoding.DecodeString(raw)
|
|
if decErr != nil {
|
|
t.Fatalf("base64url decode: %v", decErr)
|
|
}
|
|
eml := string(decoded)
|
|
if !strings.Contains(eml, "text/calendar") {
|
|
t.Errorf("expected text/calendar in EML:\n%s", eml)
|
|
}
|
|
if !strings.Contains(eml, "method=REQUEST") {
|
|
t.Errorf("expected method=REQUEST in Content-Type:\n%s", eml)
|
|
}
|
|
}
|