Files
larksuite-cli/shortcuts/mail/draft/model.go
xzcong0820 e511404065 feat(mail): expose draft priority in --inspect projection and document --set-priority (#779)
Add a Priority field to DraftProjection populated from the EML header pair
X-Cli-Priority (CLI/OAPI primary) → X-Priority (RFC fallback for IMAP-回灌
historical drafts), with case-insensitive lookup via the existing
headerValue helper and a local mapping table aligned with the backend
gopkg/mail_priority.PriorityValueToType vocabulary. When neither header is
present (the symmetric read of --set-priority normal=remove_header) the
projection emits "unknown" so agents have a stable read-side surface.

Append one notes entry to buildDraftEditPatchTemplate documenting the
--set-priority flag and the X-Cli-Priority translation contract.

The write-side (--set-priority flag, parsePriority helper, translation
branch in mail_draft_edit.go, EML header target) is unchanged — already
shipped on master.

sprint: S4
2026-05-19 14:02:01 +08:00

454 lines
14 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"`
Priority string `json:"priority"`
}
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)
}