mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +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
232 lines
7.1 KiB
Go
232 lines
7.1 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package mail
|
|
|
|
import (
|
|
"testing"
|
|
|
|
"github.com/larksuite/cli/shortcuts/common"
|
|
draftpkg "github.com/larksuite/cli/shortcuts/mail/draft"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// newDraftEditRuntime creates a minimal RuntimeContext with the draft-edit
|
|
// flags used by buildDraftEditPatch.
|
|
func newDraftEditRuntime(flags map[string]string) *common.RuntimeContext {
|
|
cmd := &cobra.Command{Use: "test"}
|
|
for _, name := range []string{
|
|
"set-subject", "set-to", "set-cc", "set-bcc",
|
|
"set-priority", "patch-file",
|
|
"set-event-summary", "set-event-start", "set-event-end", "set-event-location",
|
|
} {
|
|
cmd.Flags().String(name, "", "")
|
|
}
|
|
cmd.Flags().Bool("remove-event", false, "")
|
|
for name, val := range flags {
|
|
_ = cmd.Flags().Set(name, val)
|
|
}
|
|
return &common.RuntimeContext{Cmd: cmd}
|
|
}
|
|
|
|
func TestBuildDraftEditPatch_SetPriorityHigh(t *testing.T) {
|
|
rt := newDraftEditRuntime(map[string]string{"set-priority": "high"})
|
|
patch, err := buildDraftEditPatch(rt)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(patch.Ops) != 1 {
|
|
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
|
|
}
|
|
op := patch.Ops[0]
|
|
if op.Op != "set_header" {
|
|
t.Errorf("Op = %q, want set_header", op.Op)
|
|
}
|
|
if op.Name != "X-Cli-Priority" {
|
|
t.Errorf("Name = %q, want X-Cli-Priority", op.Name)
|
|
}
|
|
if op.Value != "1" {
|
|
t.Errorf("Value = %q, want 1", op.Value)
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftEditPatch_SetPriorityLow(t *testing.T) {
|
|
rt := newDraftEditRuntime(map[string]string{"set-priority": "low"})
|
|
patch, err := buildDraftEditPatch(rt)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(patch.Ops) != 1 || patch.Ops[0].Value != "5" {
|
|
t.Fatalf("expected single set_header with value 5, got %+v", patch.Ops)
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftEditPatch_SetPriorityNormalClears(t *testing.T) {
|
|
rt := newDraftEditRuntime(map[string]string{"set-priority": "normal"})
|
|
patch, err := buildDraftEditPatch(rt)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(patch.Ops) != 1 {
|
|
t.Fatalf("expected 1 op, got %d", len(patch.Ops))
|
|
}
|
|
if patch.Ops[0].Op != "remove_header" || patch.Ops[0].Name != "X-Cli-Priority" {
|
|
t.Errorf("expected remove_header X-Cli-Priority, got %+v", patch.Ops[0])
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftEditPatch_InvalidPriority(t *testing.T) {
|
|
rt := newDraftEditRuntime(map[string]string{"set-priority": "urgent"})
|
|
if _, err := buildDraftEditPatch(rt); err == nil {
|
|
t.Fatal("expected error for invalid --set-priority value")
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftEditPatch_NoPriority(t *testing.T) {
|
|
rt := newDraftEditRuntime(map[string]string{"set-subject": "hello"})
|
|
patch, err := buildDraftEditPatch(rt)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
// Only the set_subject op should be present; no priority op injected.
|
|
if len(patch.Ops) != 1 || patch.Ops[0].Op != "set_subject" {
|
|
t.Errorf("expected single set_subject op, got %+v", patch.Ops)
|
|
}
|
|
}
|
|
|
|
func TestPrettyDraftAddresses(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
addrs []draftpkg.Address
|
|
want string
|
|
}{
|
|
{"empty", nil, ""},
|
|
{"single address only", []draftpkg.Address{{Address: "a@b.com"}}, "a@b.com"},
|
|
{"single with name", []draftpkg.Address{{Name: "Alice", Address: "a@b.com"}}, `"Alice" <a@b.com>`},
|
|
{"multiple", []draftpkg.Address{
|
|
{Address: "a@b.com"},
|
|
{Name: "Bob", Address: "b@c.com"},
|
|
}, `a@b.com, "Bob" <b@c.com>`},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := prettyDraftAddresses(tt.addrs)
|
|
if got != tt.want {
|
|
t.Errorf("prettyDraftAddresses() = %q, want %q", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftEditPatch_SetEventEmitsSetCalendarOp(t *testing.T) {
|
|
rt := newDraftEditRuntime(map[string]string{
|
|
"set-event-summary": "Team Sync",
|
|
"set-event-start": "2026-05-10T10:00:00+08:00",
|
|
"set-event-end": "2026-05-10T11:00:00+08:00",
|
|
"set-event-location": "Room 301",
|
|
})
|
|
patch, err := buildDraftEditPatch(rt)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(patch.Ops) != 1 {
|
|
t.Fatalf("expected 1 op, got %d: %+v", len(patch.Ops), patch.Ops)
|
|
}
|
|
op := patch.Ops[0]
|
|
if op.Op != "set_calendar" {
|
|
t.Errorf("Op = %q, want set_calendar", op.Op)
|
|
}
|
|
if op.EventSummary != "Team Sync" {
|
|
t.Errorf("EventSummary = %q, want Team Sync", op.EventSummary)
|
|
}
|
|
if op.EventLocation != "Room 301" {
|
|
t.Errorf("EventLocation = %q, want Room 301", op.EventLocation)
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftEditPatch_RemoveEventEmitsRemoveCalendarOp(t *testing.T) {
|
|
rt := newDraftEditRuntime(map[string]string{
|
|
"remove-event": "true",
|
|
})
|
|
patch, err := buildDraftEditPatch(rt)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(patch.Ops) != 1 || patch.Ops[0].Op != "remove_calendar" {
|
|
t.Fatalf("expected single remove_calendar op, got %+v", patch.Ops)
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftEditPatch_SetAndRemoveEventMutuallyExclusive(t *testing.T) {
|
|
rt := newDraftEditRuntime(map[string]string{
|
|
"set-event-summary": "Meeting",
|
|
"remove-event": "true",
|
|
})
|
|
_, err := buildDraftEditPatch(rt)
|
|
if err == nil {
|
|
t.Fatal("expected error for --set-event-summary + --remove-event, got nil")
|
|
}
|
|
}
|
|
|
|
func TestBuildDraftEditPatch_SetEventMissingStartEnd(t *testing.T) {
|
|
rt := newDraftEditRuntime(map[string]string{
|
|
"set-event-summary": "Meeting",
|
|
})
|
|
_, err := buildDraftEditPatch(rt)
|
|
if err == nil {
|
|
t.Fatal("expected error when --set-event-summary set without start/end, got nil")
|
|
}
|
|
}
|
|
|
|
func TestEffectiveRecipients_SetReplaces(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
To: []draftpkg.Address{{Address: "old@example.com"}},
|
|
Cc: []draftpkg.Address{{Address: "cc@example.com"}},
|
|
}
|
|
ops := []draftpkg.PatchOp{
|
|
{Op: "set_recipients", Field: "to", Addresses: []draftpkg.Address{{Address: "new@example.com"}}},
|
|
}
|
|
to, cc := effectiveRecipients(snapshot, ops)
|
|
if len(to) != 1 || to[0].Address != "new@example.com" {
|
|
t.Errorf("expected to=[new@example.com], got %v", to)
|
|
}
|
|
if len(cc) != 1 || cc[0].Address != "cc@example.com" {
|
|
t.Errorf("expected cc unchanged, got %v", cc)
|
|
}
|
|
}
|
|
|
|
func TestEffectiveRecipients_AddAndRemove(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
To: []draftpkg.Address{{Address: "alice@example.com"}, {Address: "bob@example.com"}},
|
|
}
|
|
ops := []draftpkg.PatchOp{
|
|
{Op: "add_recipient", Field: "to", Address: "carol@example.com"},
|
|
{Op: "remove_recipient", Field: "to", Address: "bob@example.com"},
|
|
}
|
|
to, _ := effectiveRecipients(snapshot, ops)
|
|
if len(to) != 2 {
|
|
t.Fatalf("expected 2 recipients, got %v", to)
|
|
}
|
|
addrs := map[string]bool{}
|
|
for _, a := range to {
|
|
addrs[a.Address] = true
|
|
}
|
|
if !addrs["alice@example.com"] || !addrs["carol@example.com"] || addrs["bob@example.com"] {
|
|
t.Errorf("unexpected recipient set: %v", to)
|
|
}
|
|
}
|
|
|
|
func TestEffectiveRecipients_NoOpsReturnsCopy(t *testing.T) {
|
|
snapshot := &draftpkg.DraftSnapshot{
|
|
To: []draftpkg.Address{{Address: "alice@example.com"}},
|
|
Cc: []draftpkg.Address{{Address: "bob@example.com"}},
|
|
}
|
|
to, cc := effectiveRecipients(snapshot, nil)
|
|
if len(to) != 1 || to[0].Address != "alice@example.com" {
|
|
t.Errorf("unexpected to: %v", to)
|
|
}
|
|
if len(cc) != 1 || cc[0].Address != "bob@example.com" {
|
|
t.Errorf("unexpected cc: %v", cc)
|
|
}
|
|
}
|