larksuite--cli
bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
314 行
11 KiB
Go
314 行
11 KiB
Go
// 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/errs"
|
|
"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.",
|
|
}
|
|
|
|
// noSignatureFlag is shared by all 5 compose shortcuts.
|
|
var noSignatureFlag = common.Flag{
|
|
Name: "no-signature",
|
|
Type: "bool",
|
|
Desc: "Skip automatic default signature insertion. Mutually exclusive with --signature-id.",
|
|
}
|
|
|
|
// validateNoSignatureConflict returns a structured validation error when
|
|
// --no-signature and --signature-id are both set; they are mutually exclusive.
|
|
func validateNoSignatureConflict(noSignature bool, signatureID string) error {
|
|
if noSignature && signatureID != "" {
|
|
return mailValidationParamError("--no-signature", "--no-signature and --signature-id are mutually exclusive")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// autoResolveSignatureID resolves the default signature ID for the given mailbox/sender.
|
|
// isReply=true uses DefaultReplyID (+reply/+reply-all/+forward);
|
|
// isReply=false uses DefaultSendID (+send/+draft-create).
|
|
// Returns "" on API failure (writes stderr warning) or when no default is configured.
|
|
func autoResolveSignatureID(runtime *common.RuntimeContext, mailboxID, senderEmail string, isReply bool) string {
|
|
resp, err := signature.ListAll(runtime, mailboxID)
|
|
if err != nil {
|
|
fmt.Fprintf(runtime.IO().ErrOut,
|
|
"warning: failed to fetch default signature: %v; sending without signature\n", err)
|
|
return ""
|
|
}
|
|
if isReply {
|
|
return signature.DefaultReplyID(resp.Usages, senderEmail)
|
|
}
|
|
return signature.DefaultSendID(resp.Usages, senderEmail)
|
|
}
|
|
|
|
// injectPlainTextSignature appends a plain-text rendering of the signature to a
|
|
// plain-text body. The HTML signature (sig.RenderedContent) is converted via
|
|
// draftpkg.PlainTextFromHTML; inline images are dropped (plain text has none).
|
|
// Returns textBody unchanged when sig is nil.
|
|
func injectPlainTextSignature(textBody string, sig *signatureResult) string {
|
|
if sig == nil {
|
|
return textBody
|
|
}
|
|
sigText := strings.TrimRight(draftpkg.PlainTextFromHTML(sig.RenderedContent), "\n")
|
|
if sigText == "" {
|
|
return textBody
|
|
}
|
|
return textBody + "\n\n" + sigText
|
|
}
|
|
|
|
// 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 optionally 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.
|
|
//
|
|
// userExplicit must be true when the caller obtained signatureID from a user-supplied flag
|
|
// (--signature-id); false when the ID was auto-resolved from default usages. When false,
|
|
// a "not found" error from the signatures API is treated as graceful degradation (no
|
|
// signature) rather than a hard failure — this protects against stale default IDs.
|
|
//
|
|
// includeImages controls whether inline image attachments are downloaded. Pass false for
|
|
// plain-text compose paths to avoid unnecessary network I/O (images are discarded in
|
|
// plain-text mode anyway).
|
|
func resolveSignature(ctx context.Context, runtime *common.RuntimeContext, mailboxID, signatureID, fromEmail string, userExplicit, includeImages bool) (*signatureResult, error) {
|
|
if signatureID == "" {
|
|
return nil, nil
|
|
}
|
|
|
|
sig, err := signature.Get(runtime, mailboxID, signatureID)
|
|
if err != nil {
|
|
if !userExplicit && errs.IsValidation(err) {
|
|
// Stale auto-resolved default signature ID — degrade gracefully instead of
|
|
// blocking the entire send/reply/forward operation.
|
|
fmt.Fprintf(runtime.IO().ErrOut,
|
|
"warning: default signature %q not found in current list; sending without signature\n", signatureID)
|
|
return nil, 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 only when the compose path needs them.
|
|
// Plain-text paths discard images, so skip the download to avoid unnecessary
|
|
// network I/O (and potential failures from expired pre-signed URLs).
|
|
var images []draftpkg.SignatureImage
|
|
if includeImages {
|
|
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, mailDecorateProblemMessage(err, "failed to download signature image %s", img.ImageName)
|
|
}
|
|
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, placing
|
|
// it right after the user-authored region and before any system-managed
|
|
// tail (large attachment card or quote block). Any existing signature is
|
|
// removed first. Returns the new full HTML body.
|
|
//
|
|
// Delegates to draftpkg.PlaceSignatureBeforeSystemTail for the actual
|
|
// placement, sharing a single source of truth with the edit-time
|
|
// insert_signature op so both paths yield identical structure.
|
|
func injectSignatureIntoBody(bodyHTML string, sig *signatureResult) string {
|
|
if sig == nil {
|
|
return bodyHTML
|
|
}
|
|
sigBlock := draftpkg.SignatureSpacing() + draftpkg.BuildSignatureHTML(sig.ID, sig.RenderedContent)
|
|
return draftpkg.PlaceSignatureBeforeSystemTail(bodyHTML, sigBlock)
|
|
}
|
|
|
|
// 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 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.CallAPITyped("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, "", mailInvalidResponseError("signature image download: invalid URL: %v", err).WithCause(err)
|
|
}
|
|
if u.Scheme != "https" {
|
|
return nil, "", mailInvalidResponseError("signature image download: URL must use https (got %q)", u.Scheme)
|
|
}
|
|
if u.Host == "" {
|
|
return nil, "", mailInvalidResponseError("signature image download: URL has no host")
|
|
}
|
|
|
|
httpClient, err := runtime.Factory.HttpClient()
|
|
if err != nil {
|
|
return nil, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(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, "", errs.NewInternalError(errs.SubtypeSDKError, "signature image download: %v", err).WithCause(err)
|
|
}
|
|
// Do NOT send Authorization: the download URL is pre-signed.
|
|
|
|
resp, err := httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "signature image download: %v", err).WithCause(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode >= 400 {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
if resp.StatusCode >= 500 {
|
|
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkServer, "signature image download: HTTP %d: %s", resp.StatusCode, string(body)).
|
|
WithCode(resp.StatusCode).
|
|
WithRetryable()
|
|
}
|
|
subtype := errs.SubtypeUnknown
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
subtype = errs.SubtypeNotFound
|
|
}
|
|
return nil, "", errs.NewAPIError(subtype, "signature image download: HTTP %d: %s", resp.StatusCode, string(body)).WithCode(resp.StatusCode)
|
|
}
|
|
|
|
const maxSize = 10 * 1024 * 1024
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
|
|
if err != nil {
|
|
return nil, "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "signature image download: read body: %v", err).WithCause(err)
|
|
}
|
|
if len(data) > maxSize {
|
|
return nil, "", mailFailedPreconditionError("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
|
|
}
|