mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +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
189 lines
5.5 KiB
Go
189 lines
5.5 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package draft
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
const calendarMediaType = "text/calendar"
|
|
|
|
// applyCalendarSet installs or replaces the text/calendar MIME part in the
|
|
// snapshot. The caller is expected to have pre-built icsData using the
|
|
// snapshot's From/To/Cc addresses.
|
|
func applyCalendarSet(snapshot *DraftSnapshot, icsData []byte) error {
|
|
if len(icsData) == 0 {
|
|
return fmt.Errorf("set_calendar: ICS data is empty (shortcut layer must pre-build it)")
|
|
}
|
|
setCalendarPart(snapshot, icsData)
|
|
return nil
|
|
}
|
|
|
|
// applyCalendarRemove strips the text/calendar part from the snapshot.
|
|
// No-op if no calendar part exists.
|
|
func applyCalendarRemove(snapshot *DraftSnapshot) error {
|
|
removeCalendarPart(snapshot)
|
|
return nil
|
|
}
|
|
|
|
// setCalendarPart places exactly one text/calendar part inside
|
|
// multipart/alternative, matching the Feishu client behavior. Any existing
|
|
// text/calendar parts elsewhere in the tree are removed first.
|
|
func setCalendarPart(snapshot *DraftSnapshot, icsData []byte) {
|
|
newPart := &Part{
|
|
MediaType: calendarMediaType,
|
|
MediaParams: map[string]string{"charset": "UTF-8", "method": "REQUEST"},
|
|
Body: icsData,
|
|
Dirty: true,
|
|
}
|
|
|
|
if snapshot.Body == nil {
|
|
snapshot.Body = newPart
|
|
return
|
|
}
|
|
|
|
// Remove all existing text/calendar parts from everywhere in the tree.
|
|
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
|
|
snapshot.Body = newPart
|
|
return
|
|
}
|
|
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
|
|
|
|
// Place inside the existing multipart/alternative.
|
|
if alt := FindPartByMediaType(snapshot.Body, "multipart/alternative"); alt != nil {
|
|
alt.Children = append(alt.Children, newPart)
|
|
alt.Dirty = true
|
|
return
|
|
}
|
|
|
|
// No multipart/alternative exists. If the body is a single leaf,
|
|
// wrap it in multipart/alternative together with the calendar.
|
|
if !snapshot.Body.IsMultipart() {
|
|
original := *snapshot.Body
|
|
// Reset all header-carrying fields so the serializer constructs a fresh
|
|
// Content-Type from MediaType instead of reusing the stale leaf headers.
|
|
snapshot.Body.Headers = nil
|
|
snapshot.Body.MediaType = "multipart/alternative"
|
|
snapshot.Body.MediaParams = nil
|
|
snapshot.Body.ContentDisposition = ""
|
|
snapshot.Body.ContentDispositionArg = nil
|
|
snapshot.Body.ContentID = ""
|
|
snapshot.Body.PartID = ""
|
|
snapshot.Body.Body = nil
|
|
snapshot.Body.TransferEncoding = ""
|
|
snapshot.Body.RawEntity = nil
|
|
snapshot.Body.Preamble = nil
|
|
snapshot.Body.Epilogue = nil
|
|
snapshot.Body.EncodingProblem = false
|
|
snapshot.Body.Children = []*Part{&original, newPart}
|
|
snapshot.Body.Dirty = true
|
|
return
|
|
}
|
|
|
|
// Multipart body without an alternative sub-part (e.g. multipart/mixed
|
|
// with a text/html child). Find the first text/* child and wrap it in
|
|
// a new multipart/alternative that also contains the calendar.
|
|
for i, child := range snapshot.Body.Children {
|
|
if child != nil && strings.HasPrefix(strings.ToLower(child.MediaType), "text/") {
|
|
alt := &Part{
|
|
MediaType: "multipart/alternative",
|
|
Children: []*Part{child, newPart},
|
|
Dirty: true,
|
|
}
|
|
snapshot.Body.Children[i] = alt
|
|
snapshot.Body.Dirty = true
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fallback: append to the root multipart container.
|
|
snapshot.Body.Children = append(snapshot.Body.Children, newPart)
|
|
snapshot.Body.Dirty = true
|
|
}
|
|
|
|
func removeCalendarPart(snapshot *DraftSnapshot) {
|
|
if snapshot.Body == nil {
|
|
return
|
|
}
|
|
if strings.EqualFold(snapshot.Body.MediaType, calendarMediaType) {
|
|
snapshot.Body = nil
|
|
return
|
|
}
|
|
removeAllPartsByMediaType(snapshot.Body, calendarMediaType)
|
|
}
|
|
|
|
// FindPartByMediaType walks the MIME tree and returns the first part with
|
|
// the given media type, or nil when not found.
|
|
func FindPartByMediaType(root *Part, mediaType string) *Part {
|
|
if root == nil {
|
|
return nil
|
|
}
|
|
if strings.EqualFold(root.MediaType, mediaType) {
|
|
return root
|
|
}
|
|
for _, child := range root.Children {
|
|
if found := FindPartByMediaType(child, mediaType); found != nil {
|
|
return found
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// findAllPartsByMediaType walks the MIME tree and returns every part with
|
|
// the given media type. Used in tests to assert tree contents.
|
|
func findAllPartsByMediaType(root *Part, mediaType string) []*Part {
|
|
if root == nil {
|
|
return nil
|
|
}
|
|
var result []*Part
|
|
if strings.EqualFold(root.MediaType, mediaType) {
|
|
result = append(result, root)
|
|
}
|
|
for _, child := range root.Children {
|
|
result = append(result, findAllPartsByMediaType(child, mediaType)...)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// removePartByMediaType removes the first part with the given media type from
|
|
// the MIME tree. The parent is marked dirty when a removal happens.
|
|
func removePartByMediaType(root *Part, mediaType string) {
|
|
if root == nil {
|
|
return
|
|
}
|
|
for i, child := range root.Children {
|
|
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
|
|
root.Children = append(root.Children[:i], root.Children[i+1:]...)
|
|
root.Dirty = true
|
|
return
|
|
}
|
|
removePartByMediaType(child, mediaType)
|
|
}
|
|
}
|
|
|
|
// removeAllPartsByMediaType removes every part with the given media type from
|
|
// the MIME tree, at all nesting levels.
|
|
func removeAllPartsByMediaType(root *Part, mediaType string) {
|
|
if root == nil {
|
|
return
|
|
}
|
|
var kept []*Part
|
|
removed := false
|
|
for _, child := range root.Children {
|
|
if child != nil && strings.EqualFold(child.MediaType, mediaType) {
|
|
removed = true
|
|
continue
|
|
}
|
|
kept = append(kept, child)
|
|
}
|
|
if removed {
|
|
root.Children = kept
|
|
root.Dirty = true
|
|
}
|
|
for _, child := range root.Children {
|
|
removeAllPartsByMediaType(child, mediaType)
|
|
}
|
|
}
|