mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +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
720 lines
23 KiB
Go
720 lines
23 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package ics
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestBuild_Basic(t *testing.T) {
|
|
event := Event{
|
|
UID: "test-uid-123",
|
|
Summary: "Product Review",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
Organizer: Address{Name: "Sender", Email: "sender@example.com"},
|
|
Attendees: []Address{
|
|
{Name: "Alice", Email: "alice@example.com"},
|
|
{Name: "Bob", Email: "bob@example.com"},
|
|
},
|
|
}
|
|
// Unfold before assertion so long property lines (which exceed 75 octets and
|
|
// are folded per RFC 5545) can be matched as a single contiguous string.
|
|
ics := unfoldLines(string(Build(event)))
|
|
|
|
checks := []string{
|
|
"BEGIN:VCALENDAR",
|
|
"CALSCALE:GREGORIAN",
|
|
"VERSION:2.0",
|
|
"METHOD:REQUEST",
|
|
"X-LARK-MAIL-DRAFT:TRUE",
|
|
"BEGIN:VEVENT",
|
|
"UID:test-uid-123",
|
|
"DTSTAMP:",
|
|
"CREATED:",
|
|
"LAST-MODIFIED:",
|
|
"DTSTART:20260420T060000Z",
|
|
"DTEND:20260420T070000Z",
|
|
"SUMMARY:Product Review",
|
|
"STATUS:CONFIRMED",
|
|
"TRANSP:OPAQUE",
|
|
"SEQUENCE:0",
|
|
"ORGANIZER;ROLE=CHAIR;CN=Sender:MAILTO:sender@example.com",
|
|
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Alice;PARTSTAT=NEEDS-ACTION:MAILTO:alice@example.com",
|
|
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=Bob;PARTSTAT=NEEDS-ACTION:MAILTO:bob@example.com",
|
|
"END:VEVENT",
|
|
"END:VCALENDAR",
|
|
}
|
|
for _, want := range checks {
|
|
if !strings.Contains(ics, want) {
|
|
t.Errorf("missing %q in ICS:\n%s", want, ics)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBuild_OrganizerFallsBackToEmailWhenNoName(t *testing.T) {
|
|
event := Event{
|
|
Summary: "Meeting",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
Organizer: Address{Email: "o@e.com"},
|
|
Attendees: []Address{{Email: "a@e.com"}},
|
|
}
|
|
ics := unfoldLines(string(Build(event)))
|
|
if !strings.Contains(ics, "ORGANIZER;ROLE=CHAIR;CN=o@e.com:MAILTO:o@e.com") {
|
|
t.Errorf("ORGANIZER without name should fall back to email as CN:\n%s", ics)
|
|
}
|
|
if !strings.Contains(ics, "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL;CN=a@e.com;PARTSTAT=NEEDS-ACTION:MAILTO:a@e.com") {
|
|
t.Errorf("ATTENDEE without name should fall back to email as CN:\n%s", ics)
|
|
}
|
|
}
|
|
|
|
func TestBuild_WithLocation(t *testing.T) {
|
|
event := Event{
|
|
Summary: "Meeting",
|
|
Location: "5F Conference Room",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
}
|
|
ics := string(Build(event))
|
|
if !strings.Contains(ics, "LOCATION:5F Conference Room") {
|
|
t.Errorf("missing LOCATION in ICS:\n%s", ics)
|
|
}
|
|
}
|
|
|
|
func TestBuild_NoLocation(t *testing.T) {
|
|
event := Event{
|
|
Summary: "Meeting",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
}
|
|
ics := string(Build(event))
|
|
if strings.Contains(ics, "LOCATION") {
|
|
t.Errorf("should not have LOCATION when empty:\n%s", ics)
|
|
}
|
|
}
|
|
|
|
func TestBuild_AutoUIDIsPureUUID(t *testing.T) {
|
|
event := Event{
|
|
Summary: "Test",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
}
|
|
ics := string(Build(event))
|
|
if !strings.Contains(ics, "UID:") {
|
|
t.Fatal("missing UID")
|
|
}
|
|
// Extract the UID line to assert on its format.
|
|
var uid string
|
|
for _, line := range strings.Split(ics, "\r\n") {
|
|
if strings.HasPrefix(line, "UID:") {
|
|
uid = strings.TrimPrefix(line, "UID:")
|
|
break
|
|
}
|
|
}
|
|
if strings.Contains(uid, "@") {
|
|
t.Errorf("auto-generated UID should be pure UUID (no @host suffix), got %q", uid)
|
|
}
|
|
// UUID v4 has 36 chars (8-4-4-4-12 plus 4 dashes).
|
|
if len(uid) != 36 {
|
|
t.Errorf("auto-generated UID should be 36-char UUID, got %d chars: %q", len(uid), uid)
|
|
}
|
|
}
|
|
|
|
func TestBuild_EscapesTextValues(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{"semicolon", "a;b", `a\;b`},
|
|
{"comma", "a,b", `a\,b`},
|
|
{"backslash", `a\b`, `a\\b`},
|
|
{"newline", "a\nb", `a\nb`},
|
|
{"crlf", "a\r\nb", `a\nb`},
|
|
{"mixed", `a;\,b` + "\n", `a\;\\\,b\n`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := escapeTextValue(tc.input); got != tc.want {
|
|
t.Errorf("escapeTextValue(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuild_RejectsInjectionViaSummary proves that a malicious SUMMARY
|
|
// containing a newline plus a fake property line cannot inject a second
|
|
// DTSTART into the rendered ICS — the newline is escaped into a literal
|
|
// "\n" sequence inside the SUMMARY value.
|
|
func TestBuild_RejectsInjectionViaSummary(t *testing.T) {
|
|
event := Event{
|
|
Summary: "harmless\nDTSTART:19700101T000000Z",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
}
|
|
ics := unfoldLines(string(Build(event)))
|
|
|
|
// Count occurrences of DTSTART at the start of a line (i.e., as an
|
|
// actual property), ignoring the literal "DTSTART:" substring that
|
|
// now appears inside the escaped SUMMARY value.
|
|
dtstartPropertyLines := 0
|
|
for _, line := range strings.Split(ics, "\r\n") {
|
|
if strings.HasPrefix(line, "DTSTART:") {
|
|
dtstartPropertyLines++
|
|
}
|
|
}
|
|
if dtstartPropertyLines != 1 {
|
|
t.Errorf("expected exactly one DTSTART: property line, got %d in:\n%s", dtstartPropertyLines, ics)
|
|
}
|
|
if !strings.Contains(ics, `SUMMARY:harmless\nDTSTART:19700101T000000Z`) {
|
|
t.Errorf("expected escaped SUMMARY to contain literal \\n, got:\n%s", ics)
|
|
}
|
|
}
|
|
|
|
func TestBuild_CNWithSpecialCharsIsQuoted(t *testing.T) {
|
|
event := Event{
|
|
Summary: "Meeting",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
Organizer: Address{Name: "Smith, Alice", Email: "alice@example.com"},
|
|
Attendees: []Address{
|
|
{Name: "Doe; Bob", Email: "bob@example.com"},
|
|
{Name: "Plain Name", Email: "plain@example.com"},
|
|
},
|
|
}
|
|
ics := unfoldLines(string(Build(event)))
|
|
if !strings.Contains(ics, `CN="Smith, Alice"`) {
|
|
t.Errorf("expected quoted CN for organizer name with comma:\n%s", ics)
|
|
}
|
|
if !strings.Contains(ics, `CN="Doe; Bob"`) {
|
|
t.Errorf("expected quoted CN for attendee name with semicolon:\n%s", ics)
|
|
}
|
|
// Names without special chars must NOT be double-quoted.
|
|
if !strings.Contains(ics, "CN=Plain Name") || strings.Contains(ics, `CN="Plain Name"`) {
|
|
t.Errorf("plain name should be unquoted:\n%s", ics)
|
|
}
|
|
}
|
|
|
|
func TestBuild_EmailAddressSanitized(t *testing.T) {
|
|
// CR/LF inside an email address must not produce injected property lines.
|
|
event := Event{
|
|
Summary: "Meeting",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
Organizer: Address{Name: "Alice", Email: "alice@example.com\r\nX-INJECTED:bad"},
|
|
Attendees: []Address{{Name: "Bob", Email: "bob@example.com\nY-INJECTED:bad"}},
|
|
}
|
|
output := string(Build(event))
|
|
if strings.Contains(output, "\r\nX-INJECTED") {
|
|
t.Error("organizer email CR/LF injection not sanitized")
|
|
}
|
|
if strings.Contains(output, "\r\nY-INJECTED") {
|
|
t.Error("attendee email CR/LF injection not sanitized")
|
|
}
|
|
}
|
|
|
|
func TestBuild_CNStripsControlChars(t *testing.T) {
|
|
// A display name containing CR, LF, or other control characters must not
|
|
// produce extra ICS property lines (injection via CN parameter).
|
|
event := Event{
|
|
Summary: "Meeting",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
Organizer: Address{Name: "Alice\r\nDTSTART:99999999", Email: "alice@example.com"},
|
|
Attendees: []Address{
|
|
{Name: "Bob\nX-INJECTED:bad", Email: "bob@example.com"},
|
|
},
|
|
}
|
|
output := string(Build(event))
|
|
// Check that control chars don't produce injected property lines.
|
|
// A standalone ICS property line starts at the beginning of a CRLF-delimited line.
|
|
if strings.Contains(output, "\r\nDTSTART:99999999") {
|
|
t.Error("ICS output contains injected DTSTART property line via organizer CN")
|
|
}
|
|
if strings.Contains(output, "\r\nX-INJECTED") {
|
|
t.Error("ICS output contains injected X-INJECTED property line via attendee CN")
|
|
}
|
|
}
|
|
|
|
func TestBuild_LineFolding(t *testing.T) {
|
|
event := Event{
|
|
Summary: strings.Repeat("A", 100), // long summary triggers folding
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
}
|
|
ics := string(Build(event))
|
|
// Every physical line (first line and continuation lines alike) must be
|
|
// ≤ 75 octets excluding the CRLF terminator per RFC 5545 §3.1.
|
|
for _, line := range strings.Split(ics, "\r\n") {
|
|
if len(line) > 75 {
|
|
t.Errorf("line exceeds 75 octets: %q (len=%d)", line, len(line))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_Basic(t *testing.T) {
|
|
ics := "BEGIN:VCALENDAR\r\n" +
|
|
"VERSION:2.0\r\n" +
|
|
"METHOD:REQUEST\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:abc123@larksuite.com\r\n" +
|
|
"DTSTART:20260420T060000Z\r\n" +
|
|
"DTEND:20260420T070000Z\r\n" +
|
|
"SUMMARY:Product Review\r\n" +
|
|
"LOCATION:5F Room\r\n" +
|
|
"ORGANIZER;CN=Sender:mailto:sender@example.com\r\n" +
|
|
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=Alice:mailto:alice@example.com\r\n" +
|
|
"ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=Bob:mailto:bob@example.com\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
|
|
event := ParseEvent(ics)
|
|
if event == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
if event.Method != "REQUEST" {
|
|
t.Errorf("Method = %q, want REQUEST", event.Method)
|
|
}
|
|
if event.IsLarkDraft {
|
|
t.Error("IsLarkDraft = true, want false (no X-LARK-MAIL-DRAFT in input)")
|
|
}
|
|
if event.UID != "abc123@larksuite.com" {
|
|
t.Errorf("UID = %q, want abc123@larksuite.com", event.UID)
|
|
}
|
|
if event.Summary != "Product Review" {
|
|
t.Errorf("Summary = %q, want Product Review", event.Summary)
|
|
}
|
|
if event.Location != "5F Room" {
|
|
t.Errorf("Location = %q, want 5F Room", event.Location)
|
|
}
|
|
if event.Organizer != "sender@example.com" {
|
|
t.Errorf("Organizer = %q, want sender@example.com", event.Organizer)
|
|
}
|
|
if len(event.Attendees) != 2 {
|
|
t.Fatalf("Attendees count = %d, want 2", len(event.Attendees))
|
|
}
|
|
if event.Attendees[0] != "alice@example.com" {
|
|
t.Errorf("Attendees[0] = %q, want alice@example.com", event.Attendees[0])
|
|
}
|
|
if event.Attendees[1] != "bob@example.com" {
|
|
t.Errorf("Attendees[1] = %q, want bob@example.com", event.Attendees[1])
|
|
}
|
|
wantStart := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
|
if !event.Start.Equal(wantStart) {
|
|
t.Errorf("Start = %v, want %v", event.Start, wantStart)
|
|
}
|
|
wantEnd := time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC)
|
|
if !event.End.Equal(wantEnd) {
|
|
t.Errorf("End = %v, want %v", event.End, wantEnd)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_IsLarkDraft(t *testing.T) {
|
|
icsWithMarker := "BEGIN:VCALENDAR\r\n" +
|
|
"METHOD:REQUEST\r\n" +
|
|
"X-LARK-MAIL-DRAFT:TRUE\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:draft-test\r\n" +
|
|
"DTSTART:20260420T060000Z\r\n" +
|
|
"DTEND:20260420T070000Z\r\n" +
|
|
"SUMMARY:Draft Event\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
event := ParseEvent(icsWithMarker)
|
|
if event == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
if !event.IsLarkDraft {
|
|
t.Error("IsLarkDraft = false, want true")
|
|
}
|
|
|
|
icsWithoutMarker := "BEGIN:VCALENDAR\r\n" +
|
|
"METHOD:REQUEST\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:external-test\r\n" +
|
|
"DTSTART:20260420T060000Z\r\n" +
|
|
"DTEND:20260420T070000Z\r\n" +
|
|
"SUMMARY:External Event\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
event2 := ParseEvent(icsWithoutMarker)
|
|
if event2 == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
if event2.IsLarkDraft {
|
|
t.Error("IsLarkDraft = true, want false")
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_WithTZID(t *testing.T) {
|
|
ics := "BEGIN:VCALENDAR\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:tz-test\r\n" +
|
|
"DTSTART;TZID=Asia/Shanghai:20260420T140000\r\n" +
|
|
"DTEND;TZID=Asia/Shanghai:20260420T150000\r\n" +
|
|
"SUMMARY:TZ Test\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
|
|
event := ParseEvent(ics)
|
|
if event == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
// 14:00 Asia/Shanghai = 06:00 UTC
|
|
wantStart := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
|
if !event.Start.Equal(wantStart) {
|
|
t.Errorf("Start = %v, want %v", event.Start, wantStart)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_FoldedLines(t *testing.T) {
|
|
ics := "BEGIN:VCALENDAR\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:fold-test\r\n" +
|
|
"DTSTART:20260420T060000Z\r\n" +
|
|
"DTEND:20260420T070000Z\r\n" +
|
|
"SUMMARY:This is a very long summary that should be unfolded correctly by th\r\n" +
|
|
" e parser when processing\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
|
|
event := ParseEvent(ics)
|
|
if event == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
want := "This is a very long summary that should be unfolded correctly by the parser when processing"
|
|
if event.Summary != want {
|
|
t.Errorf("Summary = %q, want %q", event.Summary, want)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_FoldedLines_LFOnly(t *testing.T) {
|
|
// Some mail servers strip \r before storage, producing LF-only ICS.
|
|
ics := "BEGIN:VCALENDAR\n" +
|
|
"BEGIN:VEVENT\n" +
|
|
"UID:lf-fold-test\n" +
|
|
"DTSTART:20260420T060000Z\n" +
|
|
"DTEND:20260420T070000Z\n" +
|
|
"SUMMARY:This is a very long summary that should be unfolded correctly by th\n" +
|
|
" e parser when LF-only folding is used\n" +
|
|
"END:VEVENT\n" +
|
|
"END:VCALENDAR\n"
|
|
|
|
event := ParseEvent(ics)
|
|
if event == nil {
|
|
t.Fatal("ParseEvent returned nil for LF-only ICS")
|
|
}
|
|
want := "This is a very long summary that should be unfolded correctly by the parser when LF-only folding is used"
|
|
if event.Summary != want {
|
|
t.Errorf("Summary = %q, want %q", event.Summary, want)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_NoVEvent(t *testing.T) {
|
|
ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n"
|
|
event := ParseEvent(ics)
|
|
if event != nil {
|
|
t.Error("expected nil for ICS without VEVENT")
|
|
}
|
|
}
|
|
|
|
// TestParseEvent_OrganizerWithoutMailto covers the case where the backend
|
|
// re-serializes our ICS and drops the "MAILTO:" scheme prefix. Observed in
|
|
// practice on drafts returned by user_mailboxes/me/drafts.get.
|
|
func TestParseEvent_OrganizerWithoutMailto(t *testing.T) {
|
|
ics := "BEGIN:VCALENDAR\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:no-mailto-test\r\n" +
|
|
"DTSTART:20260420T060000Z\r\n" +
|
|
"DTEND:20260420T070000Z\r\n" +
|
|
"SUMMARY:Test\r\n" +
|
|
"ORGANIZER;CN=org@example.com:org@example.com\r\n" +
|
|
"ATTENDEE;PARTSTAT=NEEDS-ACTION;CN=att@example.com:att@example.com\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
|
|
event := ParseEvent(ics)
|
|
if event == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
if event.Organizer != "org@example.com" {
|
|
t.Errorf("Organizer = %q, want org@example.com (parser must accept bare email when mailto: is absent)", event.Organizer)
|
|
}
|
|
if len(event.Attendees) != 1 || event.Attendees[0] != "att@example.com" {
|
|
t.Errorf("Attendees = %v, want [att@example.com]", event.Attendees)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_MailtoCaseInsensitive(t *testing.T) {
|
|
ics := "BEGIN:VCALENDAR\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:case-test\r\n" +
|
|
"DTSTART:20260420T060000Z\r\n" +
|
|
"DTEND:20260420T070000Z\r\n" +
|
|
"SUMMARY:Test\r\n" +
|
|
"ORGANIZER;CN=Sender:MAILTO:sender@example.com\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
|
|
event := ParseEvent(ics)
|
|
if event == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
if event.Organizer != "sender@example.com" {
|
|
t.Errorf("Organizer = %q, want sender@example.com (uppercase MAILTO: should be accepted)", event.Organizer)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_RecurrenceIDPopulatesOriginalTime(t *testing.T) {
|
|
ics := "BEGIN:VCALENDAR\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:recurring-exception\r\n" +
|
|
"DTSTART:20260501T020000Z\r\n" +
|
|
"DTEND:20260501T030000Z\r\n" +
|
|
"RECURRENCE-ID:20260501T020000Z\r\n" +
|
|
"SUMMARY:Exception instance\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
event := ParseEvent(ics)
|
|
if event == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
// 2026-05-01 02:00:00 UTC = 1777600800
|
|
if event.OriginalTime != 1777600800 {
|
|
t.Errorf("OriginalTime = %d, want 1777600800", event.OriginalTime)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_NoRecurrenceIDYieldsZero(t *testing.T) {
|
|
ics := "BEGIN:VCALENDAR\r\n" +
|
|
"BEGIN:VEVENT\r\n" +
|
|
"UID:single-event\r\n" +
|
|
"DTSTART:20260420T060000Z\r\n" +
|
|
"DTEND:20260420T070000Z\r\n" +
|
|
"SUMMARY:Single\r\n" +
|
|
"END:VEVENT\r\n" +
|
|
"END:VCALENDAR\r\n"
|
|
event := ParseEvent(ics)
|
|
if event == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
if event.OriginalTime != 0 {
|
|
t.Errorf("OriginalTime = %d, want 0 for non-recurring event", event.OriginalTime)
|
|
}
|
|
}
|
|
|
|
func TestUnescapeTextValue(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{"plain", "hello", "hello"},
|
|
{"semicolon", `a\;b`, "a;b"},
|
|
{"comma", `a\,b`, "a,b"},
|
|
{"backslash", `a\\b`, `a\b`},
|
|
{"newline_lower", `a\nb`, "a\nb"},
|
|
{"newline_upper", `a\Nb`, "a\nb"},
|
|
{"mixed", `a\;\\\,b\n`, "a;\\,b\n"},
|
|
{"dangling_backslash_kept", `ends\`, `ends\`},
|
|
{"unknown_escape_kept", `\x`, `\x`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := unescapeTextValue(tc.input); got != tc.want {
|
|
t.Errorf("unescapeTextValue(%q) = %q, want %q", tc.input, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRoundTrip_SpecialCharsInSummaryAndLocation(t *testing.T) {
|
|
event := Event{
|
|
UID: "rt-special",
|
|
Summary: `Review;with,special\chars` + "\n" + `and newline`,
|
|
Location: `B1,Room 3;floor 2`,
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
}
|
|
parsed := ParseEvent(string(Build(event)))
|
|
if parsed == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
if !parsed.IsLarkDraft {
|
|
t.Error("IsLarkDraft = false after roundtrip, want true (Build should write X-LARK-MAIL-DRAFT)")
|
|
}
|
|
if parsed.Summary != event.Summary {
|
|
t.Errorf("Summary roundtrip: got %q, want %q", parsed.Summary, event.Summary)
|
|
}
|
|
if parsed.Location != event.Location {
|
|
t.Errorf("Location roundtrip: got %q, want %q", parsed.Location, event.Location)
|
|
}
|
|
}
|
|
|
|
func TestBuild_WriteFolded_SingleCharExceeds75Bytes(t *testing.T) {
|
|
// A single multibyte rune that is > 75 bytes is not reachable in practice,
|
|
// but we exercise the cut==0 fallback by constructing a fake line via a
|
|
// 75-octet name followed by a multi-octet rune that crosses the boundary.
|
|
// The simplest way: a name of exactly 74 chars + ':' = 75, then a multi-byte
|
|
// rune — the first iteration has cut==0, triggering the fallback.
|
|
var b strings.Builder
|
|
longName := strings.Repeat("A", 74)
|
|
// value starts with a 3-byte UTF-8 rune (€ = 0xE2 0x82 0xAC)
|
|
writeFolded(&b, longName, "€remainder")
|
|
result := b.String()
|
|
if !strings.Contains(result, "\r\n ") {
|
|
t.Errorf("expected line folding CRLF+SP in output:\n%q", result)
|
|
}
|
|
}
|
|
|
|
func TestSplitProperty_NoColon(t *testing.T) {
|
|
name, value := splitProperty("NOCOLON")
|
|
if name != "NOCOLON" || value != "" {
|
|
t.Errorf("splitProperty(no colon): got name=%q value=%q, want NOCOLON/\"\"", name, value)
|
|
}
|
|
}
|
|
|
|
func TestSplitProperty_QuotedColon(t *testing.T) {
|
|
// A colon inside a quoted CN param must not be treated as the separator.
|
|
name, value := splitProperty(`ORGANIZER;CN="Doe: Jane":mailto:alice@example.com`)
|
|
if name != `ORGANIZER;CN="Doe: Jane"` {
|
|
t.Errorf("name = %q, want ORGANIZER;CN=\"Doe: Jane\"", name)
|
|
}
|
|
if value != "mailto:alice@example.com" {
|
|
t.Errorf("value = %q, want mailto:alice@example.com", value)
|
|
}
|
|
}
|
|
|
|
func TestParseICSTime_TZIDCaseInsensitive(t *testing.T) {
|
|
// TZID parameter name is case-insensitive per RFC 5545 §3.2.
|
|
result := parseICSTime("20260420T140000", "DTSTART;tzid=Asia/Shanghai")
|
|
want := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
|
if !result.Equal(want) {
|
|
t.Errorf("parseICSTime with lowercase tzid= = %v, want %v", result, want)
|
|
}
|
|
}
|
|
|
|
func TestParseICSTime_TZIDWithTrailingParam(t *testing.T) {
|
|
// Trailing parameters after TZID (e.g. ;VALUE=DATE-TIME) must not be
|
|
// included in the timezone name passed to time.LoadLocation.
|
|
result := parseICSTime("20260420T140000", "DTSTART;TZID=Asia/Shanghai;VALUE=DATE-TIME")
|
|
want := time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC)
|
|
if !result.Equal(want) {
|
|
t.Errorf("parseICSTime with trailing ;VALUE= = %v, want %v", result, want)
|
|
}
|
|
}
|
|
|
|
func TestParseICSTime_DateOnly(t *testing.T) {
|
|
// All-day event: YYYYMMDD format
|
|
result := parseICSTime("20260420", "DTSTART;VALUE=DATE")
|
|
want := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)
|
|
if !result.Equal(want) {
|
|
t.Errorf("parseICSTime date-only = %v, want %v", result, want)
|
|
}
|
|
}
|
|
|
|
func TestParseICSTime_LocalWithoutTZ(t *testing.T) {
|
|
// Local time without timezone suffix (no Z, no TZID) — treated as UTC
|
|
result := parseICSTime("20260420T140000", "DTSTART")
|
|
want := time.Date(2026, 4, 20, 14, 0, 0, 0, time.UTC)
|
|
if !result.Equal(want) {
|
|
t.Errorf("parseICSTime local = %v, want %v", result, want)
|
|
}
|
|
}
|
|
|
|
func TestParseICSTime_InvalidReturnsZero(t *testing.T) {
|
|
result := parseICSTime("not-a-date", "DTSTART")
|
|
if !result.IsZero() {
|
|
t.Errorf("parseICSTime invalid = %v, want zero", result)
|
|
}
|
|
}
|
|
|
|
func TestExtractMailto_NoAt(t *testing.T) {
|
|
result := extractMailto("notanemail")
|
|
if result != "" {
|
|
t.Errorf("extractMailto(no @) = %q, want empty", result)
|
|
}
|
|
}
|
|
|
|
func TestRoundTrip(t *testing.T) {
|
|
original := Event{
|
|
UID: "roundtrip-test",
|
|
Summary: "Roundtrip Meeting",
|
|
Location: "Room 301",
|
|
Start: time.Date(2026, 4, 20, 6, 0, 0, 0, time.UTC),
|
|
End: time.Date(2026, 4, 20, 7, 0, 0, 0, time.UTC),
|
|
Organizer: Address{Name: "Sender", Email: "sender@example.com"},
|
|
Attendees: []Address{
|
|
{Name: "Alice", Email: "alice@example.com"},
|
|
},
|
|
}
|
|
icsBytes := Build(original)
|
|
parsed := ParseEvent(string(icsBytes))
|
|
if parsed == nil {
|
|
t.Fatal("ParseEvent returned nil on Build output")
|
|
}
|
|
if parsed.UID != original.UID {
|
|
t.Errorf("UID roundtrip: %q != %q", parsed.UID, original.UID)
|
|
}
|
|
if parsed.Summary != original.Summary {
|
|
t.Errorf("Summary roundtrip: %q != %q", parsed.Summary, original.Summary)
|
|
}
|
|
if parsed.Location != original.Location {
|
|
t.Errorf("Location roundtrip: %q != %q", parsed.Location, original.Location)
|
|
}
|
|
if !parsed.Start.Equal(original.Start) {
|
|
t.Errorf("Start roundtrip: %v != %v", parsed.Start, original.Start)
|
|
}
|
|
if parsed.Organizer != original.Organizer.Email {
|
|
t.Errorf("Organizer roundtrip: %q != %q", parsed.Organizer, original.Organizer.Email)
|
|
}
|
|
if len(parsed.Attendees) != 1 || parsed.Attendees[0] != "alice@example.com" {
|
|
t.Errorf("Attendees roundtrip: %v", parsed.Attendees)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_LowercaseAndParameterizedProps(t *testing.T) {
|
|
ics := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
|
|
"uid:lowercased-uid-value\r\n" +
|
|
"SUMMARY;LANGUAGE=en-US:Team Sync\r\n" +
|
|
"location;ALTREP=\"cid:part1\":Room 301\r\n" +
|
|
"DTSTART:20260501T100000Z\r\n" +
|
|
"DTEND:20260501T110000Z\r\n" +
|
|
"END:VEVENT\r\nEND:VCALENDAR\r\n"
|
|
ev := ParseEvent(ics)
|
|
if ev == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
if ev.UID != "lowercased-uid-value" {
|
|
t.Errorf("UID: got %q", ev.UID)
|
|
}
|
|
if ev.Summary != "Team Sync" {
|
|
t.Errorf("Summary: got %q", ev.Summary)
|
|
}
|
|
if ev.Location != "Room 301" {
|
|
t.Errorf("Location: got %q", ev.Location)
|
|
}
|
|
}
|
|
|
|
func TestParseEvent_StartEndUTCInOutput(t *testing.T) {
|
|
// Verify that times with TZID are parsed with correct offset
|
|
// (UTC normalization in output is done by the helpers layer; parser
|
|
// returns time.Time which callers can call .UTC() on).
|
|
ics := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
|
|
"DTSTART;TZID=Asia/Shanghai:20260501T180000\r\n" +
|
|
"DTEND;TZID=Asia/Shanghai:20260501T190000\r\n" +
|
|
"END:VEVENT\r\nEND:VCALENDAR\r\n"
|
|
ev := ParseEvent(ics)
|
|
if ev == nil {
|
|
t.Fatal("ParseEvent returned nil")
|
|
}
|
|
wantStart := "2026-05-01T10:00:00Z"
|
|
if got := ev.Start.UTC().Format(time.RFC3339); got != wantStart {
|
|
t.Errorf("Start UTC: got %q, want %q", got, wantStart)
|
|
}
|
|
}
|