mirror of
https://github.com/larksuite/cli.git
synced 2026-07-06 00:06:28 +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
181 lines
5.9 KiB
Go
181 lines
5.9 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||
// SPDX-License-Identifier: MIT
|
||
|
||
// Package ics provides RFC 5545 iCalendar generation and parsing for mail calendar invitations.
|
||
package ics
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// Event holds the data needed to generate an ICS VCALENDAR invitation.
|
||
type Event struct {
|
||
UID string // auto-generated if empty
|
||
Summary string // SUMMARY (required)
|
||
Location string // LOCATION (optional)
|
||
Start time.Time // DTSTART (required)
|
||
End time.Time // DTEND (required)
|
||
Organizer Address // ORGANIZER
|
||
Attendees []Address // ATTENDEE list (To + Cc, excluding Bcc)
|
||
}
|
||
|
||
// Address represents a name + email pair for ORGANIZER / ATTENDEE.
|
||
type Address struct {
|
||
Name string
|
||
Email string
|
||
}
|
||
|
||
// Build generates a RFC 5545 VCALENDAR byte slice with METHOD:REQUEST.
|
||
// The output is suitable for use as a text/calendar MIME part.
|
||
func Build(event Event) []byte {
|
||
uid := event.UID
|
||
if uid == "" {
|
||
uid = uuid.New().String()
|
||
}
|
||
|
||
now := time.Now().UTC()
|
||
nowICS := formatICSTime(now)
|
||
var b strings.Builder
|
||
|
||
b.WriteString("BEGIN:VCALENDAR\r\n")
|
||
b.WriteString("CALSCALE:GREGORIAN\r\n")
|
||
b.WriteString("VERSION:2.0\r\n")
|
||
b.WriteString("PRODID:-//Lark CLI//EN\r\n")
|
||
b.WriteString("METHOD:REQUEST\r\n")
|
||
b.WriteString("X-LARK-MAIL-DRAFT:TRUE\r\n")
|
||
b.WriteString("BEGIN:VEVENT\r\n")
|
||
writeFolded(&b, "UID", uid)
|
||
writeFolded(&b, "DTSTAMP", nowICS)
|
||
writeFolded(&b, "CREATED", nowICS)
|
||
writeFolded(&b, "LAST-MODIFIED", nowICS)
|
||
writeFolded(&b, "DTSTART", formatICSTime(event.Start.UTC()))
|
||
writeFolded(&b, "DTEND", formatICSTime(event.End.UTC()))
|
||
writeFolded(&b, "SUMMARY", escapeTextValue(event.Summary))
|
||
if event.Location != "" {
|
||
writeFolded(&b, "LOCATION", escapeTextValue(event.Location))
|
||
}
|
||
b.WriteString("STATUS:CONFIRMED\r\n")
|
||
b.WriteString("TRANSP:OPAQUE\r\n")
|
||
b.WriteString("SEQUENCE:0\r\n")
|
||
if event.Organizer.Email != "" {
|
||
organizer := "ORGANIZER;ROLE=CHAIR"
|
||
if event.Organizer.Name != "" {
|
||
organizer += ";CN=" + quoteCNParam(event.Organizer.Name)
|
||
} else {
|
||
organizer += ";CN=" + quoteCNParam(event.Organizer.Email)
|
||
}
|
||
writeFolded(&b, organizer, mailtoScheme+sanitizeMailtoAddress(event.Organizer.Email))
|
||
}
|
||
for _, a := range event.Attendees {
|
||
attendee := "ATTENDEE;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CUTYPE=INDIVIDUAL"
|
||
if a.Name != "" {
|
||
attendee += ";CN=" + quoteCNParam(a.Name)
|
||
} else {
|
||
attendee += ";CN=" + quoteCNParam(a.Email)
|
||
}
|
||
attendee += ";PARTSTAT=NEEDS-ACTION"
|
||
writeFolded(&b, attendee, mailtoScheme+sanitizeMailtoAddress(a.Email))
|
||
}
|
||
b.WriteString("END:VEVENT\r\n")
|
||
b.WriteString("END:VCALENDAR\r\n")
|
||
|
||
return []byte(b.String())
|
||
}
|
||
|
||
// formatICSTime formats a time.Time as ICS UTC: YYYYMMDDTHHMMSSZ.
|
||
func formatICSTime(t time.Time) string {
|
||
return t.Format("20060102T150405Z")
|
||
}
|
||
|
||
// escapeTextValue escapes a string for use as an ICS TEXT value per RFC 5545
|
||
// §3.3.11: backslash, newline, semicolon, and comma carry structural meaning
|
||
// and must be escaped. Applied to SUMMARY, LOCATION, DESCRIPTION etc. — not
|
||
// to identifiers (UID), date-times (DTSTART/DTEND), or URIs.
|
||
//
|
||
// Without this, a user-supplied summary containing a newline or colon would
|
||
// let the payload inject a fake property line, e.g.
|
||
//
|
||
// --event-summary "foo\nDTSTART:20000101T000000Z"
|
||
//
|
||
// would turn into a second DTSTART line after folding.
|
||
func escapeTextValue(s string) string {
|
||
// Normalise CR / CRLF so downstream only sees LF.
|
||
s = strings.ReplaceAll(s, "\r\n", "\n")
|
||
s = strings.ReplaceAll(s, "\r", "\n")
|
||
// Order matters: escape backslash first so its own replacement is not
|
||
// picked up by later rules.
|
||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||
s = strings.ReplaceAll(s, ";", `\;`)
|
||
s = strings.ReplaceAll(s, ",", `\,`)
|
||
return s
|
||
}
|
||
|
||
// quoteCNParam wraps a CN parameter value in double-quotes per RFC 5545 §3.2
|
||
// when the value contains characters that are not allowed in an unquoted
|
||
// paramtext (, ; :). Characters that are illegal inside a quoted-string are
|
||
// stripped: DQUOTE (%x22) is excluded by QSAFE-CHAR, and control characters
|
||
// (%x00–%x08, %x0A–%x1F, %x7F) would break the property line structure.
|
||
func quoteCNParam(s string) string {
|
||
s = strings.Map(func(r rune) rune {
|
||
if r == '"' || r < 0x09 || (r >= 0x0A && r <= 0x1F) || r == 0x7F {
|
||
return -1
|
||
}
|
||
return r
|
||
}, s)
|
||
if strings.ContainsAny(s, ",:;") {
|
||
return `"` + s + `"`
|
||
}
|
||
return s
|
||
}
|
||
|
||
// writeFolded writes a property line with RFC 5545 line folding (75-octet limit).
|
||
// Long lines are folded by inserting CRLF + space at UTF-8 character boundaries.
|
||
// Continuation lines begin with a single SPACE (1 octet), so their content is
|
||
// limited to 74 octets to keep the total physical line at ≤ 75 octets.
|
||
func writeFolded(b *strings.Builder, name, value string) {
|
||
line := fmt.Sprintf("%s:%s", name, value)
|
||
const maxLineOctets = 75 // RFC 5545 §3.1: lines SHOULD NOT be longer than 75 octets
|
||
limit := maxLineOctets
|
||
for len(line) > limit {
|
||
// Find the last complete UTF-8 character that fits within the limit.
|
||
cut := 0
|
||
for i := 0; i < len(line); {
|
||
_, size := utf8.DecodeRuneInString(line[i:])
|
||
if i+size > limit {
|
||
break
|
||
}
|
||
i += size
|
||
cut = i
|
||
}
|
||
if cut == 0 {
|
||
// Single character exceeds limit (shouldn't happen in practice).
|
||
cut = limit
|
||
}
|
||
b.WriteString(line[:cut])
|
||
b.WriteString("\r\n ")
|
||
line = line[cut:]
|
||
limit = maxLineOctets - 1 // continuation lines: 1-octet SPACE + 74 content = 75
|
||
}
|
||
b.WriteString(line)
|
||
b.WriteString("\r\n")
|
||
}
|
||
|
||
// sanitizeMailtoAddress strips control characters (CR, LF, and other chars
|
||
// below 0x20 or equal to 0x7F) from an email address before embedding it in a
|
||
// MAILTO: URI value. Prevents property-injection attacks analogous to the CN
|
||
// parameter protection in quoteCNParam.
|
||
func sanitizeMailtoAddress(s string) string {
|
||
return strings.Map(func(r rune) rune {
|
||
if r < 0x20 || r == 0x7F {
|
||
return -1
|
||
}
|
||
return r
|
||
}, s)
|
||
}
|