Files
larksuite-cli/shortcuts/mail/draft/serialize.go
evandance 5e6a3eb857 feat(mail): return typed error envelopes across the mail domain (#1250)
* feat(mail): return typed error envelopes across the mail domain

Replace every produced error path in shortcuts/mail with typed errs.* envelopes, so consumers get stable category, subtype, param/params, hint, retryable, and log_id metadata for classification and recovery instead of free-form message text.

- Locally constructed mail errors move from output.Err* / output.Errorf / final fmt.Errorf / common legacy helpers to errs.* builders, with structured params on multi-flag validation and failed-precondition states kept non-retryable.

- API-call failures move from runtime.CallAPI / DoAPIJSON legacy boundaries to runtime.CallAPITyped or runtime.ClassifyAPIResponse, and mail-specific enrichers read errs.ProblemOf so typed code, subtype, hint, and log_id metadata are preserved.

- Batch draft-send partial failures now use runtime.OutPartialFailure so successful and failed draft sends stay in stdout while the command exits through a typed multi-status signal.

- Add mail-domain typed helpers, mail API code metadata, and guard wiring to keep shortcuts/mail from reintroducing legacy envelopes or legacy API calls.

- Keep genuine intermediate fmt.Errorf wraps in parser/builder layers annotated with nolint comments; command-facing paths wrap them into typed validation, API, network, or internal errors.

* fix(mail): report aborted draft-send batches as a single failure result

When an account-level failure interrupts a batch send after some drafts
already went out, the command previously produced two machine-readable
failure results: the partial-failure ledger on stdout and a second error
envelope on stderr. Consumers could not tell which one to recover from.

The batch ledger is now the only failure result for that case: it gains
aborted and abort_error fields carrying the typed cause, so callers can
see which drafts were sent, which failed, why the batch stopped, and how
to recover — all from stdout. A --stop-on-error stop keeps these fields
unset because stopping early there is the caller's own choice.
2026-06-04 21:02:20 +08:00

339 lines
8.2 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//nolint:forbidigo // intermediate draft serializer errors; mail command layer wraps into typed ValidationError.
package draft
import (
"bytes"
"encoding/base64"
"fmt"
"math/rand"
"mime"
"mime/quotedprintable"
"strings"
)
func Serialize(snapshot *DraftSnapshot) (string, error) {
if snapshot == nil || snapshot.Body == nil {
return "", fmt.Errorf("draft snapshot is empty")
}
var buf bytes.Buffer
mimeVersionValue := "1.0"
wroteMimeVersion := false
for _, header := range snapshot.Headers {
if strings.EqualFold(header.Name, "MIME-Version") {
mimeVersionValue = header.Value
writeHeader(&buf, header.Name, header.Value)
wroteMimeVersion = true
continue
}
if isBodyHeader(header.Name) {
continue
}
writeHeader(&buf, header.Name, header.Value)
}
if !wroteMimeVersion {
writeHeader(&buf, "MIME-Version", mimeVersionValue)
}
if err := writeTopLevelBody(&buf, snapshot.Body); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(buf.Bytes()), nil
}
func writeTopLevelBody(buf *bytes.Buffer, root *Part) error {
if canReuseRawEntity(root) {
buf.Write(root.RawEntity)
if len(root.RawEntity) == 0 || root.RawEntity[len(root.RawEntity)-1] != '\n' {
buf.WriteByte('\n')
}
return nil
}
if root.IsMultipart() {
for _, header := range orderedPartHeaders(root, false) {
writeHeader(buf, header.Name, header.Value)
}
buf.WriteByte('\n')
return writeMultipartBody(buf, root)
}
for _, header := range orderedPartHeaders(root, true) {
writeHeader(buf, header.Name, header.Value)
}
buf.WriteByte('\n')
return writeLeafBody(buf, root)
}
func writeMultipartBody(buf *bytes.Buffer, part *Part) error {
boundary := part.MediaParams["boundary"]
if boundary == "" {
boundary = newBoundary()
part.MediaParams["boundary"] = boundary
}
if len(part.Preamble) > 0 {
buf.Write(part.Preamble)
if part.Preamble[len(part.Preamble)-1] != '\n' {
buf.WriteByte('\n')
}
}
for _, child := range part.Children {
if child == nil {
continue
}
fmt.Fprintf(buf, "--%s\n", boundary)
if canReuseRawEntity(child) {
buf.Write(child.RawEntity)
if n := len(child.RawEntity); n == 0 || child.RawEntity[n-1] != '\n' {
buf.WriteByte('\n')
}
continue
}
if child.IsMultipart() {
for _, header := range orderedPartHeaders(child, false) {
writeHeader(buf, header.Name, header.Value)
}
buf.WriteByte('\n')
if err := writeMultipartBody(buf, child); err != nil {
return err
}
continue
}
for _, header := range orderedPartHeaders(child, true) {
writeHeader(buf, header.Name, header.Value)
}
buf.WriteByte('\n')
if err := writeLeafBody(buf, child); err != nil {
return err
}
}
fmt.Fprintf(buf, "--%s--\n", boundary)
if len(part.Epilogue) > 0 {
buf.Write(part.Epilogue)
if part.Epilogue[len(part.Epilogue)-1] != '\n' {
buf.WriteByte('\n')
}
}
return nil
}
func orderedPartHeaders(part *Part, includeCTE bool) []Header {
contentTypeValue := existingHeaderValue(part.Headers, "Content-Type")
if contentTypeValue == "" {
contentTypeValue = mime.FormatMediaType(part.MediaType, cloneStringMap(part.MediaParams))
}
headers := make([]Header, 0, len(part.Headers)+4)
replacements := map[string]Header{
"content-type": {
Name: "Content-Type",
Value: contentTypeValue,
},
}
if includeCTE {
if cte := chooseTransferEncoding(part); cte != "" {
value := cte
if existing := existingHeaderValue(part.Headers, "Content-Transfer-Encoding"); strings.EqualFold(existing, cte) {
value = existing
}
replacements["content-transfer-encoding"] = Header{
Name: "Content-Transfer-Encoding",
Value: value,
}
}
}
if part.ContentDisposition != "" {
value := existingHeaderValue(part.Headers, "Content-Disposition")
if value == "" {
value = mime.FormatMediaType(part.ContentDisposition, cloneStringMap(part.ContentDispositionArg))
}
replacements["content-disposition"] = Header{
Name: "Content-Disposition",
Value: value,
}
}
if part.ContentID != "" {
value := existingHeaderValue(part.Headers, "Content-ID")
if value == "" {
value = "<" + part.ContentID + ">"
}
replacements["content-id"] = Header{Name: "Content-ID", Value: value}
}
written := make(map[string]bool, len(replacements))
for _, header := range part.Headers {
name := strings.ToLower(header.Name)
switch name {
case "mime-version":
continue
case "content-type", "content-transfer-encoding", "content-disposition", "content-id":
if replacement, ok := replacements[name]; ok {
replacement.Name = header.Name
headers = append(headers, replacement)
written[name] = true
}
default:
headers = append(headers, header)
}
}
for _, key := range []string{"content-type", "content-transfer-encoding", "content-disposition", "content-id"} {
if written[key] {
continue
}
if replacement, ok := replacements[key]; ok {
headers = append(headers, replacement)
}
}
return headers
}
func chooseTransferEncoding(part *Part) string {
if part.IsMultipart() {
return ""
}
switch {
case part.ContentDisposition == "attachment":
return "base64"
case strings.HasPrefix(part.MediaType, "text/"):
switch strings.ToLower(strings.TrimSpace(part.TransferEncoding)) {
case "quoted-printable":
return "quoted-printable"
case "base64":
if hasNonASCII(part.Body) {
return "base64"
}
}
if hasNonASCII(part.Body) {
return "quoted-printable"
}
return "7bit"
default:
return "base64"
}
}
func writeLeafBody(buf *bytes.Buffer, part *Part) error {
body, err := encodedLeafBody(part)
if err != nil {
return err
}
cte := chooseTransferEncoding(part)
switch cte {
case "base64":
writeFoldedBody(buf, base64.StdEncoding.EncodeToString(body), 76)
case "quoted-printable":
writer := quotedprintable.NewWriter(buf)
if _, err := writer.Write(body); err != nil {
_ = writer.Close()
return err
}
if err := writer.Close(); err != nil {
return err
}
if buf.Len() == 0 || buf.Bytes()[buf.Len()-1] != '\n' {
buf.WriteByte('\n')
}
default:
if len(body) > 0 {
buf.Write(body)
if body[len(body)-1] != '\n' {
buf.WriteByte('\n')
}
} else {
buf.WriteByte('\n')
}
}
return nil
}
func writeFoldedBody(buf *bytes.Buffer, encoded string, width int) {
if width <= 0 {
width = 76
}
for len(encoded) > width {
buf.WriteString(encoded[:width])
buf.WriteByte('\n')
encoded = encoded[width:]
}
if encoded != "" {
buf.WriteString(encoded)
buf.WriteByte('\n')
}
}
func writeHeader(buf *bytes.Buffer, name, value string) {
// Strip CR and LF as a last-resort defense against header injection.
// Callers (applyOp, Validate) already reject CR/LF explicitly; this
// sanitisation covers any path that bypasses those checks.
name = strings.NewReplacer("\r", "", "\n", "").Replace(name)
value = strings.NewReplacer("\r", "", "\n", "").Replace(value)
buf.WriteString(name)
buf.WriteString(": ")
buf.WriteString(value)
buf.WriteByte('\n')
}
func existingHeaderValue(headers []Header, name string) string {
for _, header := range headers {
if strings.EqualFold(header.Name, name) {
return header.Value
}
}
return ""
}
func canReuseRawEntity(part *Part) bool {
if part == nil || len(part.RawEntity) == 0 {
return false
}
return !partHasDirty(part)
}
func partHasDirty(part *Part) bool {
if part == nil {
return false
}
if part.Dirty {
return true
}
for _, child := range part.Children {
if partHasDirty(child) {
return true
}
}
return false
}
func hasNonASCII(body []byte) bool {
for _, b := range body {
if b > 127 {
return true
}
}
return false
}
func encodedLeafBody(part *Part) ([]byte, error) {
if part == nil {
return nil, nil
}
if !isTextualMediaType(part.MediaType) {
return part.Body, nil
}
charsetLabel := normalizeCharsetLabel(part.MediaParams["charset"])
if charsetLabel == "" {
charsetLabel = "UTF-8"
part.MediaParams["charset"] = charsetLabel
}
encoded, err := encodeTextCharset(part.Body, charsetLabel)
if err == nil {
return encoded, nil
}
part.MediaParams["charset"] = "UTF-8"
syncStructuredPartHeaders(part)
return part.Body, nil
}
func newBoundary() string {
return fmt.Sprintf("lark-draft-%d-%d", rand.Int63(), rand.Int63())
}