Files
larksuite-cli/shortcuts/mail/draft/model.go
chanthuang dce2beb91c feat(mail): support calendar events in emails (#646)
* 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
2026-04-29 15:31:38 +08:00

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)
}