mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 23:15:25 +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
223 lines
7.0 KiB
Go
223 lines
7.0 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package ics
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// mailtoScheme is the canonical case for the RFC 5545 ORGANIZER / ATTENDEE
|
|
// CAL-ADDRESS URI scheme. Emitted by the builder in upper-case to match
|
|
// Feishu client output; matched case-insensitively by the parser.
|
|
const mailtoScheme = "MAILTO:"
|
|
|
|
// ParsedEvent holds key fields extracted from an ICS VCALENDAR.
|
|
type ParsedEvent struct {
|
|
Method string // VCALENDAR-level METHOD (REQUEST/REPLY/CANCEL)
|
|
IsLarkDraft bool // true when VCALENDAR contains X-LARK-MAIL-DRAFT (Feishu private property indicating the event is editable)
|
|
UID string // VEVENT UID
|
|
Summary string // VEVENT SUMMARY, RFC 5545 TEXT unescaped
|
|
Location string // VEVENT LOCATION, RFC 5545 TEXT unescaped
|
|
Start time.Time // VEVENT DTSTART
|
|
End time.Time // VEVENT DTEND
|
|
Organizer string // ORGANIZER email (from MAILTO: URI or bare email)
|
|
Attendees []string // ATTENDEE emails (from MAILTO: URIs or bare emails)
|
|
OriginalTime int64 // RECURRENCE-ID as Unix seconds, 0 if not present. Used together with UID to derive the Feishu calendar event_id = UID + "_" + OriginalTime.
|
|
}
|
|
|
|
// ParseEvent extracts key fields from an ICS VCALENDAR string.
|
|
// Returns nil if no VEVENT is found.
|
|
func ParseEvent(icsText string) *ParsedEvent {
|
|
// Step 1: line unfolding (RFC 5545 §3.1)
|
|
unfolded := unfoldLines(icsText)
|
|
|
|
lines := strings.Split(unfolded, "\n")
|
|
var event ParsedEvent
|
|
inVEvent := false
|
|
foundVEvent := false
|
|
|
|
for _, line := range lines {
|
|
line = strings.TrimRight(line, "\r")
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
upper := strings.ToUpper(line)
|
|
|
|
// VCALENDAR-level properties
|
|
if !inVEvent && strings.HasPrefix(upper, "METHOD:") {
|
|
event.Method = strings.TrimSpace(line[len("METHOD:"):])
|
|
continue
|
|
}
|
|
if !inVEvent && strings.HasPrefix(upper, "X-LARK-MAIL-DRAFT:") {
|
|
event.IsLarkDraft = true
|
|
continue
|
|
}
|
|
|
|
if upper == "BEGIN:VEVENT" {
|
|
inVEvent = true
|
|
continue
|
|
}
|
|
if upper == "END:VEVENT" {
|
|
inVEvent = false
|
|
foundVEvent = true
|
|
continue
|
|
}
|
|
|
|
if !inVEvent {
|
|
continue
|
|
}
|
|
|
|
// VEVENT properties — RFC 5545 §3.1: property names are
|
|
// case-insensitive and may carry parameters (NAME;PARAM=v:value).
|
|
name, value := splitProperty(line)
|
|
propUpper := strings.ToUpper(name)
|
|
switch {
|
|
case propUpper == "UID" || strings.HasPrefix(propUpper, "UID;"):
|
|
event.UID = value
|
|
case propUpper == "SUMMARY" || strings.HasPrefix(propUpper, "SUMMARY;"):
|
|
event.Summary = unescapeTextValue(value)
|
|
case propUpper == "LOCATION" || strings.HasPrefix(propUpper, "LOCATION;"):
|
|
event.Location = unescapeTextValue(value)
|
|
case propUpper == "DTSTART" || strings.HasPrefix(propUpper, "DTSTART;"):
|
|
event.Start = parseICSTime(value, name)
|
|
case propUpper == "DTEND" || strings.HasPrefix(propUpper, "DTEND;"):
|
|
event.End = parseICSTime(value, name)
|
|
case propUpper == "RECURRENCE-ID" || strings.HasPrefix(propUpper, "RECURRENCE-ID;"):
|
|
if t := parseICSTime(value, name); !t.IsZero() {
|
|
event.OriginalTime = t.Unix()
|
|
}
|
|
case propUpper == "ORGANIZER" || strings.HasPrefix(propUpper, "ORGANIZER;"):
|
|
if email := extractMailto(value); email != "" {
|
|
event.Organizer = email
|
|
}
|
|
case propUpper == "ATTENDEE" || strings.HasPrefix(propUpper, "ATTENDEE;"):
|
|
if email := extractMailto(value); email != "" {
|
|
event.Attendees = append(event.Attendees, email)
|
|
}
|
|
}
|
|
}
|
|
|
|
if !foundVEvent {
|
|
return nil
|
|
}
|
|
return &event
|
|
}
|
|
|
|
// unfoldLines reverses RFC 5545 line folding: CRLF (or bare LF) followed by
|
|
// a single whitespace character is merged back into the preceding line.
|
|
// CRLF forms are handled first so that "\r\n " is consumed as a unit and does
|
|
// not leave a stray "\r" for the LF-only pass to mis-process.
|
|
func unfoldLines(s string) string {
|
|
s = strings.ReplaceAll(s, "\r\n ", "")
|
|
s = strings.ReplaceAll(s, "\r\n\t", "")
|
|
// LF-only folding — produced by some mail servers that strip \r.
|
|
s = strings.ReplaceAll(s, "\n ", "")
|
|
s = strings.ReplaceAll(s, "\n\t", "")
|
|
return s
|
|
}
|
|
|
|
// splitProperty splits "NAME;PARAMS:VALUE" into (name-with-params, value).
|
|
// It scans for the first colon that is not inside a double-quoted parameter
|
|
// value (e.g. CN="Doe: Jane"), per RFC 5545 §3.1.
|
|
func splitProperty(line string) (string, string) {
|
|
inQuote := false
|
|
for i := 0; i < len(line); i++ {
|
|
switch line[i] {
|
|
case '"':
|
|
inQuote = !inQuote
|
|
case ':':
|
|
if !inQuote {
|
|
return line[:i], line[i+1:]
|
|
}
|
|
}
|
|
}
|
|
return line, ""
|
|
}
|
|
|
|
// parseICSTime parses ICS datetime formats:
|
|
// - 20260420T060000Z (UTC)
|
|
// - TZID=Asia/Shanghai:20260420T140000 (with timezone in property params)
|
|
// - 20260420T140000 (local, treated as UTC)
|
|
func parseICSTime(value, propName string) time.Time {
|
|
value = strings.TrimSpace(value)
|
|
|
|
// Check for TZID in property params: DTSTART;TZID=Asia/Shanghai
|
|
// Case-insensitive search (RFC 5545 §3.2 param names are case-insensitive).
|
|
// Stop at the next ';' so trailing params like ;VALUE=DATE-TIME are excluded.
|
|
if idx := strings.Index(strings.ToUpper(propName), "TZID="); idx >= 0 {
|
|
tzPart := propName[idx+5:] // skip past "TZID="
|
|
if end := strings.IndexByte(tzPart, ';'); end >= 0 {
|
|
tzPart = tzPart[:end]
|
|
}
|
|
if loc, err := time.LoadLocation(tzPart); err == nil {
|
|
if t, err := time.ParseInLocation("20060102T150405", value, loc); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
}
|
|
|
|
// UTC format: YYYYMMDDTHHMMSSZ
|
|
if t, err := time.Parse("20060102T150405Z", value); err == nil {
|
|
return t
|
|
}
|
|
|
|
// Date-only: YYYYMMDD (all-day events)
|
|
if t, err := time.Parse("20060102", value); err == nil {
|
|
return t
|
|
}
|
|
|
|
// Local time without timezone (treat as UTC)
|
|
if t, err := time.Parse("20060102T150405", value); err == nil {
|
|
return t
|
|
}
|
|
|
|
return time.Time{}
|
|
}
|
|
|
|
// unescapeTextValue reverses escapeTextValue per RFC 5545 §3.3.11, turning
|
|
// the ICS on-wire representation back into a plain Go string. Only applied
|
|
// to TEXT-typed properties (SUMMARY, LOCATION, DESCRIPTION, etc.) —
|
|
// identifiers, date-times, and URIs are parsed as-is.
|
|
func unescapeTextValue(s string) string {
|
|
if !strings.Contains(s, `\`) {
|
|
return s
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(len(s))
|
|
for i := 0; i < len(s); i++ {
|
|
if s[i] == '\\' && i+1 < len(s) {
|
|
switch s[i+1] {
|
|
case 'n', 'N':
|
|
b.WriteByte('\n')
|
|
i++
|
|
continue
|
|
case '\\', ';', ',':
|
|
b.WriteByte(s[i+1])
|
|
i++
|
|
continue
|
|
}
|
|
}
|
|
b.WriteByte(s[i])
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// extractMailto extracts the email address from an ICS ORGANIZER/ATTENDEE value.
|
|
// Accepts both "mailto:user@example.com" (RFC 5545 standard, case-insensitive per
|
|
// RFC 3986 §3.1) and a bare "user@example.com" value (observed in backend-regenerated
|
|
// ICS where the mailto: scheme prefix is dropped).
|
|
func extractMailto(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
lower := strings.ToLower(value)
|
|
if idx := strings.Index(lower, strings.ToLower(mailtoScheme)); idx >= 0 {
|
|
return strings.TrimSpace(value[idx+len(mailtoScheme):])
|
|
}
|
|
if strings.Contains(value, "@") && !strings.ContainsAny(value, " \t") {
|
|
return value
|
|
}
|
|
return ""
|
|
}
|