mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 08:12:36 +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
430 lines
13 KiB
Go
430 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package draft
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const fixtureCalData = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// set_calendar — validate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSetCalendar_ValidateRequiresSummary(t *testing.T) {
|
|
err := PatchOp{Op: "set_calendar", EventStart: "2026-04-25T10:00+08:00", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "event_summary") {
|
|
t.Errorf("expected event_summary error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetCalendar_ValidateRequiresStartAndEnd(t *testing.T) {
|
|
err := PatchOp{Op: "set_calendar", EventSummary: "Meeting", EventStart: "2026-04-25T10:00+08:00"}.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "event_start and event_end") {
|
|
t.Errorf("expected start/end error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetCalendar_ValidateInvalidStartFormat(t *testing.T) {
|
|
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "not-a-date", EventEnd: "2026-04-25T11:00+08:00"}.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "event_start") {
|
|
t.Errorf("expected event_start error for bad format, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetCalendar_ValidateInvalidEndFormat(t *testing.T) {
|
|
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T10:00+08:00", EventEnd: "not-a-date"}.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "event_end") {
|
|
t.Errorf("expected event_end error for bad format, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetCalendar_ValidateEndNotAfterStart(t *testing.T) {
|
|
err := PatchOp{Op: "set_calendar", EventSummary: "M", EventStart: "2026-04-25T11:00+08:00", EventEnd: "2026-04-25T10:00+08:00"}.Validate()
|
|
if err == nil || !strings.Contains(err.Error(), "after") {
|
|
t.Errorf("expected end-after-start error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSetCalendar_ValidateOK(t *testing.T) {
|
|
err := PatchOp{
|
|
Op: "set_calendar",
|
|
EventSummary: "Meeting",
|
|
EventStart: "2026-04-25T10:00+08:00",
|
|
EventEnd: "2026-04-25T11:00+08:00",
|
|
}.Validate()
|
|
if err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// set_calendar — Apply adds text/calendar part when none exists
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSetCalendar_AddsCalendarPartToHTMLDraft(t *testing.T) {
|
|
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
|
From: Alice <alice@example.com>
|
|
To: Bob <bob@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: text/html; charset=UTF-8
|
|
|
|
<p>Hello</p>`)
|
|
|
|
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
|
Ops: []PatchOp{{
|
|
Op: "set_calendar",
|
|
EventSummary: "Meeting",
|
|
EventStart: "2026-04-25T10:00+08:00",
|
|
EventEnd: "2026-04-25T11:00+08:00",
|
|
CalendarICS: []byte(fixtureCalData),
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
|
|
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
|
|
if part == nil {
|
|
t.Fatal("text/calendar part not added to draft")
|
|
}
|
|
if string(part.Body) != fixtureCalData {
|
|
t.Errorf("calendar part body mismatch: got %q", part.Body)
|
|
}
|
|
if part.MediaParams["method"] != "REQUEST" {
|
|
t.Errorf("calendar part missing method=REQUEST in MediaParams: %v", part.MediaParams)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// set_calendar — Apply replaces existing text/calendar part
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSetCalendar_ReplacesExistingCalendarPart(t *testing.T) {
|
|
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
|
From: Alice <alice@example.com>
|
|
To: Bob <bob@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: multipart/alternative; boundary="b1"
|
|
|
|
--b1
|
|
Content-Type: text/html; charset=UTF-8
|
|
|
|
<p>Hello</p>
|
|
--b1
|
|
Content-Type: text/calendar; charset=UTF-8
|
|
|
|
BEGIN:VCALENDAR
|
|
VERSION:2.0
|
|
SUMMARY:OLD
|
|
END:VCALENDAR
|
|
--b1--`)
|
|
|
|
newICS := []byte("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
|
|
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
|
Ops: []PatchOp{{
|
|
Op: "set_calendar",
|
|
EventSummary: "NEW",
|
|
EventStart: "2026-04-25T10:00+08:00",
|
|
EventEnd: "2026-04-25T11:00+08:00",
|
|
CalendarICS: newICS,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
|
|
part := FindPartByMediaType(snapshot.Body, calendarMediaType)
|
|
if part == nil {
|
|
t.Fatal("text/calendar part missing")
|
|
}
|
|
if !strings.Contains(string(part.Body), "SUMMARY:NEW") {
|
|
t.Errorf("expected new SUMMARY, got %q", part.Body)
|
|
}
|
|
if strings.Contains(string(part.Body), "SUMMARY:OLD") {
|
|
t.Errorf("old SUMMARY not replaced")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// set_calendar — Apply requires pre-built ICS
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestSetCalendar_EmptyICSIsError(t *testing.T) {
|
|
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
|
From: Alice <alice@example.com>
|
|
To: Bob <bob@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: text/html; charset=UTF-8
|
|
|
|
<p>Hello</p>`)
|
|
|
|
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
|
Ops: []PatchOp{{
|
|
Op: "set_calendar",
|
|
EventSummary: "Meeting",
|
|
EventStart: "2026-04-25T10:00+08:00",
|
|
EventEnd: "2026-04-25T11:00+08:00",
|
|
// CalendarICS intentionally nil — simulates missing pre-process.
|
|
}},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("expected error for missing CalendarICS")
|
|
}
|
|
if !strings.Contains(err.Error(), "ICS data is empty") {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// remove_calendar
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestRemoveCalendar_StripsCalendarPart(t *testing.T) {
|
|
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
|
From: Alice <alice@example.com>
|
|
To: Bob <bob@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: multipart/alternative; boundary="b1"
|
|
|
|
--b1
|
|
Content-Type: text/html; charset=UTF-8
|
|
|
|
<p>Hello</p>
|
|
--b1
|
|
Content-Type: text/calendar; charset=UTF-8
|
|
|
|
BEGIN:VCALENDAR
|
|
END:VCALENDAR
|
|
--b1--`)
|
|
|
|
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
|
Ops: []PatchOp{{Op: "remove_calendar"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
|
|
if part := FindPartByMediaType(snapshot.Body, calendarMediaType); part != nil {
|
|
t.Errorf("text/calendar part should be removed, but still found")
|
|
}
|
|
}
|
|
|
|
func TestRemoveCalendar_NoOpWhenAbsent(t *testing.T) {
|
|
snapshot := mustParseFixtureDraft(t, `Subject: Plain
|
|
From: Alice <alice@example.com>
|
|
To: Bob <bob@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: text/html; charset=UTF-8
|
|
|
|
<p>Hello</p>`)
|
|
|
|
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
|
Ops: []PatchOp{{Op: "remove_calendar"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
// Body remains intact.
|
|
if snapshot.Body == nil {
|
|
t.Fatal("body unexpectedly nil")
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal MIME helpers (coverage)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func TestFindPartByMediaType_CaseInsensitive(t *testing.T) {
|
|
root := &Part{
|
|
MediaType: "multipart/mixed",
|
|
Children: []*Part{
|
|
{MediaType: "TEXT/Calendar"},
|
|
},
|
|
}
|
|
got := FindPartByMediaType(root, "text/calendar")
|
|
if got == nil {
|
|
t.Fatal("expected to find part despite case mismatch")
|
|
}
|
|
}
|
|
|
|
func TestRemovePartByMediaType_MarksParentDirty(t *testing.T) {
|
|
root := &Part{
|
|
MediaType: "multipart/mixed",
|
|
Children: []*Part{
|
|
{MediaType: "text/calendar"},
|
|
{MediaType: "text/html"},
|
|
},
|
|
}
|
|
removePartByMediaType(root, "text/calendar")
|
|
if len(root.Children) != 1 {
|
|
t.Fatalf("expected 1 remaining child, got %d", len(root.Children))
|
|
}
|
|
if !root.Dirty {
|
|
t.Error("parent not marked dirty after removal")
|
|
}
|
|
}
|
|
|
|
func TestSetCalendar_CollapsesToOneInsideAlternative(t *testing.T) {
|
|
// Feishu client creates two text/calendar copies: one inside
|
|
// multipart/alternative and one as an inline attachment in
|
|
// multipart/mixed. set_calendar must collapse them to a single
|
|
// copy inside multipart/alternative.
|
|
snapshot := mustParseFixtureDraft(t, `Subject: Meeting
|
|
From: Alice <alice@example.com>
|
|
To: Bob <bob@example.com>
|
|
MIME-Version: 1.0
|
|
Content-Type: multipart/mixed; boundary="outer"
|
|
|
|
--outer
|
|
Content-Type: multipart/alternative; boundary="inner"
|
|
|
|
--inner
|
|
Content-Type: text/html; charset=UTF-8
|
|
|
|
<p>Hello</p>
|
|
--inner
|
|
Content-Type: text/calendar; charset=UTF-8
|
|
|
|
BEGIN:VCALENDAR
|
|
SUMMARY:OLD
|
|
END:VCALENDAR
|
|
--inner--
|
|
--outer
|
|
Content-Type: text/calendar; charset=UTF-8; name="invite.ics"
|
|
Content-Id: <invite.ics>
|
|
|
|
BEGIN:VCALENDAR
|
|
SUMMARY:OLD
|
|
END:VCALENDAR
|
|
--outer--`)
|
|
|
|
newICS := []byte("BEGIN:VCALENDAR\r\nSUMMARY:NEW\r\nEND:VCALENDAR\r\n")
|
|
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
|
Ops: []PatchOp{{
|
|
Op: "set_calendar",
|
|
EventSummary: "NEW",
|
|
EventStart: "2026-04-25T10:00+08:00",
|
|
EventEnd: "2026-04-25T11:00+08:00",
|
|
CalendarICS: newICS,
|
|
}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
|
|
// Exactly one text/calendar part should remain, inside alternative.
|
|
parts := findAllPartsByMediaType(snapshot.Body, calendarMediaType)
|
|
if len(parts) != 1 {
|
|
t.Fatalf("expected 1 text/calendar part, got %d", len(parts))
|
|
}
|
|
if !strings.Contains(string(parts[0].Body), "SUMMARY:NEW") {
|
|
t.Errorf("expected SUMMARY:NEW, got %q", parts[0].Body)
|
|
}
|
|
|
|
// The calendar part must be a child of multipart/alternative.
|
|
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
|
|
if alt == nil {
|
|
t.Fatal("multipart/alternative not found")
|
|
}
|
|
found := false
|
|
for _, child := range alt.Children {
|
|
if strings.EqualFold(child.MediaType, calendarMediaType) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("text/calendar part not inside multipart/alternative")
|
|
}
|
|
}
|
|
|
|
func TestRemoveCalendar_RootLevelCalendarBody(t *testing.T) {
|
|
// When the snapshot body is itself a text/calendar leaf (no multipart
|
|
// wrapper), removeCalendarPart must nil out snapshot.Body rather than
|
|
// trying to remove it from a parent's children slice.
|
|
snapshot := &DraftSnapshot{
|
|
Body: &Part{
|
|
MediaType: "text/calendar",
|
|
Body: []byte(fixtureCalData),
|
|
},
|
|
}
|
|
err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{
|
|
Ops: []PatchOp{{Op: "remove_calendar"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Apply: %v", err)
|
|
}
|
|
if snapshot.Body != nil {
|
|
t.Errorf("snapshot.Body should be nil after removing root-level text/calendar, got %+v", snapshot.Body)
|
|
}
|
|
}
|
|
|
|
func TestSetCalendarPart_OnNilBodyCreatesLeaf(t *testing.T) {
|
|
snapshot := &DraftSnapshot{}
|
|
setCalendarPart(snapshot, []byte(fixtureCalData))
|
|
if snapshot.Body == nil {
|
|
t.Fatal("body should be created")
|
|
}
|
|
if !strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
|
|
t.Errorf("expected %s leaf, got %s", calendarMediaType, snapshot.Body.MediaType)
|
|
}
|
|
}
|
|
|
|
func TestSetCalendarPart_MixedWithoutAlternativeWrapsTextChild(t *testing.T) {
|
|
// multipart/mixed with a text/html child but no alternative sub-part.
|
|
// setCalendarPart should wrap the text/html in a new alternative.
|
|
snapshot := &DraftSnapshot{
|
|
Body: &Part{
|
|
MediaType: "multipart/mixed",
|
|
Children: []*Part{
|
|
{MediaType: "text/html", Body: []byte("<p>Hi</p>")},
|
|
{MediaType: "application/pdf", Body: []byte("pdf-data")},
|
|
},
|
|
},
|
|
}
|
|
setCalendarPart(snapshot, []byte(fixtureCalData))
|
|
|
|
if snapshot.Body.MediaType != "multipart/mixed" {
|
|
t.Fatalf("root should stay multipart/mixed, got %s", snapshot.Body.MediaType)
|
|
}
|
|
alt := FindPartByMediaType(snapshot.Body, "multipart/alternative")
|
|
if alt == nil {
|
|
t.Fatal("expected a multipart/alternative child to be created")
|
|
}
|
|
if len(alt.Children) != 2 {
|
|
t.Fatalf("alternative should have 2 children, got %d", len(alt.Children))
|
|
}
|
|
if !strings.EqualFold(alt.Children[0].MediaType, "text/html") {
|
|
t.Errorf("first alternative child should be text/html, got %s", alt.Children[0].MediaType)
|
|
}
|
|
if !strings.EqualFold(alt.Children[1].MediaType, calendarMediaType) {
|
|
t.Errorf("second alternative child should be text/calendar, got %s", alt.Children[1].MediaType)
|
|
}
|
|
}
|
|
|
|
func TestSetCalendarPart_FallbackAppendsToMultipart(t *testing.T) {
|
|
// multipart/mixed with only non-text children (no text/* to wrap).
|
|
snapshot := &DraftSnapshot{
|
|
Body: &Part{
|
|
MediaType: "multipart/mixed",
|
|
Children: []*Part{
|
|
{MediaType: "application/pdf", Body: []byte("pdf-data")},
|
|
},
|
|
},
|
|
}
|
|
setCalendarPart(snapshot, []byte(fixtureCalData))
|
|
|
|
found := false
|
|
for _, child := range snapshot.Body.Children {
|
|
if strings.EqualFold(child.MediaType, calendarMediaType) {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("text/calendar should be appended as fallback child")
|
|
}
|
|
}
|