diff --git a/shortcuts/mail/draft/model.go b/shortcuts/mail/draft/model.go index e4f190fb8..d0c89f4b7 100644 --- a/shortcuts/mail/draft/model.go +++ b/shortcuts/mail/draft/model.go @@ -166,6 +166,7 @@ type DraftProjection struct { 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 { diff --git a/shortcuts/mail/draft/projection.go b/shortcuts/mail/draft/projection.go index 277d68bc5..810133948 100644 --- a/shortcuts/mail/draft/projection.go +++ b/shortcuts/mail/draft/projection.go @@ -140,9 +140,53 @@ func Project(snapshot *DraftSnapshot) DraftProjection { proj.LargeAttachmentsSummary = projectLargeAttachments(snapshot.Headers, htmlBody) + proj.Priority = parsePriorityFromHeaders(snapshot.Headers) + return proj } +// parsePriorityFromHeaders derives the read-side priority projection from +// EML headers. It mirrors the write-side helper helpers.go:parsePriority +// (which translates --set-priority high|normal|low into set_header / +// remove_header X-Cli-Priority ops). Lookup order is case-insensitive +// via headerValue: +// 1. X-Cli-Priority (CLI/OAPI-specific header recognised by +// mail-data-access headersToPbBodyExtra) +// 2. X-Priority (RFC standard, fallback for IMAP-回灌 historical drafts) +// +// When neither header is present (including after the write-side translates +// --set-priority normal into remove_header X-Cli-Priority), this returns +// "unknown" — symmetric with the server-side BodyExtra.priority_type==nil +// "no signal" semantic. Agents cannot distinguish "explicitly normal" from +// "never set" — known limitation. +func parsePriorityFromHeaders(headers []Header) string { + if v := headerValue(headers, "X-Cli-Priority"); v != "" { + return mapPriorityValue(v) + } + if v := headerValue(headers, "X-Priority"); v != "" { + return mapPriorityValue(v) + } + return "unknown" +} + +// mapPriorityValue normalises a raw priority header value to the projection +// vocabulary {"high","normal","low","unknown"}. The accepted input table is +// kept in sync with backend gopkg/mail_priority.PriorityValueToType so that +// CLI read-side projection observes the same set of values the server +// recognises on write. +func mapPriorityValue(raw string) string { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "1", "high", "1 (highest)": + return "high" + case "3", "normal", "3 (normal)": + return "normal" + case "5", "low", "5 (lowest)": + return "low" + default: + return "unknown" + } +} + // projectLargeAttachments extracts large attachment info from the draft. // It first tries the server-format header (X-Lark-Large-Attachment) which // carries filename and size directly. Falls back to merging CLI-format diff --git a/shortcuts/mail/draft/projection_test.go b/shortcuts/mail/draft/projection_test.go index 3fe197eaf..6bd95049c 100644 --- a/shortcuts/mail/draft/projection_test.go +++ b/shortcuts/mail/draft/projection_test.go @@ -178,6 +178,171 @@ func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) { } } +// --------------------------------------------------------------------------- +// Priority projection (X-Cli-Priority primary, X-Priority fallback) +// --------------------------------------------------------------------------- + +func TestProjectPriorityXCliPriorityHigh(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: priority high +From: Alice +To: Bob +X-Cli-Priority: 1 +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +hello +`) + proj := Project(snapshot) + if proj.Priority != "high" { + t.Fatalf("Priority = %q, want %q", proj.Priority, "high") + } +} + +func TestProjectPriorityFallbackXPriorityLow(t *testing.T) { + // Only the standard X-Priority header is present (e.g. an IMAP-回灌 + // historical draft). The fallback path should kick in. + snapshot := mustParseFixtureDraft(t, `Subject: priority low (fallback) +From: Alice +To: Bob +X-Priority: 5 +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +hello +`) + proj := Project(snapshot) + if proj.Priority != "low" { + t.Fatalf("Priority = %q, want %q", proj.Priority, "low") + } +} + +func TestProjectPriorityBothAbsentUnknown(t *testing.T) { + // Neither header is present — symmetric with the write-side + // `--set-priority normal` semantic (remove_header X-Cli-Priority). + snapshot := mustParseFixtureDraft(t, `Subject: no priority +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +hello +`) + proj := Project(snapshot) + if proj.Priority != "unknown" { + t.Fatalf("Priority = %q, want %q", proj.Priority, "unknown") + } +} + +func TestProjectPriorityXCliPriorityOutlookStyleHigh(t *testing.T) { + // X-Cli-Priority set to the Outlook-style string "high" (any case). + snapshot := mustParseFixtureDraft(t, `Subject: priority high (string) +From: Alice +To: Bob +X-Cli-Priority: High +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +hello +`) + proj := Project(snapshot) + if proj.Priority != "high" { + t.Fatalf("Priority = %q, want %q", proj.Priority, "high") + } +} + +func TestProjectPriorityUnmappedValueUnknown(t *testing.T) { + // Value outside the recognised mapping table (e.g. "urgent") falls + // back to "unknown". + snapshot := mustParseFixtureDraft(t, `Subject: priority urgent +From: Alice +To: Bob +X-Cli-Priority: urgent +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +hello +`) + proj := Project(snapshot) + if proj.Priority != "unknown" { + t.Fatalf("Priority = %q, want %q", proj.Priority, "unknown") + } +} + +func TestProjectPriorityXCliPriorityWinsOverXPriority(t *testing.T) { + // X-Cli-Priority must take precedence over X-Priority when both are + // set (defensive: agent or upstream may write both). + snapshot := mustParseFixtureDraft(t, `Subject: both headers +From: Alice +To: Bob +X-Cli-Priority: 1 +X-Priority: 5 +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +hello +`) + proj := Project(snapshot) + if proj.Priority != "high" { + t.Fatalf("Priority = %q, want %q (X-Cli-Priority must win)", proj.Priority, "high") + } +} + +func TestProjectPriorityNormalThree(t *testing.T) { + // X-Cli-Priority=3 → "normal" (rare in CLI write path since + // `--set-priority normal` actually removes the header, but this case + // covers e.g. a draft set by another OAPI client that wrote 3). + snapshot := mustParseFixtureDraft(t, `Subject: priority three +From: Alice +To: Bob +X-Cli-Priority: 3 +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +hello +`) + proj := Project(snapshot) + if proj.Priority != "normal" { + t.Fatalf("Priority = %q, want %q", proj.Priority, "normal") + } +} + +func TestProjectPriorityFallbackXPriorityNormalString(t *testing.T) { + // IMAP-回灌 / external client writes the RFC-standard `X-Priority: Normal` + // string. The fallback path must project this as "normal" — symmetric with + // how `X-Priority: High` / `Low` are already handled. + snapshot := mustParseFixtureDraft(t, `Subject: priority normal (fallback) +From: Alice +To: Bob +X-Priority: Normal +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +hello +`) + proj := Project(snapshot) + if proj.Priority != "normal" { + t.Fatalf("Priority = %q, want %q", proj.Priority, "normal") + } +} + +func TestProjectPriorityOutlookStyleThreeNormal(t *testing.T) { + // Outlook-style `3 (Normal)` parenthesised form — symmetric with the + // already-supported `1 (Highest)` / `5 (Lowest)`. + snapshot := mustParseFixtureDraft(t, `Subject: priority three (normal) +From: Alice +To: Bob +X-Priority: 3 (Normal) +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +hello +`) + proj := Project(snapshot) + if proj.Priority != "normal" { + t.Fatalf("Priority = %q, want %q", proj.Priority, "normal") + } +} + func TestParseMissingInlineCIDReportedAsProjectionWarning(t *testing.T) { // Missing CID references should NOT prevent parsing; they are reported // as warnings in Project() instead. diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index 3ae2a411b..6666f0f79 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -293,6 +293,9 @@ func executeDraftInspect(runtime *common.RuntimeContext, mailboxID, draftID stri if len(projection.Warnings) > 0 { fmt.Fprintf(w, "warnings: %s\n", sanitizeForTerminal(strings.Join(projection.Warnings, "; "))) } + if projection.Priority != "" && projection.Priority != "unknown" { + fmt.Fprintf(w, "priority: %s\n", sanitizeForTerminal(projection.Priority)) + } }) return nil } @@ -553,6 +556,7 @@ func buildDraftEditPatchTemplate() map[string]interface{} { "`add_inline`/`replace_inline`/`remove_inline` are for CID-based inline images", "`replace_inline` keeps the original filename and content_type when those fields are omitted", "protected headers require `allow_protected_header_edits=true`", + "--set-priority high|normal|low controls draft priority via X-Cli-Priority header (CLI/OAPI specific). high → set_header X-Cli-Priority=1; low → set_header X-Cli-Priority=5; normal → remove_header X-Cli-Priority. Backend mail-data-access headersToPbBodyExtra recognizes X-Cli-Priority but not standard X-Priority/Importance for OAPI flow.", }, "command_example": "lark-cli mail +draft-edit --print-patch-template", "patch_file_example": "lark-cli mail +draft-edit --draft-id d_xxx --patch-file ./patch.json",