项目文件夹

文件
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

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
}