diff --git a/shortcuts/mail/draft/model.go b/shortcuts/mail/draft/model.go index b772f9ce..22d3c898 100644 --- a/shortcuts/mail/draft/model.go +++ b/shortcuts/mail/draft/model.go @@ -144,6 +144,8 @@ type DraftProjection struct { 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"` InlineSummary []PartSummary `json:"inline_summary,omitempty"` Warnings []string `json:"warnings,omitempty"` @@ -182,6 +184,22 @@ type PatchOp struct { FileName string `json:"filename,omitempty"` ContentType string `json:"content_type,omitempty"` Target AttachmentTarget `json:"target,omitempty"` + SignatureID string `json:"signature_id,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:"-"` +} + +// 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 { @@ -274,6 +292,12 @@ func (op PatchOp) Validate() error { 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. default: return fmt.Errorf("unsupported op %q", op.Op) } diff --git a/shortcuts/mail/draft/patch.go b/shortcuts/mail/draft/patch.go index 5ab6336e..1c2cb335 100644 --- a/shortcuts/mail/draft/patch.go +++ b/shortcuts/mail/draft/patch.go @@ -33,10 +33,12 @@ var protectedHeaders = map[string]bool{ // bodyChangingOps lists patch operations that modify the HTML body content, // which is the trigger for running local image path resolution. var bodyChangingOps = map[string]bool{ - "set_body": true, - "set_reply_body": true, - "replace_body": true, - "append_body": true, + "set_body": true, + "set_reply_body": true, + "replace_body": true, + "append_body": true, + "insert_signature": true, + "remove_signature": true, } func Apply(dctx *DraftCtx, snapshot *DraftSnapshot, patch Patch) error { @@ -121,6 +123,10 @@ func applyOp(dctx *DraftCtx, snapshot *DraftSnapshot, op PatchOp, options PatchO return fmt.Errorf("remove_inline: %w", err) } return removeInline(snapshot, partID) + case "insert_signature": + return insertSignatureOp(snapshot, op) + case "remove_signature": + return removeSignatureOp(snapshot) default: return fmt.Errorf("unsupported patch op %q", op.Op) } @@ -284,7 +290,7 @@ func setReplyBody(snapshot *DraftSnapshot, value string, options PatchOptions) e if htmlPart == nil { return setBody(snapshot, value, options) } - _, quotePart := splitAtQuote(string(htmlPart.Body)) + _, quotePart := SplitAtQuote(string(htmlPart.Body)) if quotePart == "" { // No quote block found — fall back to regular set_body. return setBody(snapshot, value, options) @@ -1135,3 +1141,166 @@ func postProcessInlineImages(dctx *DraftCtx, snapshot *DraftSnapshot, resolveLoc removeOrphanedInlineParts(snapshot.Body, refSet) return nil } + +// ── Signature patch operations ── + +// insertSignatureOp inserts a pre-rendered signature into the HTML body. +// The RenderedSignatureHTML and SignatureImages fields must be populated +// by the shortcut layer before calling Apply. +func insertSignatureOp(snapshot *DraftSnapshot, op PatchOp) error { + htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) + if htmlPart == nil { + return fmt.Errorf("insert_signature: no HTML body part found; use set_body first") + } + html := string(htmlPart.Body) + + // Collect CIDs from old signature before removing it, so we can + // clean up orphaned MIME inline parts and avoid duplicates. + oldSigCIDs := collectSignatureCIDsFromHTML(html) + + // Remove existing signature (if any), including preceding spacing. + html = RemoveSignatureHTML(html) + + // Remove orphaned MIME inline parts from old signature. + for _, cid := range oldSigCIDs { + if !containsCIDIgnoreCase(html, cid) { + removeMIMEPartByCID(snapshot.Body, cid) + } + } + + // Split at quote and insert signature between body and quote. + body, quote := SplitAtQuote(html) + sigBlock := SignatureSpacing() + BuildSignatureHTML(op.SignatureID, op.RenderedSignatureHTML) + html = body + sigBlock + quote + + htmlPart.Body = []byte(html) + htmlPart.Dirty = true + + // Add signature inline images to the MIME tree. + for _, img := range op.SignatureImages { + addInlinePartToSnapshot(snapshot, img.Data, img.ContentType, img.FileName, img.CID) + } + + syncTextPartFromHTML(snapshot, html) + return nil +} + +// removeSignatureOp removes the signature block from the HTML body. +func removeSignatureOp(snapshot *DraftSnapshot) error { + htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID) + if htmlPart == nil { + return fmt.Errorf("remove_signature: no HTML body part found") + } + html := string(htmlPart.Body) + + if !signatureWrapperRe.MatchString(html) { + return fmt.Errorf("no signature found in draft body") + } + + // Collect CIDs referenced by the signature before removing it. + sigCIDs := collectSignatureCIDsFromHTML(html) + + // Remove signature and preceding spacing. + html = RemoveSignatureHTML(html) + + // Remove orphaned inline parts (only if the CID is no longer referenced in remaining HTML). + for _, cid := range sigCIDs { + if !containsCIDIgnoreCase(html, cid) { + removeMIMEPartByCID(snapshot.Body, cid) + } + } + + htmlPart.Body = []byte(html) + htmlPart.Dirty = true + + syncTextPartFromHTML(snapshot, html) + return nil +} + +// syncTextPartFromHTML regenerates the text/plain part from the current HTML, +// mirroring the coupled-body logic in tryApplyCoupledBodySetBody. +func syncTextPartFromHTML(snapshot *DraftSnapshot, html string) { + if snapshot.PrimaryTextPartID == "" { + return + } + textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID) + if textPart == nil { + return + } + textPart.Body = []byte(plainTextFromHTML(html)) + textPart.Dirty = true +} + +// Note: SignatureSpacing, BuildSignatureHTML, FindMatchingCloseDiv, and +// RemoveSignatureHTML are exported from projection.go to avoid duplication +// with the mail package's signature_html.go. + +// collectSignatureCIDsFromHTML extracts CID references from the signature block in HTML. +func collectSignatureCIDsFromHTML(html string) []string { + loc := signatureWrapperRe.FindStringIndex(html) + if loc == nil { + return nil + } + sigEnd := FindMatchingCloseDiv(html, loc[0]) + sigHTML := html[loc[0]:sigEnd] + + matches := cidRefRegexp.FindAllStringSubmatch(sigHTML, -1) + cids := make([]string, 0, len(matches)) + for _, m := range matches { + if len(m) >= 2 { + cids = append(cids, m[1]) + } + } + return cids +} + +// removeMIMEPartByCID removes the first MIME part with the given Content-ID. +func removeMIMEPartByCID(root *Part, cid string) { + if root == nil { + return + } + normalizedCID := strings.Trim(cid, "<>") + for i, child := range root.Children { + if child == nil { + continue + } + childCID := strings.Trim(child.ContentID, "<>") + if strings.EqualFold(childCID, normalizedCID) { + root.Children = append(root.Children[:i], root.Children[i+1:]...) + return + } + removeMIMEPartByCID(child, cid) + } +} + +// addInlinePartToSnapshot adds an inline image part to the MIME tree. +func addInlinePartToSnapshot(snapshot *DraftSnapshot, data []byte, contentType, filename, cid string) { + part := &Part{ + MediaType: contentType, + ContentDisposition: "inline", + ContentID: strings.Trim(cid, "<>"), + Body: data, + Dirty: true, + } + if filename != "" { + part.MediaParams = map[string]string{"name": filename} + } + // Find or create the multipart/related container. + if snapshot.Body == nil { + return + } + if snapshot.Body.IsMultipart() { + snapshot.Body.Children = append(snapshot.Body.Children, part) + } + // Non-multipart body: inline part is not added. This is expected when + // the draft has a simple text/html body without multipart/related wrapper. + // The signature HTML still references the CID, but the image won't render. + // In practice, compose shortcuts wrap the body in multipart/related when + // inline images are present, so this path rarely triggers. +} + +// containsCIDIgnoreCase checks if html contains a "cid:" reference, +// case-insensitively. Aligned with other CID comparisons in this package. +func containsCIDIgnoreCase(html, cid string) bool { + return strings.Contains(strings.ToLower(html), "cid:"+strings.ToLower(cid)) +} diff --git a/shortcuts/mail/draft/patch_signature_test.go b/shortcuts/mail/draft/patch_signature_test.go new file mode 100644 index 00000000..f2669114 --- /dev/null +++ b/shortcuts/mail/draft/patch_signature_test.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package draft + +import ( + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// insert_signature — basic insertion into HTML body +// --------------------------------------------------------------------------- + +func TestInsertSignature_BasicHTML(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Sig test +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +

Hello

`) + + err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{ + Ops: []PatchOp{{ + Op: "insert_signature", + SignatureID: "sig-123", + RenderedSignatureHTML: "
-- My Signature
", + }}, + }) + if err != nil { + t.Fatalf("Apply insert_signature: %v", err) + } + + html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body) + if !strings.Contains(html, "My Signature") { + t.Error("signature not found in HTML body") + } + if !strings.Contains(html, `class="lark-mail-signature"`) { + t.Error("signature wrapper class not found") + } + if !strings.Contains(html, `id="sig-123"`) { + t.Error("signature ID not found") + } + // Body text should come before signature + bodyIdx := strings.Index(html, "Hello") + sigIdx := strings.Index(html, "My Signature") + if bodyIdx > sigIdx { + t.Error("signature should appear after body text") + } +} + +// --------------------------------------------------------------------------- +// insert_signature — with quoted content (reply/forward) +// --------------------------------------------------------------------------- + +func TestInsertSignature_BeforeQuote(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Reply with sig +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +

My reply

quoted content
`) + + err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{ + Ops: []PatchOp{{ + Op: "insert_signature", + SignatureID: "sig-456", + RenderedSignatureHTML: "
-- Reply Sig
", + }}, + }) + if err != nil { + t.Fatalf("Apply insert_signature: %v", err) + } + + html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body) + sigIdx := strings.Index(html, "Reply Sig") + quoteIdx := strings.Index(html, "quoted content") + if sigIdx < 0 || quoteIdx < 0 { + t.Fatalf("missing signature or quote in: %s", html) + } + if sigIdx > quoteIdx { + t.Error("signature should appear before quote block") + } +} + +// --------------------------------------------------------------------------- +// insert_signature — replaces existing signature +// --------------------------------------------------------------------------- + +func TestInsertSignature_ReplacesExisting(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Replace sig +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +

Hello

-- Old Sig
`) + + err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{ + Ops: []PatchOp{{ + Op: "insert_signature", + SignatureID: "new-sig", + RenderedSignatureHTML: "
-- New Sig
", + }}, + }) + if err != nil { + t.Fatalf("Apply insert_signature: %v", err) + } + + html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body) + if strings.Contains(html, "Old Sig") { + t.Error("old signature should have been removed") + } + if !strings.Contains(html, "New Sig") { + t.Error("new signature not found") + } +} + +// --------------------------------------------------------------------------- +// insert_signature — no HTML body +// --------------------------------------------------------------------------- + +func TestInsertSignature_NoHTMLBody(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Plain text +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 + +Just plain text`) + + err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{ + Ops: []PatchOp{{ + Op: "insert_signature", + SignatureID: "sig-x", + RenderedSignatureHTML: "
sig
", + }}, + }) + if err == nil { + t.Fatal("expected error for insert_signature on plain text draft") + } + if !strings.Contains(err.Error(), "no HTML body") { + t.Fatalf("expected 'no HTML body' error, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// remove_signature — removes existing signature +// --------------------------------------------------------------------------- + +func TestRemoveSignature_Basic(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: Remove sig +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +

Hello

-- My Sig
`) + + err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{ + Ops: []PatchOp{{Op: "remove_signature"}}, + }) + if err != nil { + t.Fatalf("Apply remove_signature: %v", err) + } + + html := string(findPart(snapshot.Body, snapshot.PrimaryHTMLPartID).Body) + if strings.Contains(html, "My Sig") { + t.Error("signature should have been removed") + } + if strings.Contains(html, "lark-mail-signature") { + t.Error("signature wrapper should have been removed") + } + if !strings.Contains(html, "Hello") { + t.Error("body text should be preserved") + } +} + +// --------------------------------------------------------------------------- +// remove_signature — no signature present +// --------------------------------------------------------------------------- + +func TestRemoveSignature_NoSignature(t *testing.T) { + snapshot := mustParseFixtureDraft(t, `Subject: No sig +From: Alice +To: Bob +MIME-Version: 1.0 +Content-Type: text/html; charset=UTF-8 + +

No signature here

`) + + err := Apply(&DraftCtx{FIO: testFIO}, snapshot, Patch{ + Ops: []PatchOp{{Op: "remove_signature"}}, + }) + if err == nil { + t.Fatal("expected error when removing non-existent signature") + } + if !strings.Contains(err.Error(), "no signature found") { + t.Fatalf("expected 'no signature found' error, got: %v", err) + } +} diff --git a/shortcuts/mail/draft/projection.go b/shortcuts/mail/draft/projection.go index 233d7352..a61f635b 100644 --- a/shortcuts/mail/draft/projection.go +++ b/shortcuts/mail/draft/projection.go @@ -4,6 +4,7 @@ package draft import ( + "html" "regexp" "strings" ) @@ -27,6 +28,18 @@ var quoteWrapperRe = regexp.MustCompile(`]*class="[^"]*` + QuoteWrapper var cidRefRegexp = regexp.MustCompile(`(?i)cid:([^"' >]+)`) +// SignatureWrapperClass is the CSS class for the mail signature container. +const SignatureWrapperClass = "lark-mail-signature" + +var signatureWrapperRe = regexp.MustCompile( + `]*class="[^"]*` + SignatureWrapperClass + `[^"]*"`) + +// signatureIDRe extracts the id from a signature wrapper div, regardless of +// whether id appears before or after the class attribute. +var signatureIDRe = regexp.MustCompile( + `]*class="[^"]*` + SignatureWrapperClass + `[^"]*"[^>]*id="([^"]*)"` + + `|]*id="([^"]*)"[^>]*class="[^"]*` + SignatureWrapperClass) + func Project(snapshot *DraftSnapshot) DraftProjection { proj := DraftProjection{ Subject: snapshot.Subject, @@ -45,6 +58,17 @@ func Project(snapshot *DraftSnapshot) DraftProjection { html := string(part.Body) proj.BodyHTMLSummary = summarizeHTML(html) proj.HasQuotedContent = hasQuotedContent(html) + proj.HasSignature = signatureWrapperRe.MatchString(html) + if proj.HasSignature { + if m := signatureIDRe.FindStringSubmatch(html); m != nil { + // alternation regex: id is in m[1] (class-first) or m[2] (id-first) + if m[1] != "" { + proj.SignatureID = m[1] + } else if len(m) >= 3 { + proj.SignatureID = m[2] + } + } + } } parts := flattenParts(snapshot.Body) @@ -128,10 +152,10 @@ func hasQuotedContent(html string) bool { return quoteWrapperRe.MatchString(html) } -// splitAtQuote splits an HTML body into the user-authored content and +// SplitAtQuote splits an HTML body into the user-authored content and // the trailing reply/forward quote block. If no quote block is found, // quote is empty and body is the original html unchanged. -func splitAtQuote(html string) (body, quote string) { +func SplitAtQuote(html string) (body, quote string) { loc := quoteWrapperRe.FindStringIndex(html) if loc == nil { return html, "" @@ -139,6 +163,70 @@ func splitAtQuote(html string) (body, quote string) { return html[:loc[0]], html[loc[0]:] } +// ── Exported signature HTML utilities ── +// Used by both draft/patch.go (internal) and mail/signature_html.go (cross-package). + +// signatureSpacingRe matches 1-2 empty-line divs before the signature. +var signatureSpacingRe = regexp.MustCompile( + `(?:]*>]*>
\s*){1,2}$`) + +// SignatureSpacingRe returns the compiled regex for signature spacing detection. +func SignatureSpacingRe() *regexp.Regexp { return signatureSpacingRe } + +// SignatureSpacing returns the 2 empty-line divs placed before the signature, +// matching the structure generated by the Lark mail editor. +func SignatureSpacing() string { + line := `

` + return line + line +} + +// BuildSignatureHTML wraps signature content in the standard signature container div. +// sigID is HTML-escaped to prevent attribute injection. +func BuildSignatureHTML(sigID, content string) string { + return `
` + content + `
` +} + +// FindMatchingCloseDiv finds the position after the closing that matches +// the
") { + depth-- + i += 6 + if depth == 0 { + return i + } + } else { + i++ + } + } + return len(html) +} + +// RemoveSignatureHTML removes the signature block and its preceding spacing from HTML. +// Returns the HTML unchanged if no signature is found. +func RemoveSignatureHTML(html string) string { + loc := signatureWrapperRe.FindStringIndex(html) + if loc == nil { + return html + } + sigStart := loc[0] + sigEnd := FindMatchingCloseDiv(html, sigStart) + + // Extend backward to include preceding spacing. + beforeSig := html[:sigStart] + if spacingLoc := signatureSpacingRe.FindStringIndex(beforeSig); spacingLoc != nil { + sigStart = spacingLoc[0] + } + + return html[:sigStart] + html[sigEnd:] +} + func summarizeHTML(html string) string { trimmed := strings.TrimSpace(html) runes := []rune(trimmed) diff --git a/shortcuts/mail/draft/projection_test.go b/shortcuts/mail/draft/projection_test.go index a2e7163f..3fe197ea 100644 --- a/shortcuts/mail/draft/projection_test.go +++ b/shortcuts/mail/draft/projection_test.go @@ -100,7 +100,7 @@ Content-Type: text/html; charset=UTF-8 func TestSplitAtQuoteReply(t *testing.T) { html := `
My reply
quoted
` - body, quote := splitAtQuote(html) + body, quote := SplitAtQuote(html) if body != `
My reply
` { t.Fatalf("body = %q", body) } @@ -111,7 +111,7 @@ func TestSplitAtQuoteReply(t *testing.T) { func TestSplitAtQuoteForward(t *testing.T) { html := `
note
quoted
` - body, quote := splitAtQuote(html) + body, quote := SplitAtQuote(html) if body != `
note
` { t.Fatalf("body = %q", body) } @@ -122,7 +122,7 @@ func TestSplitAtQuoteForward(t *testing.T) { func TestSplitAtQuoteNoQuote(t *testing.T) { html := `
no quote here
` - body, quote := splitAtQuote(html) + body, quote := SplitAtQuote(html) if body != html { t.Fatalf("body = %q, want original html", body) } @@ -169,7 +169,7 @@ Content-Type: text/html; charset=UTF-8 func TestSplitAtQuoteFalsePositivePlainText(t *testing.T) { html := `

The CSS class history-quote-wrapper is used for quotes.

` - body, quote := splitAtQuote(html) + body, quote := SplitAtQuote(html) if body != html { t.Fatalf("body should be unchanged, got %q", body) } diff --git a/shortcuts/mail/mail_draft_create.go b/shortcuts/mail/mail_draft_create.go index 6d43abed..66875808 100644 --- a/shortcuts/mail/mail_draft_create.go +++ b/shortcuts/mail/mail_draft_create.go @@ -46,6 +46,7 @@ var MailDraftCreate = common.Shortcut{ {Name: "plain-text", Type: "bool", Desc: "Force plain-text mode, ignoring HTML auto-detection. Cannot be used with --inline."}, {Name: "attach", Desc: "Optional. Regular attachment file paths (relative path only). Separate multiple paths with commas. Each path must point to a readable local file."}, {Name: "inline", Desc: "Optional. Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, + signatureFlag, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { input, err := parseDraftCreateInput(runtime) @@ -72,6 +73,9 @@ var MailDraftCreate = common.Shortcut{ if strings.TrimSpace(runtime.Str("body")) == "" { return output.ErrValidation("--body is required; pass the full email body") } + if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { + return err + } if err := validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")); err != nil { return err } @@ -82,11 +86,15 @@ var MailDraftCreate = common.Shortcut{ if err != nil { return err } - rawEML, err := buildRawEMLForDraftCreate(runtime, input) + mailboxID := resolveComposeMailboxID(runtime) + sigResult, err := resolveSignature(ctx, runtime, mailboxID, runtime.Str("signature-id"), runtime.Str("from")) + if err != nil { + return err + } + rawEML, err := buildRawEMLForDraftCreate(runtime, input, sigResult) if err != nil { return err } - mailboxID := resolveComposeMailboxID(runtime) draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { return fmt.Errorf("create draft failed: %w", err) @@ -121,7 +129,7 @@ func parseDraftCreateInput(runtime *common.RuntimeContext) (draftCreateInput, er return input, nil } -func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput) (string, error) { +func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreateInput, sigResult *signatureResult) (string, error) { senderEmail := resolveComposeSenderEmail(runtime) if senderEmail == "" { return "", fmt.Errorf("unable to determine sender email; please specify --from explicitly") @@ -153,12 +161,18 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate var autoResolvedPaths []string if input.PlainText { bld = bld.TextBody([]byte(input.Body)) - } else if bodyIsHTML(input.Body) { - resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(input.Body) + } else if bodyIsHTML(input.Body) || sigResult != nil { + htmlBody := input.Body + if !bodyIsHTML(input.Body) { + htmlBody = buildBodyDiv(input.Body, false) + } + resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody) if resolveErr != nil { return "", resolveErr } + resolved = injectSignatureIntoBody(resolved, sigResult) bld = bld.HTMLBody([]byte(resolved)) + bld = addSignatureImagesToBuilder(bld, sigResult) var allCIDs []string for _, ref := range refs { bld = bld.AddFileInline(ref.FilePath, ref.CID) @@ -169,6 +183,7 @@ func buildRawEMLForDraftCreate(runtime *common.RuntimeContext, input draftCreate bld = bld.AddFileInline(spec.FilePath, spec.CID) allCIDs = append(allCIDs, spec.CID) } + allCIDs = append(allCIDs, signatureCIDs(sigResult)...) if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil { return "", err } diff --git a/shortcuts/mail/mail_draft_create_test.go b/shortcuts/mail/mail_draft_create_test.go index fdf37f3c..2696e5da 100644 --- a/shortcuts/mail/mail_draft_create_test.go +++ b/shortcuts/mail/mail_draft_create_test.go @@ -33,7 +33,7 @@ func TestBuildRawEMLForDraftCreate_ResolvesLocalImages(t *testing.T) { Body: `

Hello

`, } - rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input) + rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil) if err != nil { t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) } @@ -58,7 +58,7 @@ func TestBuildRawEMLForDraftCreate_NoLocalImages(t *testing.T) { Body: `

Hello world

`, } - rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input) + rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil) if err != nil { t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) } @@ -93,7 +93,7 @@ func TestBuildRawEMLForDraftCreate_AutoResolveCountedInSizeLimit(t *testing.T) { Attach: "./big.txt", } - _, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input) + _, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil) if err == nil { t.Fatal("expected size limit error when auto-resolved image + attachment exceed 25MB") } @@ -113,7 +113,7 @@ func TestBuildRawEMLForDraftCreate_OrphanedInlineSpecError(t *testing.T) { Inline: `[{"cid":"orphan","file_path":"./unused.png"}]`, } - _, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input) + _, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil) if err == nil { t.Fatal("expected error for orphaned --inline CID not referenced in body") } @@ -133,7 +133,7 @@ func TestBuildRawEMLForDraftCreate_MissingCIDRefError(t *testing.T) { Inline: `[{"cid":"present","file_path":"./present.png"}]`, } - _, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input) + _, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil) if err == nil { t.Fatal("expected error for missing CID reference") } @@ -153,7 +153,7 @@ func TestBuildRawEMLForDraftCreate_PlainTextSkipsResolve(t *testing.T) { PlainText: true, } - rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input) + rawEML, err := buildRawEMLForDraftCreate(newRuntimeWithFrom("sender@example.com"), input, nil) if err != nil { t.Fatalf("buildRawEMLForDraftCreate() error = %v", err) } diff --git a/shortcuts/mail/mail_draft_edit.go b/shortcuts/mail/mail_draft_edit.go index c6daf0a2..5e34a5bf 100644 --- a/shortcuts/mail/mail_draft_edit.go +++ b/shortcuts/mail/mail_draft_edit.go @@ -92,6 +92,24 @@ var MailDraftEdit = common.Shortcut{ if err != nil { return output.ErrValidation("parse draft raw EML failed: %v", err) } + // Pre-process insert_signature ops: resolve signature using the draft's + // From address so alias/shared-mailbox senders get correct template vars. + var draftFromEmail string + if len(snapshot.From) > 0 { + draftFromEmail = snapshot.From[0].Address + } + for i := range patch.Ops { + if patch.Ops[i].Op == "insert_signature" { + sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, patch.Ops[i].SignatureID, draftFromEmail) + if sigErr != nil { + return sigErr + } + if sigResult != nil { + patch.Ops[i].RenderedSignatureHTML = sigResult.RenderedContent + patch.Ops[i].SignatureImages = sigResult.Images + } + } + } dctx := &draftpkg.DraftCtx{FIO: runtime.FileIO()} if err := draftpkg.Apply(dctx, snapshot, patch); err != nil { return output.ErrValidation("apply draft patch failed: %v", err) @@ -313,6 +331,8 @@ func buildDraftEditPatchTemplate() map[string]interface{} { {"op": "add_inline", "shape": map[string]interface{}{"path": "string(relative path)", "cid": "string", "filename": "string(optional)", "content_type": "string(optional)"}, "note": "advanced: prefer in set_body/set_reply_body instead"}, {"op": "replace_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}, "path": "string(relative path)", "cid": "string(optional)", "filename": "string(optional)", "content_type": "string(optional)"}}, {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, + {"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}}, + {"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature from the HTML body"}, }, "supported_ops_by_group": []map[string]interface{}{ { @@ -348,6 +368,13 @@ func buildDraftEditPatchTemplate() map[string]interface{} { {"op": "remove_inline", "shape": map[string]interface{}{"target": map[string]interface{}{"part_id": "string(optional)", "cid": "string(optional)"}}}, }, }, + { + "group": "signature", + "ops": []map[string]interface{}{ + {"op": "insert_signature", "shape": map[string]interface{}{"signature_id": "string (run mail +signature to list IDs)"}}, + {"op": "remove_signature", "shape": map[string]interface{}{}, "note": "removes existing signature and its preceding spacing from the HTML body"}, + }, + }, }, "recommended_usage": []string{ "Use direct flags (--set-subject, --set-to, --set-cc, --set-bcc) for simple metadata edits", diff --git a/shortcuts/mail/mail_forward.go b/shortcuts/mail/mail_forward.go index 4a0f35c0..c7051c45 100644 --- a/shortcuts/mail/mail_forward.go +++ b/shortcuts/mail/mail_forward.go @@ -35,7 +35,7 @@ var MailForward = common.Shortcut{ {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, {Name: "confirm-send", Type: "bool", Desc: "Send the forward immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, {Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, - }, + signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") to := runtime.Str("to") @@ -68,6 +68,9 @@ var MailForward = common.Shortcut{ return err } } + if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { + return err + } return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -82,7 +85,12 @@ var MailForward = common.Shortcut{ confirmSend := runtime.Bool("confirm-send") sendTime := runtime.Str("send-time") + signatureID := runtime.Str("signature-id") mailboxID := resolveComposeMailboxID(runtime) + sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from")) + if sigErr != nil { + return sigErr + } sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId) if err != nil { return fmt.Errorf("failed to fetch original message: %w", err) @@ -119,7 +127,7 @@ var MailForward = common.Shortcut{ if messageId != "" { bld = bld.LMSReplyToMessageID(messageId) } - useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw)) + useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil) if strings.TrimSpace(inlineFlag) != "" && !useHTML { return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") } @@ -143,8 +151,13 @@ var MailForward = common.Shortcut{ if resolveErr != nil { return resolveErr } - fullHTML := resolved + forwardQuote + bodyWithSig := resolved + if sigResult != nil { + bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent) + } + fullHTML := bodyWithSig + forwardQuote bld = bld.HTMLBody([]byte(fullHTML)) + bld = addSignatureImagesToBuilder(bld, sigResult) var userCIDs []string for _, ref := range refs { bld = bld.AddFileInline(ref.FilePath, ref.CID) @@ -155,7 +168,7 @@ var MailForward = common.Shortcut{ bld = bld.AddFileInline(spec.FilePath, spec.CID) userCIDs = append(userCIDs, spec.CID) } - if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil { + if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil { return err } } else { diff --git a/shortcuts/mail/mail_reply.go b/shortcuts/mail/mail_reply.go index 9312b938..e54022a9 100644 --- a/shortcuts/mail/mail_reply.go +++ b/shortcuts/mail/mail_reply.go @@ -33,7 +33,7 @@ var MailReply = common.Shortcut{ {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, {Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, {Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, - }, + signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") confirmSend := runtime.Bool("confirm-send") @@ -60,6 +60,9 @@ var MailReply = common.Shortcut{ if err := validateSendTime(runtime); err != nil { return err } + if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { + return err + } return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -79,7 +82,12 @@ var MailReply = common.Shortcut{ return err } + signatureID := runtime.Str("signature-id") mailboxID := resolveComposeMailboxID(runtime) + sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from")) + if sigErr != nil { + return sigErr + } sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId) if err != nil { return fmt.Errorf("failed to fetch original message: %w", err) @@ -97,7 +105,7 @@ var MailReply = common.Shortcut{ } replyTo = mergeAddrLists(replyTo, toFlag) - useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw)) + useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil) if strings.TrimSpace(inlineFlag) != "" && !useHTML { return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") } @@ -144,8 +152,13 @@ var MailReply = common.Shortcut{ if resolveErr != nil { return resolveErr } - fullHTML := resolved + quoted + bodyWithSig := resolved + if sigResult != nil { + bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent) + } + fullHTML := bodyWithSig + quoted bld = bld.HTMLBody([]byte(fullHTML)) + bld = addSignatureImagesToBuilder(bld, sigResult) var userCIDs []string for _, ref := range refs { bld = bld.AddFileInline(ref.FilePath, ref.CID) @@ -156,7 +169,7 @@ var MailReply = common.Shortcut{ bld = bld.AddFileInline(spec.FilePath, spec.CID) userCIDs = append(userCIDs, spec.CID) } - if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil { + if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil { return err } } else { diff --git a/shortcuts/mail/mail_reply_all.go b/shortcuts/mail/mail_reply_all.go index df05616c..0f719459 100644 --- a/shortcuts/mail/mail_reply_all.go +++ b/shortcuts/mail/mail_reply_all.go @@ -34,7 +34,7 @@ var MailReplyAll = common.Shortcut{ {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, {Name: "confirm-send", Type: "bool", Desc: "Send the reply immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, {Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, - }, + signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { messageId := runtime.Str("message-id") confirmSend := runtime.Bool("confirm-send") @@ -61,6 +61,9 @@ var MailReplyAll = common.Shortcut{ if err := validateSendTime(runtime); err != nil { return err } + if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { + return err + } return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), "") }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -81,7 +84,12 @@ var MailReplyAll = common.Shortcut{ return err } + signatureID := runtime.Str("signature-id") mailboxID := resolveComposeMailboxID(runtime) + sigResult, sigErr := resolveSignature(ctx, runtime, mailboxID, signatureID, runtime.Str("from")) + if sigErr != nil { + return sigErr + } sourceMsg, err := fetchComposeSourceMessage(runtime, mailboxID, messageId) if err != nil { return fmt.Errorf("failed to fetch original message: %w", err) @@ -115,7 +123,7 @@ var MailReplyAll = common.Shortcut{ return err } - useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw)) + useHTML := !plainText && (bodyIsHTML(body) || bodyIsHTML(orig.bodyRaw) || sigResult != nil) if strings.TrimSpace(inlineFlag) != "" && !useHTML { return fmt.Errorf("--inline requires HTML mode, but neither the new body nor the original message contains HTML") } @@ -158,8 +166,13 @@ var MailReplyAll = common.Shortcut{ if resolveErr != nil { return resolveErr } - fullHTML := resolved + quoted + bodyWithSig := resolved + if sigResult != nil { + bodyWithSig += draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sigResult.ID, sigResult.RenderedContent) + } + fullHTML := bodyWithSig + quoted bld = bld.HTMLBody([]byte(fullHTML)) + bld = addSignatureImagesToBuilder(bld, sigResult) var userCIDs []string for _, ref := range refs { bld = bld.AddFileInline(ref.FilePath, ref.CID) @@ -170,7 +183,7 @@ var MailReplyAll = common.Shortcut{ bld = bld.AddFileInline(spec.FilePath, spec.CID) userCIDs = append(userCIDs, spec.CID) } - if err := validateInlineCIDs(resolved, userCIDs, srcCIDs); err != nil { + if err := validateInlineCIDs(bodyWithSig, append(userCIDs, signatureCIDs(sigResult)...), srcCIDs); err != nil { return err } } else { diff --git a/shortcuts/mail/mail_send.go b/shortcuts/mail/mail_send.go index a3965588..c6671cf3 100644 --- a/shortcuts/mail/mail_send.go +++ b/shortcuts/mail/mail_send.go @@ -33,7 +33,7 @@ var MailSend = common.Shortcut{ {Name: "inline", Desc: "Inline images as a JSON array. Each entry: {\"cid\":\"\",\"file_path\":\"\"}. All file_path values must be relative paths. Cannot be used with --plain-text. CID images are embedded via in the HTML body. CID is a unique identifier, e.g. a random hex string like \"a1b2c3d4e5f6a7b8c9d0\"."}, {Name: "confirm-send", Type: "bool", Desc: "Send the email immediately instead of saving as draft. Only use after the user has explicitly confirmed recipients and content."}, {Name: "send-time", Desc: "Scheduled send time as a Unix timestamp in seconds. Must be at least 5 minutes in the future. Use with --confirm-send to schedule the email."}, - }, + signatureFlag}, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { to := runtime.Str("to") subject := runtime.Str("subject") @@ -66,6 +66,9 @@ var MailSend = common.Shortcut{ if err := validateSendTime(runtime); err != nil { return err } + if err := validateSignatureWithPlainText(runtime.Bool("plain-text"), runtime.Str("signature-id")); err != nil { + return err + } return validateComposeInlineAndAttachments(runtime.FileIO(), runtime.Str("attach"), runtime.Str("inline"), runtime.Bool("plain-text"), runtime.Str("body")) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -81,6 +84,13 @@ var MailSend = common.Shortcut{ sendTime := runtime.Str("send-time") senderEmail := resolveComposeSenderEmail(runtime) + signatureID := runtime.Str("signature-id") + + mailboxID := resolveComposeMailboxID(runtime) + sigResult, err := resolveSignature(ctx, runtime, mailboxID, signatureID, senderEmail) + if err != nil { + return err + } bld := emlbuilder.New().WithFileIO(runtime.FileIO()). Subject(subject). @@ -101,12 +111,19 @@ var MailSend = common.Shortcut{ var autoResolvedPaths []string if plainText { bld = bld.TextBody([]byte(body)) - } else if bodyIsHTML(body) { - resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(body) + } else if bodyIsHTML(body) || sigResult != nil { + // If signature is requested on plain-text body, auto-upgrade to HTML. + htmlBody := body + if !bodyIsHTML(body) { + htmlBody = buildBodyDiv(body, false) + } + resolved, refs, resolveErr := draftpkg.ResolveLocalImagePaths(htmlBody) if resolveErr != nil { return resolveErr } + resolved = injectSignatureIntoBody(resolved, sigResult) bld = bld.HTMLBody([]byte(resolved)) + bld = addSignatureImagesToBuilder(bld, sigResult) var allCIDs []string for _, ref := range refs { bld = bld.AddFileInline(ref.FilePath, ref.CID) @@ -117,6 +134,7 @@ var MailSend = common.Shortcut{ bld = bld.AddFileInline(spec.FilePath, spec.CID) allCIDs = append(allCIDs, spec.CID) } + allCIDs = append(allCIDs, signatureCIDs(sigResult)...) if err := validateInlineCIDs(resolved, allCIDs, nil); err != nil { return err } @@ -137,7 +155,6 @@ var MailSend = common.Shortcut{ return fmt.Errorf("failed to build EML: %w", err) } - mailboxID := resolveComposeMailboxID(runtime) draftID, err := draftpkg.CreateWithRaw(runtime, mailboxID, rawEML) if err != nil { return fmt.Errorf("failed to create draft: %w", err) diff --git a/shortcuts/mail/mail_signature.go b/shortcuts/mail/mail_signature.go new file mode 100644 index 00000000..fccd2513 --- /dev/null +++ b/shortcuts/mail/mail_signature.go @@ -0,0 +1,216 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "regexp" + "strings" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" + "github.com/larksuite/cli/shortcuts/mail/signature" +) + +var MailSignature = common.Shortcut{ + Service: "mail", + Command: "+signature", + Description: "List or view email signatures with default usage info.", + Risk: "read", + Scopes: []string{"mail:user_mailbox:readonly"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "from", Default: "me", Desc: "Mailbox address (default: me)"}, + {Name: "detail", Desc: "Signature ID to view rendered details. Omit to list all signatures."}, + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + mailboxID := runtime.Str("from") + if mailboxID == "" { + mailboxID = "me" + } + return common.NewDryRunAPI(). + Desc("List or view email signatures"). + GET(mailboxPath(mailboxID, "signatures")) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + mailboxID := runtime.Str("from") + if mailboxID == "" { + mailboxID = "me" + } + detailID := runtime.Str("detail") + + resp, err := signature.ListAll(runtime, mailboxID) + if err != nil { + return err + } + + if detailID != "" { + return executeSignatureDetail(runtime, resp, detailID, mailboxID) + } + return executeSignatureList(runtime, resp) + }, +} + +func executeSignatureList(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse) error { + // Build default signature ID maps from usages. + sendDefaults := map[string]bool{} + replyDefaults := map[string]bool{} + for _, usage := range resp.Usages { + if usage.SendMailSignatureID != "" && usage.SendMailSignatureID != "0" { + sendDefaults[usage.SendMailSignatureID] = true + } + if usage.ReplySignatureID != "" && usage.ReplySignatureID != "0" { + replyDefaults[usage.ReplySignatureID] = true + } + } + + lang := resolveLang(runtime) + items := make([]map[string]interface{}, 0, len(resp.Signatures)) + for _, sig := range resp.Signatures { + item := map[string]interface{}{ + "id": sig.ID, + "name": sig.Name, + "type": string(sig.SignatureType), + } + if len(sig.Images) > 0 { + item["images"] = len(sig.Images) + } + + // Short content preview (rendered for TENANT). + rendered := signature.InterpolateTemplate(&sig, lang, "", "") + item["content_preview"] = contentPreview(rendered, 200, lang) + + if sendDefaults[sig.ID] { + item["is_send_default"] = true + } + if replyDefaults[sig.ID] { + item["is_reply_default"] = true + } + + items = append(items, item) + } + + runtime.OutFormat( + map[string]interface{}{"signatures": items}, + &output.Meta{Count: len(items)}, + nil, + ) + return nil +} + +func executeSignatureDetail(runtime *common.RuntimeContext, resp *signature.GetSignaturesResponse, sigID, mailboxID string) error { + var sig *signature.Signature + for i := range resp.Signatures { + if resp.Signatures[i].ID == sigID { + sig = &resp.Signatures[i] + break + } + } + if sig == nil { + return output.ErrValidation("signature not found: %s", sigID) + } + + lang := resolveLang(runtime) + + detail := map[string]interface{}{ + "id": sig.ID, + "name": sig.Name, + "type": string(sig.SignatureType), + } + + // Usage info. + for _, usage := range resp.Usages { + if usage.SendMailSignatureID == sig.ID { + detail["is_send_default"] = true + } + if usage.ReplySignatureID == sig.ID { + detail["is_reply_default"] = true + } + } + + // Images metadata — output the full structure from API. + if len(sig.Images) > 0 { + detail["images"] = sig.Images + } + + // Template variables (TENANT signatures): show resolved values. + if sig.HasTemplateVars() { + vars := make(map[string]string, len(sig.UserFields)) + for key, field := range sig.UserFields { + vars[key] = field.Resolve(lang) + } + detail["template_vars"] = vars + } + + // Rendered content preview. + rendered := signature.InterpolateTemplate(sig, lang, "", "") + detail["content_preview"] = contentPreview(rendered, 200, lang) + + runtime.Out(detail, nil) + return nil +} + +// resolveLang maps CLI config lang ("zh"/"en") to i18n key ("zh_cn"/"en_us"). +func resolveLang(runtime *common.RuntimeContext) string { + multi, err := core.LoadMultiAppConfig() + if err != nil { + return "zh_cn" + } + cfg, err := runtime.Factory.Config() + if err != nil { + return "zh_cn" + } + app := multi.FindApp(cfg.ProfileName) + if app == nil { + return "zh_cn" + } + switch app.Lang { + case "en": + return "en_us" + case "ja": + return "ja_jp" + default: + return "zh_cn" + } +} + +// contentPreview converts HTML to a compact plain-text preview. +// tags become a localized image placeholder, all other tags become +// spaces, then consecutive whitespace is collapsed. Result is truncated +// to maxLen runes. +func contentPreview(html string, maxLen int, lang string) string { + placeholder := "[image]" + if strings.HasPrefix(lang, "zh") { + placeholder = "[图片]" + } + imgRe := regexp.MustCompile(`]*>`) + s := imgRe.ReplaceAllString(html, placeholder) + + // Strip remaining tags, replacing each with a space. + var result strings.Builder + inTag := false + for _, r := range s { + switch { + case r == '<': + inTag = true + result.WriteByte(' ') + case r == '>': + inTag = false + case !inTag: + result.WriteRune(r) + } + } + + // Collapse whitespace and trim. + text := strings.Join(strings.Fields(result.String()), " ") + text = strings.TrimSpace(text) + + runes := []rune(text) + if len(runes) <= maxLen { + return text + } + return string(runes[:maxLen]) + "..." +} diff --git a/shortcuts/mail/shortcuts.go b/shortcuts/mail/shortcuts.go index eb9f2d27..ef05b37a 100644 --- a/shortcuts/mail/shortcuts.go +++ b/shortcuts/mail/shortcuts.go @@ -19,5 +19,6 @@ func Shortcuts() []common.Shortcut { MailDraftCreate, MailDraftEdit, MailForward, + MailSignature, } } diff --git a/shortcuts/mail/signature/interpolate.go b/shortcuts/mail/signature/interpolate.go new file mode 100644 index 00000000..c3525bce --- /dev/null +++ b/shortcuts/mail/signature/interpolate.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package signature + +import ( + "encoding/json" + "regexp" + "strings" +) + +// variableMetaProps represents the JSON structure in data-variable-meta-props attributes. +type variableMetaProps struct { + ID string `json:"id"` + Type string `json:"type"` // "text" or "image" + DisplayName string `json:"displayName"` // human-readable label + Width string `json:"width"` // image width (for type=image) + Style string `json:"style"` // CSS style + Circle bool `json:"circle"` // circular image +} + +// variableSpanRe matches and captures the JSON and inner content. +// Group 1: JSON attribute value (double-quoted), Group 2: (single-quoted), Group 3: inner content. +// +// Limitation: uses regex instead of DOM parsing (Go has no built-in DOMParser like JS). +// If a variable contains nested tags, [\s\S]*? will match to the +// innermost , potentially truncating content. In practice, Lark's signature +// templates do not nest inside variable spans (verified against mail-editor +// source and test data). If this becomes an issue, consider using golang.org/x/net/html. +var variableSpanRe = regexp.MustCompile( + `([\s\S]*?)`) + +// InterpolateTemplate replaces template variables in a TENANT signature's content HTML. +// For USER signatures (no template variables), it returns sig.Content unchanged. +// +// Parameters: +// - sig: the signature object +// - lang: language code for i18n ("zh_cn", "en_us", "ja_jp") +// - senderName: sender display name (overrides B-NAME) +// - senderEmail: sender email address (overrides B-ENTERPRISE-EMAIL) +func InterpolateTemplate(sig *Signature, lang, senderName, senderEmail string) string { + if !sig.HasTemplateVars() { + return sig.Content + } + + // Build value map from user_fields with i18n resolution. + valueMap := make(map[string]string, len(sig.UserFields)+2) + for key, field := range sig.UserFields { + valueMap[key] = field.Resolve(lang) + } + + // Fixed injections override API values. + if senderName != "" { + valueMap["B-NAME"] = senderName + } + if senderEmail != "" { + valueMap["B-ENTERPRISE-EMAIL"] = senderEmail + } + + // Replace each with interpolated content. + result := variableSpanRe.ReplaceAllStringFunc(sig.Content, func(match string) string { + submatches := variableSpanRe.FindStringSubmatch(match) + if submatches == nil { + return match + } + + // JSON is in group 1 (double-quoted) or group 2 (single-quoted). + attrJSON := submatches[1] + if attrJSON == "" { + attrJSON = submatches[2] + } + + // Unescape HTML entities in the JSON attribute value. + attrJSON = unescapeHTMLEntities(attrJSON) + + var meta variableMetaProps + if err := json.Unmarshal([]byte(attrJSON), &meta); err != nil { + return match // preserve original on parse failure + } + + val, ok := valueMap[meta.ID] + if !ok { + val = "" // variable not in map, replace with empty + } + + switch meta.Type { + case "text": + return interpolateText(val, meta.Style) + case "image": + return interpolateImage(val, meta) + default: + return val + } + }) + + return result +} + +// interpolateText returns the replacement for a text variable. +func interpolateText(val, style string) string { + if val == "" { + return "" + } + // If value looks like a URL, wrap in . + if isURL(val) { + escaped := escapeHTML(val) + return `` + escaped + `` + } + if style != "" { + return `` + escapeHTML(val) + `` + } + return escapeHTML(val) +} + +// interpolateImage returns the replacement for an image variable. +func interpolateImage(val string, meta variableMetaProps) string { + if val == "" { + return "" + } + var attrs []string + attrs = append(attrs, `src="`+escapeHTML(val)+`"`) + if meta.Width != "" { + attrs = append(attrs, `width="`+escapeHTML(meta.Width)+`"`) + } + var styles []string + if meta.Style != "" { + styles = append(styles, meta.Style) + } + if meta.Circle { + styles = append(styles, "border-radius: 100%") + } + if len(styles) > 0 { + attrs = append(attrs, `style="`+escapeHTML(strings.Join(styles, ";"))+`"`) + } + return `` +} + +func isURL(s string) bool { + s = strings.TrimSpace(s) + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, `"`, """) + return s +} + +func unescapeHTMLEntities(s string) string { + s = strings.ReplaceAll(s, """, `"`) + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} diff --git a/shortcuts/mail/signature/interpolate_test.go b/shortcuts/mail/signature/interpolate_test.go new file mode 100644 index 00000000..d58a484a --- /dev/null +++ b/shortcuts/mail/signature/interpolate_test.go @@ -0,0 +1,137 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package signature + +import ( + "strings" + "testing" +) + +func TestInterpolateTemplate_UserSignatureUnchanged(t *testing.T) { + sig := &Signature{ + Content: "My signature", + SignatureType: SignatureTypeUser, + } + got := InterpolateTemplate(sig, "zh_cn", "Alice", "alice@example.com") + if got != sig.Content { + t.Errorf("USER signature should be unchanged, got %q", got) + } +} + +func TestInterpolateTemplate_TenantTextVariables(t *testing.T) { + sig := &Signature{ + Content: `姓名:{text}, 部门:{text}`, + SignatureType: SignatureTypeTenant, + TemplateJSONKeys: []string{"B-NAME", "B-DEPARTMENT"}, + UserFields: map[string]UserFieldValue{ + "B-NAME": {DefaultVal: "张三", I18nVals: map[string]string{"zh_cn": "", "en_us": "Zhang San"}}, + "B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "研发部", "en_us": "R&D"}}, + }, + } + + // zh_cn: B-DEPARTMENT should resolve to "研发部" (from i18n), B-NAME overridden by senderName + got := InterpolateTemplate(sig, "zh_cn", "李四", "lisi@example.com") + if !strings.Contains(got, "李四") { + t.Errorf("expected senderName override for B-NAME, got %q", got) + } + if !strings.Contains(got, "研发部") { + t.Errorf("expected zh_cn i18n value for B-DEPARTMENT, got %q", got) + } + if strings.Contains(got, "{text}") { + t.Errorf("should not contain raw placeholder {text}, got %q", got) + } + if strings.Contains(got, "data-variable-meta-props") { + t.Errorf("should not contain data-variable-meta-props attribute, got %q", got) + } +} + +func TestInterpolateTemplate_I18nFallback(t *testing.T) { + sig := &Signature{ + Content: `{text}`, + SignatureType: SignatureTypeTenant, + TemplateJSONKeys: []string{"B-DEPARTMENT"}, + UserFields: map[string]UserFieldValue{ + "B-DEPARTMENT": {DefaultVal: "默认部门", I18nVals: map[string]string{"zh_cn": "", "en_us": ""}}, + }, + } + + got := InterpolateTemplate(sig, "zh_cn", "", "") + if !strings.Contains(got, "默认部门") { + t.Errorf("expected fallback to DefaultVal, got %q", got) + } +} + +func TestInterpolateTemplate_HTMLEntityEscaping(t *testing.T) { + // Simulate the HTML-entity-escaped attribute format from real API responses. + sig := &Signature{ + Content: `{text}`, + SignatureType: SignatureTypeTenant, + TemplateJSONKeys: []string{"B-NAME"}, + UserFields: map[string]UserFieldValue{ + "B-NAME": {DefaultVal: "default"}, + }, + } + + got := InterpolateTemplate(sig, "zh_cn", "陈煌", "") + if !strings.Contains(got, "陈煌") { + t.Errorf("expected interpolated name, got %q", got) + } +} + +func TestInterpolateTemplate_URLAsText(t *testing.T) { + sig := &Signature{ + Content: `{text}`, + SignatureType: SignatureTypeTenant, + TemplateJSONKeys: []string{"B-URL"}, + UserFields: map[string]UserFieldValue{ + "B-URL": {DefaultVal: "https://example.com"}, + }, + } + + got := InterpolateTemplate(sig, "zh_cn", "", "") + if !strings.Contains(got, " tag, got %q", got) + } + if !strings.Contains(got, "https://example.com") { + t.Errorf("expected URL in output, got %q", got) + } +} + +func TestInterpolateTemplate_ImageVariable(t *testing.T) { + sig := &Signature{ + Content: ``, + SignatureType: SignatureTypeTenant, + TemplateJSONKeys: []string{"B-LOGO"}, + UserFields: map[string]UserFieldValue{ + "B-LOGO": {DefaultVal: "cid:new-logo-cid"}, + }, + } + + got := InterpolateTemplate(sig, "zh_cn", "", "") + if !strings.Contains(got, `src="cid:new-logo-cid"`) { + t.Errorf("expected new image src, got %q", got) + } + if !strings.Contains(got, `width="40"`) { + t.Errorf("expected width attribute, got %q", got) + } +} + +func TestUserFieldValue_Resolve(t *testing.T) { + v := UserFieldValue{ + DefaultVal: "default", + I18nVals: map[string]string{"zh_cn": "中文", "en_us": "", "ja_jp": "日本語"}, + } + if got := v.Resolve("zh_cn"); got != "中文" { + t.Errorf("zh_cn = %q, want 中文", got) + } + if got := v.Resolve("en_us"); got != "default" { + t.Errorf("en_us (empty) should fallback to default, got %q", got) + } + if got := v.Resolve("ja_jp"); got != "日本語" { + t.Errorf("ja_jp = %q, want 日本語", got) + } + if got := v.Resolve("fr_fr"); got != "default" { + t.Errorf("unknown lang should fallback, got %q", got) + } +} diff --git a/shortcuts/mail/signature/model.go b/shortcuts/mail/signature/model.go new file mode 100644 index 00000000..de2cac50 --- /dev/null +++ b/shortcuts/mail/signature/model.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package signature + +// SignatureType represents the type of a mail signature. +type SignatureType string + +const ( + SignatureTypeUser SignatureType = "USER" + SignatureTypeTenant SignatureType = "TENANT" +) + +// SignatureDevice represents the device platform a signature is designed for. +type SignatureDevice string + +const ( + DevicePC SignatureDevice = "PC" + DeviceMobile SignatureDevice = "MOBILE" +) + +// SignatureImage holds metadata for an inline image embedded in a signature. +type SignatureImage struct { + ImageName string `json:"image_name,omitempty"` + FileKey string `json:"file_key,omitempty"` + CID string `json:"cid,omitempty"` + FileSize string `json:"file_size,omitempty"` + Header string `json:"header,omitempty"` + ImageWidth int32 `json:"image_width,omitempty"` + ImageHeight int32 `json:"image_height,omitempty"` + DownloadURL string `json:"download_url,omitempty"` +} + +// UserFieldValue holds a template variable value with multi-language support. +type UserFieldValue struct { + DefaultVal string `json:"default_val"` + I18nVals map[string]string `json:"i18n_vals"` // keys: "zh_cn", "en_us", "ja_jp" +} + +// Resolve returns the localized value for the given language code. +// Falls back to DefaultVal when the language key is missing or empty. +func (v UserFieldValue) Resolve(lang string) string { + if val, ok := v.I18nVals[lang]; ok && val != "" { + return val + } + return v.DefaultVal +} + +// Signature represents a single mail signature returned by the API. +type Signature struct { + ID string `json:"id"` + Name string `json:"name"` + SignatureType SignatureType `json:"signature_type"` + SignatureDevice SignatureDevice `json:"signature_device"` + Content string `json:"content"` + Images []SignatureImage `json:"images,omitempty"` + TemplateJSONKeys []string `json:"template_json_keys,omitempty"` + UserFields map[string]UserFieldValue `json:"user_fields,omitempty"` +} + +// IsTenant returns true if this is a tenant/corporate signature with template variables. +func (s *Signature) IsTenant() bool { + return s.SignatureType == SignatureTypeTenant +} + +// HasTemplateVars returns true if the signature contains template variables that need interpolation. +func (s *Signature) HasTemplateVars() bool { + return len(s.TemplateJSONKeys) > 0 +} + +// SignatureUsage indicates which signature is used by default for a given email address. +type SignatureUsage struct { + EmailAddress string `json:"email_address"` + SendMailSignatureID string `json:"send_mail_signature_id"` + ReplySignatureID string `json:"reply_signature_id"` +} + +// GetSignaturesResponse is the parsed response from the get_signatures API. +type GetSignaturesResponse struct { + Signatures []Signature `json:"signatures"` + Usages []SignatureUsage `json:"usages"` +} diff --git a/shortcuts/mail/signature/provider.go b/shortcuts/mail/signature/provider.go new file mode 100644 index 00000000..1dda6696 --- /dev/null +++ b/shortcuts/mail/signature/provider.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package signature + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/larksuite/cli/shortcuts/common" +) + +// processCache holds per-mailbox cached responses. +// CLI runs one command per process, so a package-level map is sufficient — +// it is naturally scoped to a single Execute lifecycle. +var processCache = map[string]*GetSignaturesResponse{} + +func signaturesPath(mailboxID string) string { + return "/open-apis/mail/v1/user_mailboxes/" + url.PathEscape(mailboxID) + "/settings/signatures" +} + +// ListAll fetches all signatures and usage info for a mailbox. +// Results are cached per mailboxID within the current Execute lifecycle. +func ListAll(runtime *common.RuntimeContext, mailboxID string) (*GetSignaturesResponse, error) { + if cached, ok := processCache[mailboxID]; ok { + return cached, nil + } + + data, err := runtime.CallAPI("GET", signaturesPath(mailboxID), nil, nil) + if err != nil { + return nil, fmt.Errorf("get signatures: %w", err) + } + + raw, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("get signatures: marshal response: %w", err) + } + + var resp GetSignaturesResponse + if err := json.Unmarshal(raw, &resp); err != nil { + return nil, fmt.Errorf("get signatures: unmarshal response: %w", err) + } + + processCache[mailboxID] = &resp + return &resp, nil +} + +// List returns all signatures for a mailbox. +func List(runtime *common.RuntimeContext, mailboxID string) ([]Signature, error) { + resp, err := ListAll(runtime, mailboxID) + if err != nil { + return nil, err + } + return resp.Signatures, nil +} + +// Get returns a single signature by ID. Returns an error if not found. +func Get(runtime *common.RuntimeContext, mailboxID, signatureID string) (*Signature, error) { + resp, err := ListAll(runtime, mailboxID) + if err != nil { + return nil, err + } + for i := range resp.Signatures { + if resp.Signatures[i].ID == signatureID { + return &resp.Signatures[i], nil + } + } + return nil, fmt.Errorf("signature not found: %s", signatureID) +} diff --git a/shortcuts/mail/signature_compose.go b/shortcuts/mail/signature_compose.go new file mode 100644 index 00000000..71e7a6a7 --- /dev/null +++ b/shortcuts/mail/signature_compose.go @@ -0,0 +1,245 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package mail + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "strings" + "time" + + "github.com/larksuite/cli/shortcuts/common" + draftpkg "github.com/larksuite/cli/shortcuts/mail/draft" + "github.com/larksuite/cli/shortcuts/mail/emlbuilder" + "github.com/larksuite/cli/shortcuts/mail/signature" +) + +// signatureFlag is the common flag definition for --signature-id, shared by all compose shortcuts. +var signatureFlag = common.Flag{ + Name: "signature-id", + Desc: "Optional. Signature ID to append after body content. Run `mail +signature` to list available signatures.", +} + +// signatureResult holds the pre-processed signature data ready for HTML injection. +type signatureResult struct { + ID string + RenderedContent string + Images []draftpkg.SignatureImage +} + +// resolveSignature fetches, interpolates, and downloads images for a signature. +// Returns nil if signatureID is empty. +// resolveSignature fetches, interpolates, and downloads images for a signature. +// fromEmail is the --from address (may be an alias); used to match the correct +// sender identity for template interpolation. Pass "" to use the primary address. +func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string) (*signatureResult, error) { + if signatureID == "" { + return nil, nil + } + + sig, err := signature.Get(runtime, mailboxID, signatureID) + if err != nil { + return nil, err + } + + // Resolve sender info for template interpolation. + lang := resolveLang(runtime) + senderName, senderEmail := resolveSenderInfo(runtime, mailboxID, fromEmail) + rendered := signature.InterpolateTemplate(sig, lang, senderName, senderEmail) + + // Download signature inline images. The file_key field contains a + // direct download URL provided by the mail backend. + var images []draftpkg.SignatureImage + for _, img := range sig.Images { + if img.DownloadURL == "" || img.CID == "" { + continue + } + data, ct, err := downloadSignatureImage(runtime, img.DownloadURL, img.ImageName) + if err != nil { + return nil, fmt.Errorf("failed to download signature image %s: %w", img.ImageName, err) + } + images = append(images, draftpkg.SignatureImage{ + CID: img.CID, + ContentType: ct, + FileName: img.ImageName, + Data: data, + }) + } + + return &signatureResult{ + ID: sig.ID, + RenderedContent: rendered, + Images: images, + }, nil +} + +// injectSignatureIntoBody inserts signature HTML into the body, before the quote block. +// It removes any existing signature first, then places the new signature between +// the user-authored content and the quote block (if any). +// Returns the new full HTML body. +func injectSignatureIntoBody(bodyHTML string, sig *signatureResult) string { + if sig == nil { + return bodyHTML + } + cleaned := draftpkg.RemoveSignatureHTML(bodyHTML) + userContent, quote := draftpkg.SplitAtQuote(cleaned) + sigBlock := draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sig.ID, sig.RenderedContent) + return userContent + sigBlock + quote +} + +// addSignatureImagesToBuilder adds signature inline images to the EML builder. +func addSignatureImagesToBuilder(bld emlbuilder.Builder, sig *signatureResult) emlbuilder.Builder { + if sig == nil { + return bld + } + for _, img := range sig.Images { + cid := normalizeInlineCID(img.CID) + if cid == "" { + continue + } + bld = bld.AddInline(img.Data, img.ContentType, img.FileName, cid) + } + return bld +} + +// resolveSenderInfo fetches senderName and senderEmail via the send_as API. +// resolveSenderInfo fetches send_as addresses and returns the name/email +// for signature interpolation. If fromEmail is non-empty, it matches +// that address in the sendable list (for alias/send_as scenarios); +// otherwise falls back to the first (primary) address. +func resolveSenderInfo(runtime *common.RuntimeContext, mailboxID, fromEmail string) (name, email string) { + data, err := runtime.CallAPI("GET", mailboxPath(mailboxID, "settings", "send_as"), nil, nil) + if err != nil { + return "", "" + } + addrs, ok := data["sendable_addresses"].([]interface{}) + if !ok || len(addrs) == 0 { + return "", "" + } + // If fromEmail is specified, find the matching address. + if fromEmail != "" { + for _, a := range addrs { + m, ok := a.(map[string]interface{}) + if !ok { + continue + } + e, _ := m["email_address"].(string) + if strings.EqualFold(e, fromEmail) { + n, _ := m["name"].(string) + return n, e + } + } + } + // Fall back to the first sendable address (primary). + first, ok := addrs[0].(map[string]interface{}) + if !ok { + return "", "" + } + n, _ := first["name"].(string) + e, _ := first["email_address"].(string) + return n, e +} + +// downloadSignatureImage downloads a signature image by its direct URL. +// Security: enforces https, does not send Bearer token (URL is pre-signed), +// uses context timeout, and limits response size. Aligned with +// downloadAttachmentContent in helpers.go. +func downloadSignatureImage(runtime *common.RuntimeContext, downloadURL, filename string) ([]byte, string, error) { + u, err := url.Parse(downloadURL) + if err != nil { + return nil, "", fmt.Errorf("signature image download: invalid URL: %w", err) + } + if u.Scheme != "https" { + return nil, "", fmt.Errorf("signature image download: URL must use https (got %q)", u.Scheme) + } + if u.Host == "" { + return nil, "", fmt.Errorf("signature image download: URL has no host") + } + + httpClient, err := runtime.Factory.HttpClient() + if err != nil { + return nil, "", fmt.Errorf("signature image download: %w", err) + } + ctx, cancel := context.WithTimeout(runtime.Ctx(), 30*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadURL, nil) + if err != nil { + return nil, "", fmt.Errorf("signature image download: %w", err) + } + // Do NOT send Authorization: the download URL is pre-signed. + + resp, err := httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("signature image download: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, "", fmt.Errorf("signature image download: HTTP %d: %s", resp.StatusCode, string(body)) + } + + const maxSize = 10 * 1024 * 1024 + data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1)) + if err != nil { + return nil, "", fmt.Errorf("signature image download: read body: %w", err) + } + if len(data) > maxSize { + return nil, "", fmt.Errorf("signature image download: file exceeds 10MB limit") + } + + ct := resp.Header.Get("Content-Type") + if ct == "" || ct == "application/octet-stream" { + ct = contentTypeFromFilename(filename) + } + + return data, ct, nil +} + +func contentTypeFromFilename(name string) string { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".gif": + return "image/gif" + case ".webp": + return "image/webp" + case ".svg": + return "image/svg+xml" + case ".bmp": + return "image/bmp" + default: + return "application/octet-stream" + } +} + +// signatureCIDs returns the CID list from a signatureResult, for inline CID validation. +func signatureCIDs(sig *signatureResult) []string { + if sig == nil { + return nil + } + cids := make([]string, 0, len(sig.Images)) + for _, img := range sig.Images { + cid := normalizeInlineCID(img.CID) + if cid != "" { + cids = append(cids, cid) + } + } + return cids +} + +// validateSignatureWithPlainText returns an error if both --plain-text and --signature-id are set. +func validateSignatureWithPlainText(plainText bool, signatureID string) error { + if plainText && signatureID != "" { + return fmt.Errorf("--plain-text and --signature-id are mutually exclusive: signatures require HTML mode") + } + return nil +} diff --git a/skills/lark-mail/SKILL.md b/skills/lark-mail/SKILL.md index 7f33064e..0716d713 100644 --- a/skills/lark-mail/SKILL.md +++ b/skills/lark-mail/SKILL.md @@ -326,6 +326,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli mail + [flags]`) | [`+draft-create`](references/lark-mail-draft-create.md) | Create a brand-new mail draft from scratch (NOT for reply or forward). For reply drafts use +reply; for forward drafts use +forward. Only use +draft-create when composing a new email with no parent message. | | [`+draft-edit`](references/lark-mail-draft-edit.md) | Use when updating an existing mail draft without sending it. Prefer this shortcut over calling raw drafts.get or drafts.update directly, because it performs draft-safe MIME read/patch/write editing while preserving unchanged structure, attachments, and headers where possible. | | [`+forward`](references/lark-mail-forward.md) | Forward a message and save as draft (default). Use --confirm-send to send immediately after user confirmation. Original message block included automatically. | +| [`+signature`](references/lark-mail-signature.md) | List or view email signatures with default usage info. | ## API Resources @@ -406,6 +407,7 @@ lark-cli mail [flags] # 调用 API ### user_mailbox.settings + - `get_signatures` — 获取用户邮箱签名列表 - `send_as` — 获取账号的所有可发信地址,包括主地址、别名地址、邮件组。可以使用用户地址访问该接口,也可以使用用户有权限的公共邮箱地址访问该接口。 ### user_mailbox.threads @@ -467,6 +469,7 @@ lark-cli mail [flags] # 调用 API | `user_mailbox.rules.list` | `mail:user_mailbox.rule:read` | | `user_mailbox.rules.reorder` | `mail:user_mailbox.rule:write` | | `user_mailbox.rules.update` | `mail:user_mailbox.rule:write` | +| `user_mailbox.settings.get_signatures` | `mail:user_mailbox:readonly` | | `user_mailbox.settings.send_as` | `mail:user_mailbox:readonly` | | `user_mailbox.threads.batch_modify` | `mail:user_mailbox.message:modify` | | `user_mailbox.threads.batch_trash` | `mail:user_mailbox.message:modify` | diff --git a/skills/lark-mail/references/lark-mail-draft-create.md b/skills/lark-mail/references/lark-mail-draft-create.md index 80ca0bfe..22f260c1 100644 --- a/skills/lark-mail/references/lark-mail-draft-create.md +++ b/skills/lark-mail/references/lark-mail-draft-create.md @@ -51,6 +51,7 @@ lark-cli mail +draft-create --to alice@example.com --subject '测试' --body 'te | `--plain-text` | 否 | 强制纯文本模式,忽略 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 普通附件文件路径,多个用逗号分隔。相对路径 | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | +| `--signature-id ` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--format ` | 否 | 输出格式:`json`(默认)/ `pretty` / `table` / `ndjson` / `csv` | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-draft-edit.md b/skills/lark-mail/references/lark-mail-draft-edit.md index 0875a18a..c88d6a0b 100644 --- a/skills/lark-mail/references/lark-mail-draft-edit.md +++ b/skills/lark-mail/references/lark-mail-draft-edit.md @@ -219,6 +219,22 @@ lark-cli mail +draft-edit --draft-id --inspect { "op": "remove_inline", "target": { "cid": "logo" } } ``` +`insert_signature` + +```json +{ "op": "insert_signature", "signature_id": "<签名ID>" } +``` + +插入签名到正文末尾(引用块之前)。如已有签名则先移除再插入。运行 `mail +signature` 获取可用签名 ID。签名中的模板变量会自动替换,内联图片自动下载嵌入。 + +`remove_signature` + +```json +{ "op": "remove_signature" } +``` + +移除草稿中的现有签名(含签名前的空行间距)。如签名包含内联图片且正文不再引用这些图片,对应的 MIME part 也会一并移除。 + 注意事项: - `ops` 按顺序执行 @@ -228,6 +244,7 @@ lark-cli mail +draft-edit --draft-id --inspect - **`set_body` 是完整替换** — 它替换整个正文内容(包括引用区) - **`set_reply_body` 仅替换引用区前面的用户撰写部分** — 引用区自动重新拼接;value 只传用户撰写内容,不要包含引用区;如果用户要修改引用区内容,用 `set_body` 全量覆盖 - 通过 `--inspect` 返回的 `has_quoted_content` 字段可判断草稿是否包含引用区 +- 通过 `--inspect` 返回的 `has_signature` / `signature_id` 字段可判断草稿是否包含签名 ## 返回值 diff --git a/skills/lark-mail/references/lark-mail-forward.md b/skills/lark-mail/references/lark-mail-forward.md index 17a271d4..d2653587 100644 --- a/skills/lark-mail/references/lark-mail-forward.md +++ b/skills/lark-mail/references/lark-mail-forward.md @@ -66,6 +66,7 @@ lark-cli mail +forward --message-id <邮件ID> --to alice@example.com --dry-run | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔,追加在原邮件附件之后。相对路径 | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | +| `--signature-id ` | 否 | 签名 ID。附加邮箱签名到转发正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送转发(默认只保存草稿)。仅在用户明确确认后使用 | | `--send-time ` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-reply-all.md b/skills/lark-mail/references/lark-mail-reply-all.md index 645f758a..ec3d470f 100644 --- a/skills/lark-mail/references/lark-mail-reply-all.md +++ b/skills/lark-mail/references/lark-mail-reply-all.md @@ -70,6 +70,7 @@ lark-cli mail +reply-all --message-id <邮件ID> --body '测试' --dry-run | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | +| `--signature-id ` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | | `--send-time ` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-reply.md b/skills/lark-mail/references/lark-mail-reply.md index c75902de..70b97558 100644 --- a/skills/lark-mail/references/lark-mail-reply.md +++ b/skills/lark-mail/references/lark-mail-reply.md @@ -73,6 +73,7 @@ lark-cli mail +reply --message-id <邮件ID> --body '

测试

' --dry-run | `--plain-text` | 否 | 强制纯文本模式,忽略所有 HTML 自动检测。不可与 `--inline` 同时使用 | | `--attach ` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | +| `--signature-id ` | 否 | 签名 ID。附加邮箱签名到回复正文与引用块之间。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送回复(默认只保存草稿)。仅在用户明确确认后使用 | | `--send-time ` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-send.md b/skills/lark-mail/references/lark-mail-send.md index 1b4a4053..a08c0480 100644 --- a/skills/lark-mail/references/lark-mail-send.md +++ b/skills/lark-mail/references/lark-mail-send.md @@ -70,6 +70,7 @@ lark-cli mail +send --to alice@example.com --subject '测试' --body '

test

` | 否 | 附件文件路径,多个用逗号分隔。相对路径 | | `--inline ` | 否 | 高级用法:手动指定内嵌图片 CID 映射。推荐直接在 `--body` 中使用 ``(自动解析)。仅在需要精确控制 CID 命名时使用此参数。格式:`'[{"cid":"mycid","file_path":"./logo.png"}]'`,在 body 中用 `` 引用。不可与 `--plain-text` 同时使用 | +| `--signature-id ` | 否 | 签名 ID。附加邮箱签名到正文末尾。运行 `mail +signature` 查看可用签名。不可与 `--plain-text` 同时使用 | | `--confirm-send` | 否 | 确认发送邮件(默认只保存草稿)。仅在用户明确确认收件人和内容后使用 | | `--send-time ` | 否 | 定时发送时间,Unix 时间戳(秒)。需至少为当前时间 + 5 分钟。配合 `--confirm-send` 使用可定时发送邮件 | | `--dry-run` | 否 | 仅打印请求,不执行 | diff --git a/skills/lark-mail/references/lark-mail-signature.md b/skills/lark-mail/references/lark-mail-signature.md new file mode 100644 index 00000000..03ad0c66 --- /dev/null +++ b/skills/lark-mail/references/lark-mail-signature.md @@ -0,0 +1,98 @@ +# mail +signature + +> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查看邮箱签名列表或详情。返回签名的类型、默认使用情况、内容预览等信息。TENANT(企业)签名的模板变量会被自动替换为实际值。 + +本 skill 对应 shortcut:`lark-cli mail +signature`。 + +## 命令 + +```bash +# 列出所有签名 +lark-cli mail +signature + +# 查看某个签名的详情(渲染后的内容预览、模板变量值、图片信息) +lark-cli mail +signature --detail + +# 指定邮箱 +lark-cli mail +signature --from shared@example.com +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--from ` | 否 | 邮箱地址(默认 `me`) | +| `--detail ` | 否 | 签名 ID,查看详情。省略则列出所有签名 | + +## 返回值 + +**列表模式:** + +```json +{ + "ok": true, + "data": { + "signatures": [ + { + "id": "<签名ID>", + "name": "个人签名", + "type": "USER", + "content_preview": "这是我的签名内容 [image] 超链接哈哈" + }, + { + "id": "<签名ID>", + "name": "企业签名", + "type": "TENANT", + "is_send_default": true, + "is_reply_default": true, + "content_preview": "企业签名 姓名:陈煌 部门:研发团队" + } + ] + } +} +``` + +**详情模式(`--detail`):** + +```json +{ + "ok": true, + "data": { + "id": "<签名ID>", + "name": "企业签名", + "type": "TENANT", + "is_send_default": true, + "is_reply_default": true, + "images": [ + {"cid": "76CEB29E-...", "file_key": "121011...", "image_name": "image.png"} + ], + "template_vars": {"B-NAME": "陈煌", "B-DEPARTMENT": "研发团队"}, + "content_preview": "企业签名 姓名:陈煌 部门:研发团队" + } +} +``` + +## 字段说明 + +| 字段 | 说明 | +|------|------| +| `type` | `USER`(用户签名,可编辑)或 `TENANT`(企业签名,管理员模板控制) | +| `is_send_default` | 是否为新邮件的默认签名 | +| `is_reply_default` | 是否为回复/转发的默认签名 | +| `images` | 签名内联图片元数据(仅详情模式) | +| `template_vars` | TENANT 签名的模板变量已替换值(仅详情模式) | +| `content_preview` | 签名内容的纯文本预览(`` 显示为 `[image]`,最长 200 字符) | + +## 与 compose shortcut 配合 + +获取签名 ID 后,可在发送/回复/转发时附加签名: + +```bash +# 查看签名列表获取 ID +lark-cli mail +signature + +# 在发送邮件时附加签名 +lark-cli mail +send --to alice@example.com --subject '你好' --body '

内容

' --signature-id <签名ID> +```