mirror of
https://github.com/larksuite/cli.git
synced 2026-07-08 18:13:01 +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
453 lines
13 KiB
Go
453 lines
13 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package draft
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"mime"
|
|
"net/mail"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/larksuite/cli/extension/fileio"
|
|
)
|
|
|
|
type DraftRaw struct {
|
|
DraftID string
|
|
RawEML string
|
|
}
|
|
|
|
type DraftResult struct {
|
|
DraftID string
|
|
Reference string
|
|
}
|
|
|
|
type Header struct {
|
|
Name string
|
|
Value string
|
|
}
|
|
|
|
type Address struct {
|
|
Name string `json:"name,omitempty"`
|
|
Address string `json:"address"`
|
|
}
|
|
|
|
func (a Address) String() string {
|
|
if a.Name == "" {
|
|
return a.Address
|
|
}
|
|
return (&mail.Address{Name: a.Name, Address: a.Address}).String()
|
|
}
|
|
|
|
type Part struct {
|
|
PartID string
|
|
|
|
Headers []Header
|
|
MediaType string
|
|
MediaParams map[string]string
|
|
ContentDisposition string
|
|
ContentDispositionArg map[string]string
|
|
ContentID string
|
|
TransferEncoding string
|
|
|
|
Children []*Part
|
|
Body []byte
|
|
|
|
Preamble []byte
|
|
Epilogue []byte
|
|
|
|
RawEntity []byte
|
|
Dirty bool
|
|
|
|
// EncodingProblem is set when the part's body could not be decoded as
|
|
// declared (e.g. malformed base64, bad charset, unparseable Content-Type).
|
|
// The part still contains usable data (raw bytes or fallback decode) and
|
|
// can round-trip through RawEntity, but callers should treat Body as
|
|
// potentially degraded.
|
|
EncodingProblem bool
|
|
}
|
|
|
|
func (p *Part) IsMultipart() bool {
|
|
return p != nil && strings.HasPrefix(strings.ToLower(p.MediaType), "multipart/")
|
|
}
|
|
|
|
func (p *Part) Clone() *Part {
|
|
if p == nil {
|
|
return nil
|
|
}
|
|
cp := *p
|
|
cp.Headers = append([]Header{}, p.Headers...)
|
|
cp.MediaParams = cloneStringMap(p.MediaParams)
|
|
cp.ContentDispositionArg = cloneStringMap(p.ContentDispositionArg)
|
|
cp.Body = append([]byte{}, p.Body...)
|
|
cp.Preamble = append([]byte{}, p.Preamble...)
|
|
cp.Epilogue = append([]byte{}, p.Epilogue...)
|
|
cp.RawEntity = append([]byte{}, p.RawEntity...)
|
|
cp.Dirty = p.Dirty
|
|
cp.Children = make([]*Part, 0, len(p.Children))
|
|
for _, child := range p.Children {
|
|
cp.Children = append(cp.Children, child.Clone())
|
|
}
|
|
return &cp
|
|
}
|
|
|
|
func (p *Part) FileName() string {
|
|
if p == nil {
|
|
return ""
|
|
}
|
|
if name := p.ContentDispositionArg["filename"]; name != "" {
|
|
return name
|
|
}
|
|
if name := p.MediaParams["name"]; name != "" {
|
|
return name
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// DraftCtx carries runtime dependencies for draft operations.
|
|
// It is separate from DraftSnapshot to keep the snapshot a pure data model.
|
|
type DraftCtx struct {
|
|
FIO fileio.FileIO
|
|
}
|
|
|
|
type DraftSnapshot struct {
|
|
DraftID string
|
|
Headers []Header
|
|
Body *Part
|
|
|
|
Subject string
|
|
From []Address
|
|
To []Address
|
|
Cc []Address
|
|
Bcc []Address
|
|
ReplyTo []Address
|
|
MessageID string
|
|
InReplyTo string
|
|
References string
|
|
|
|
PrimaryTextPartID string
|
|
PrimaryHTMLPartID string
|
|
}
|
|
|
|
type PartSummary struct {
|
|
PartID string `json:"part_id"`
|
|
FileName string `json:"filename,omitempty"`
|
|
ContentType string `json:"content_type,omitempty"`
|
|
Disposition string `json:"disposition,omitempty"`
|
|
CID string `json:"cid,omitempty"`
|
|
}
|
|
|
|
// LargeAttachmentSummary describes a single large attachment registered in
|
|
// the draft via the X-Lms-Large-Attachment-Ids header. Unlike normal
|
|
// attachments, large attachments have no MIME part — their existence is
|
|
// conveyed by the header plus an HTML card in the body.
|
|
type LargeAttachmentSummary struct {
|
|
Token string `json:"token"`
|
|
FileName string `json:"filename,omitempty"`
|
|
SizeBytes int64 `json:"size_bytes,omitempty"`
|
|
}
|
|
|
|
type DraftProjection struct {
|
|
Subject string `json:"subject"`
|
|
To []Address `json:"to,omitempty"`
|
|
Cc []Address `json:"cc,omitempty"`
|
|
Bcc []Address `json:"bcc,omitempty"`
|
|
ReplyTo []Address `json:"reply_to,omitempty"`
|
|
InReplyTo string `json:"in_reply_to,omitempty"`
|
|
References string `json:"references,omitempty"`
|
|
BodyText string `json:"body_text,omitempty"`
|
|
BodyHTMLSummary string `json:"body_html_summary,omitempty"`
|
|
HasQuotedContent bool `json:"has_quoted_content,omitempty"`
|
|
HasSignature bool `json:"has_signature,omitempty"`
|
|
SignatureID string `json:"signature_id,omitempty"`
|
|
AttachmentsSummary []PartSummary `json:"attachments_summary,omitempty"`
|
|
LargeAttachmentsSummary []LargeAttachmentSummary `json:"large_attachments_summary,omitempty"`
|
|
InlineSummary []PartSummary `json:"inline_summary,omitempty"`
|
|
Warnings []string `json:"warnings,omitempty"`
|
|
}
|
|
|
|
type Patch struct {
|
|
Ops []PatchOp `json:"ops"`
|
|
Options PatchOptions `json:"options,omitempty"`
|
|
}
|
|
|
|
type PatchOptions struct {
|
|
RewriteEntireDraft bool `json:"rewrite_entire_draft,omitempty"`
|
|
AllowProtectedHeaderEdits bool `json:"allow_protected_header_edits,omitempty"`
|
|
}
|
|
|
|
type AttachmentTarget struct {
|
|
PartID string `json:"part_id,omitempty"`
|
|
CID string `json:"cid,omitempty"`
|
|
// Token selects a large attachment by its file token (registered via
|
|
// the X-Lms-Large-Attachment-Ids header). Only valid for
|
|
// remove_attachment; replace_inline/remove_inline operate on MIME
|
|
// parts and do not accept Token.
|
|
Token string `json:"token,omitempty"`
|
|
}
|
|
|
|
// hasKey reports whether a PartID or CID is set. Used for ops that
|
|
// target MIME parts (replace_inline, remove_inline).
|
|
func (t AttachmentTarget) hasKey() bool {
|
|
return strings.TrimSpace(t.PartID) != "" || strings.TrimSpace(t.CID) != ""
|
|
}
|
|
|
|
// hasAnyKey reports whether any locator (PartID, CID, or Token) is set.
|
|
// Used for remove_attachment which supports all three.
|
|
func (t AttachmentTarget) hasAnyKey() bool {
|
|
return t.hasKey() || strings.TrimSpace(t.Token) != ""
|
|
}
|
|
|
|
type PatchOp struct {
|
|
Op string `json:"op"`
|
|
Value string `json:"value,omitempty"`
|
|
Field string `json:"field,omitempty"`
|
|
Address string `json:"address,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Addresses []Address `json:"addresses,omitempty"`
|
|
BodyKind string `json:"body_kind,omitempty"`
|
|
Selector string `json:"selector,omitempty"`
|
|
Path string `json:"path,omitempty"`
|
|
CID string `json:"cid,omitempty"`
|
|
FileName string `json:"filename,omitempty"`
|
|
ContentType string `json:"content_type,omitempty"`
|
|
Target AttachmentTarget `json:"target,omitempty"`
|
|
SignatureID string `json:"signature_id,omitempty"`
|
|
|
|
// Calendar event fields, used by set_calendar. The raw ISO 8601 strings
|
|
// are shown in dry-run output; the shortcut layer pre-builds the ICS
|
|
// blob into CalendarICS below before Apply runs.
|
|
EventSummary string `json:"event_summary,omitempty"`
|
|
EventStart string `json:"event_start,omitempty"`
|
|
EventEnd string `json:"event_end,omitempty"`
|
|
EventLocation string `json:"event_location,omitempty"`
|
|
|
|
// RenderedSignatureHTML is set by the shortcut layer (not from JSON) after
|
|
// fetching and interpolating the signature. The patch layer uses this
|
|
// pre-rendered content for insert_signature ops.
|
|
RenderedSignatureHTML string `json:"-"`
|
|
SignatureImages []SignatureImage `json:"-"`
|
|
|
|
// CalendarICS holds the pre-built RFC 5545 ICS blob for a set_calendar
|
|
// op. Populated by the shortcut layer after the snapshot is parsed and
|
|
// organizer/attendee addresses can be resolved. Not serialised.
|
|
CalendarICS []byte `json:"-"`
|
|
}
|
|
|
|
// SignatureImage holds pre-downloaded image data for signature inline images.
|
|
// Populated by the shortcut layer, consumed by the patch layer.
|
|
type SignatureImage struct {
|
|
CID string
|
|
ContentType string
|
|
FileName string
|
|
Data []byte
|
|
}
|
|
|
|
func (p Patch) Validate() error {
|
|
if len(p.Ops) == 0 {
|
|
return fmt.Errorf("patch ops is required")
|
|
}
|
|
for i, op := range p.Ops {
|
|
if err := op.Validate(); err != nil {
|
|
return fmt.Errorf("invalid patch op #%d: %w", i+1, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (op PatchOp) Validate() error {
|
|
switch op.Op {
|
|
case "set_subject":
|
|
if strings.TrimSpace(op.Value) == "" {
|
|
return fmt.Errorf("set_subject requires value")
|
|
}
|
|
if strings.ContainsAny(op.Value, "\r\n") {
|
|
return fmt.Errorf("set_subject: value must not contain CR or LF")
|
|
}
|
|
case "set_recipients":
|
|
if !isRecipientField(op.Field) {
|
|
return fmt.Errorf("recipient field must be one of to/cc/bcc")
|
|
}
|
|
for _, addr := range op.Addresses {
|
|
if strings.TrimSpace(addr.Address) == "" {
|
|
return fmt.Errorf("set_recipients requires non-empty addresses")
|
|
}
|
|
}
|
|
case "add_recipient", "remove_recipient":
|
|
if !isRecipientField(op.Field) {
|
|
return fmt.Errorf("recipient field must be one of to/cc/bcc")
|
|
}
|
|
if strings.TrimSpace(op.Address) == "" {
|
|
return fmt.Errorf("%s requires address", op.Op)
|
|
}
|
|
case "set_reply_to":
|
|
if len(op.Addresses) == 0 {
|
|
return fmt.Errorf("set_reply_to requires addresses")
|
|
}
|
|
case "clear_reply_to":
|
|
case "set_body", "set_reply_body":
|
|
case "replace_body", "append_body":
|
|
if !isBodyKind(op.BodyKind) {
|
|
return fmt.Errorf("body_kind must be text/plain or text/html")
|
|
}
|
|
if op.Selector != "" && op.Selector != "primary" {
|
|
return fmt.Errorf("selector must be primary")
|
|
}
|
|
case "set_header":
|
|
if strings.TrimSpace(op.Name) == "" {
|
|
return fmt.Errorf("set_header requires name")
|
|
}
|
|
if strings.ContainsAny(op.Name, ":\r\n") {
|
|
return fmt.Errorf("set_header: header name must not contain ':', CR, or LF")
|
|
}
|
|
if strings.ContainsAny(op.Value, "\r\n") {
|
|
return fmt.Errorf("set_header: header value must not contain CR or LF")
|
|
}
|
|
case "remove_header":
|
|
if strings.TrimSpace(op.Name) == "" {
|
|
return fmt.Errorf("remove_header requires name")
|
|
}
|
|
case "add_attachment":
|
|
if strings.TrimSpace(op.Path) == "" {
|
|
return fmt.Errorf("add_attachment requires path")
|
|
}
|
|
case "remove_attachment":
|
|
if !op.Target.hasAnyKey() {
|
|
return fmt.Errorf("remove_attachment requires target with at least one of part_id, cid, or token")
|
|
}
|
|
case "add_inline":
|
|
if strings.TrimSpace(op.Path) == "" {
|
|
return fmt.Errorf("add_inline requires path")
|
|
}
|
|
if strings.TrimSpace(op.CID) == "" {
|
|
return fmt.Errorf("add_inline requires cid")
|
|
}
|
|
case "replace_inline":
|
|
if !op.Target.hasKey() {
|
|
return fmt.Errorf("replace_inline requires target with at least one of part_id or cid")
|
|
}
|
|
if strings.TrimSpace(op.Path) == "" {
|
|
return fmt.Errorf("replace_inline requires path")
|
|
}
|
|
case "remove_inline":
|
|
if !op.Target.hasKey() {
|
|
return fmt.Errorf("remove_inline requires target with at least one of part_id or cid")
|
|
}
|
|
case "insert_signature":
|
|
if strings.TrimSpace(op.SignatureID) == "" {
|
|
return fmt.Errorf("insert_signature requires signature_id")
|
|
}
|
|
case "remove_signature":
|
|
// No required fields.
|
|
case "set_calendar":
|
|
if strings.TrimSpace(op.EventSummary) == "" {
|
|
return fmt.Errorf("set_calendar requires event_summary")
|
|
}
|
|
if strings.TrimSpace(op.EventStart) == "" || strings.TrimSpace(op.EventEnd) == "" {
|
|
return fmt.Errorf("set_calendar requires event_start and event_end")
|
|
}
|
|
start, err := parseISO8601(op.EventStart)
|
|
if err != nil {
|
|
return fmt.Errorf("set_calendar: event_start must be a valid ISO 8601 timestamp")
|
|
}
|
|
end, err := parseISO8601(op.EventEnd)
|
|
if err != nil {
|
|
return fmt.Errorf("set_calendar: event_end must be a valid ISO 8601 timestamp")
|
|
}
|
|
if !end.After(start) {
|
|
return fmt.Errorf("set_calendar: event_end must be after event_start")
|
|
}
|
|
case "remove_calendar":
|
|
// No required fields.
|
|
default:
|
|
return fmt.Errorf("unsupported op %q", op.Op)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isRecipientField(field string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(field)) {
|
|
case "to", "cc", "bcc":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isBodyKind(kind string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(kind)) {
|
|
case "text/plain", "text/html":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func cloneStringMap(in map[string]string) map[string]string {
|
|
if len(in) == 0 {
|
|
return nil
|
|
}
|
|
out := make(map[string]string, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func formatAddressList(addrs []Address) string {
|
|
parts := make([]string, 0, len(addrs))
|
|
for _, addr := range addrs {
|
|
if strings.TrimSpace(addr.Address) == "" {
|
|
continue
|
|
}
|
|
parts = append(parts, addr.String())
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func decodeHeaderValue(value string) string {
|
|
dec := new(mime.WordDecoder)
|
|
decoded, err := dec.DecodeHeader(value)
|
|
if err != nil {
|
|
return value
|
|
}
|
|
return decoded
|
|
}
|
|
|
|
func (p Patch) Summary() map[string]interface{} {
|
|
out := map[string]interface{}{
|
|
"ops": p.Ops,
|
|
"warnings": []string{"该编辑链路不具备乐观锁保护;若草稿被并发修改,后写入者会覆盖前者"},
|
|
}
|
|
if p.Options != (PatchOptions{}) {
|
|
out["options"] = p.Options
|
|
}
|
|
return out
|
|
}
|
|
|
|
func MustJSON(v interface{}) string {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(fmt.Sprintf("MustJSON: %v", err))
|
|
}
|
|
return string(data)
|
|
}
|
|
|
|
// parseISO8601 tries common ISO 8601 timestamp layouts, accepting both
|
|
// with-seconds (RFC 3339) and without-seconds variants.
|
|
func parseISO8601(s string) (time.Time, error) {
|
|
for _, layout := range []string{
|
|
time.RFC3339,
|
|
"2006-01-02T15:04Z07:00",
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02T15:04",
|
|
} {
|
|
if t, err := time.Parse(layout, s); err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
return time.Time{}, fmt.Errorf("cannot parse %q as ISO 8601", s)
|
|
}
|