mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 14:02:43 +08:00
* 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.
339 lines
8.2 KiB
Go
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())
|
|
}
|