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
This commit is contained in:
xzcong0820
2026-05-19 14:02:01 +08:00
committed by GitHub
parent b8469d2dc6
commit e511404065
4 changed files with 213 additions and 0 deletions

View File

@@ -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 {

View File

@@ -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
// "normal" — absence of a priority header is the standard email convention
// for normal priority. 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 "normal"
}
// 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

View File

@@ -178,6 +178,170 @@ 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 <alice@example.com>
To: Bob <bob@example.com>
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 <alice@example.com>
To: Bob <bob@example.com>
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 TestProjectPriorityBothAbsentNormal(t *testing.T) {
// Neither header is present — default priority is normal.
snapshot := mustParseFixtureDraft(t, `Subject: no priority
From: Alice <alice@example.com>
To: Bob <bob@example.com>
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 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 <alice@example.com>
To: Bob <bob@example.com>
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 <alice@example.com>
To: Bob <bob@example.com>
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 <alice@example.com>
To: Bob <bob@example.com>
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 <alice@example.com>
To: Bob <bob@example.com>
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 <alice@example.com>
To: Bob <bob@example.com>
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 <alice@example.com>
To: Bob <bob@example.com>
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.

View File

@@ -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 != "" {
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",