Files
larksuite-cli/shortcuts/mail/emlbuilder/builder.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

1066 lines
34 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package emlbuilder provides a Lark-API-compatible RFC 2822 EML message builder.
//
// It is designed for use with the Lark mail drafts API
// (POST /open-apis/mail/v1/user_mailboxes/me/drafts), which requires the
// complete EML to be base64url-encoded and placed in the "raw" request field.
// After creating a draft, send it via POST .../drafts/{draft_id}/send.
//
// Key differences from standard MIME libraries:
// - Line endings are LF (\n), not CRLF — Lark API requires this.
// - Content-Type parameters are never folded onto a new line — Lark's MIME
// parser does not handle header folding correctly.
// - Non-ASCII body content is encoded as base64 (StdEncoding) — 7bit and 8bit
// are rejected by Lark for non-ASCII content.
// - BuildBase64URL() produces the base64url (URLEncoding) output that goes
// directly into the API's "raw" field.
//
// MIME structure produced by Build():
//
// multipart/mixed ← only when attachments exist
// └─ multipart/related ← only when CID inline/other parts exist
// └─ multipart/alternative ← only when multiple body types coexist
// ├─ text/plain
// ├─ text/html
// └─ text/calendar
// └─ inline/other parts (CID)
// └─ attachments
//
// Usage:
//
// raw, err := emlbuilder.New().
// From("", "alice@example.com").
// To("", "bob@example.com").
// Subject("Hello").
// TextBody([]byte("Hi Bob")).
// HTMLBody([]byte("<p>Hi Bob</p>")).
// AddInline(imgBytes, "image/png", "logo.png", "logo").
// BuildBase64URL()
package emlbuilder
import (
"bytes"
"encoding/base64"
"fmt"
"io"
"math/rand"
"mime"
"net/mail"
"path/filepath"
"strings"
"time"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/mail/filecheck"
)
// MaxEMLSize is the maximum allowed raw EML size in bytes.
const MaxEMLSize = 25 * 1024 * 1024 // 25 MB
// readFile reads the named file and returns its contents via FileIO.
func readFile(fio fileio.FileIO, path string) ([]byte, error) {
if _, err := validate.SafeInputPath(path); err != nil {
return nil, fmt.Errorf("attachment %q: %w", path, err) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
f, err := fio.Open(path)
if err != nil {
return nil, fmt.Errorf("attachment %q: %w", path, err) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
defer f.Close()
return io.ReadAll(f)
}
// Builder constructs a Lark-compatible RFC 2822 EML message.
// All setter methods return a copy of the Builder (immutable/fluent style),
// so a base builder can be reused across multiple goroutines safely.
type Builder struct {
fio fileio.FileIO // injected via WithFileIO; must be set before AddFile* calls
from mail.Address
to []mail.Address
cc []mail.Address
bcc []mail.Address
replyTo []mail.Address
dispositionNotificationTo []mail.Address
subject string
date time.Time
messageID string
inReplyTo string // raw value, without angle brackets
references string // space-separated list of message IDs, with angle brackets
lmsReplyToMessageID string // Lark internal message_id of the original message
textBody []byte
htmlBody []byte
calendarBody []byte
attachments []attachment
inlines []inline
extraHeaders [][2]string // ordered list of [name, value] pairs
allowNoRecipients bool // when true, Build() skips the recipient check (for drafts)
isReadReceiptMail bool // when true, Build() writes X-Lark-Read-Receipt-Mail: 1
err error
}
// WithFileIO returns a copy of b with the given FileIO.
func (b Builder) WithFileIO(fio fileio.FileIO) Builder {
b.fio = fio
return b
}
// attachment is a regular (non-inline) MIME attachment — bytes plus MIME
// metadata — accumulated on the Builder and serialized under the
// multipart/mixed outer envelope.
type attachment struct {
content []byte
contentType string
fileName string
}
// inline represents a CID-referenced embedded MIME part (inline image or other resource).
type inline struct {
content []byte
contentType string
fileName string
contentID string // without angle brackets
isOtherPart bool // true = no Content-Disposition (AddOtherPart); false = Content-Disposition: inline
}
// New returns an empty Builder.
func New() Builder {
return Builder{}
}
// validateHeaderValue rejects strings that contain characters unsafe in MIME
// header values: C0 control chars (except \t for folded headers), DEL (0x7F),
// and dangerous Unicode (Bidi overrides, zero-width chars) that enable
// visual-spoofing attacks.
func validateHeaderValue(v string) error {
for _, r := range v {
if r != '\t' && (r < 0x20 || r == 0x7f) {
return fmt.Errorf("emlbuilder: header value contains control character: %q", v) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
if isHeaderDangerousUnicode(r) {
return fmt.Errorf("emlbuilder: header value contains dangerous Unicode character: %q", v) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
}
return nil
}
// isHeaderDangerousUnicode identifies Unicode code points used for visual
// spoofing: Bidi overrides that reverse display order, and zero-width characters
// that hide content. These must not appear in email header values.
func isHeaderDangerousUnicode(r rune) bool {
switch {
case r >= 0x200B && r <= 0x200D: // zero-width space/non-joiner/joiner
return true
case r == 0xFEFF: // BOM / zero-width no-break space
return true
case r >= 0x202A && r <= 0x202E: // Bidi: LRE/RLE/PDF/LRO/RLO
return true
case r >= 0x2028 && r <= 0x2029: // line/paragraph separator
return true
case r >= 0x2066 && r <= 0x2069: // Bidi isolates: LRI/RLI/FSI/PDI
return true
}
return false
}
// validateHeaderName rejects any string that contains ':', CR (\r), LF (\n),
// or non-printable ASCII characters, as required by RFC 5322 field-name syntax.
func validateHeaderName(n string) error {
if strings.ContainsAny(n, ":\r\n") {
return fmt.Errorf("emlbuilder: header name contains ':', CR, or LF: %q", n) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
for _, r := range n {
if r < 0x21 || r > 0x7e {
return fmt.Errorf("emlbuilder: header name contains non-printable character: %q", n) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
}
return nil
}
// validateDisplayName rejects display names containing CR or LF, which could
// escape the quoted-string encoding used by mail.Address.String() and inject headers.
func validateDisplayName(name string) error {
if strings.ContainsAny(name, "\r\n") {
return fmt.Errorf("emlbuilder: display name contains CR or LF: %q", name) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
return nil
}
// validateCID rejects content IDs containing ASCII control characters (0x000x1F, 0x7F).
// RFC 2045 Content-ID has the same syntax as Message-ID; control characters are never valid.
func validateCID(cid string) error {
for _, r := range cid {
if r < 0x20 || r == 0x7f {
return fmt.Errorf("emlbuilder: content ID contains control character: %q", cid) //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
}
return nil
}
// From sets the From header. name may be empty.
func (b Builder) From(name, addr string) Builder {
if b.err != nil {
return b
}
if err := validateDisplayName(name); err != nil {
b.err = err
return b
}
b.from = mail.Address{Name: name, Address: addr}
return b
}
// To appends an address to the To header. name may be empty.
func (b Builder) To(name, addr string) Builder {
if addr == "" {
return b
}
if b.err != nil {
return b
}
if err := validateDisplayName(name); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.to = append(cp.to, mail.Address{Name: name, Address: addr})
return cp
}
// ToAddrs sets the To header to the given address list.
func (b Builder) ToAddrs(addrs []mail.Address) Builder {
b.to = addrs
return b
}
// CC appends an address to the Cc header. name may be empty.
func (b Builder) CC(name, addr string) Builder {
if addr == "" {
return b
}
if b.err != nil {
return b
}
if err := validateDisplayName(name); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.cc = append(cp.cc, mail.Address{Name: name, Address: addr})
return cp
}
// CCAddrs sets the Cc header to the given address list.
func (b Builder) CCAddrs(addrs []mail.Address) Builder {
b.cc = addrs
return b
}
// BCC appends an address to the Bcc list.
// Bcc addresses are included in AllRecipients() but not written to the EML headers.
func (b Builder) BCC(name, addr string) Builder {
if addr == "" {
return b
}
if b.err != nil {
return b
}
if err := validateDisplayName(name); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.bcc = append(cp.bcc, mail.Address{Name: name, Address: addr})
return cp
}
// BCCAddrs sets the Bcc list to the given address list.
func (b Builder) BCCAddrs(addrs []mail.Address) Builder {
b.bcc = addrs
return b
}
// ReplyTo appends an address to the Reply-To header. name may be empty.
func (b Builder) ReplyTo(name, addr string) Builder {
if addr == "" {
return b
}
if b.err != nil {
return b
}
if err := validateDisplayName(name); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.replyTo = append(cp.replyTo, mail.Address{Name: name, Address: addr})
return cp
}
// DispositionNotificationTo appends an address to the Disposition-Notification-To header,
// which requests a Message Disposition Notification (MDN, read receipt) from the recipient's
// mail user agent (RFC 3798). name may be empty.
//
// Recipients' clients are not obliged to honour this header; user agents commonly prompt
// the recipient, and many silently ignore it.
func (b Builder) DispositionNotificationTo(name, addr string) Builder {
if addr == "" {
return b
}
if b.err != nil {
return b
}
if err := validateDisplayName(name); err != nil {
b.err = err
return b
}
// addr ends up inside mail.Address.String() and written unescaped into
// the Disposition-Notification-To header; validate it the same way as
// other header value inputs to prevent CR/LF header injection and
// visual-spoofing via Bidi / zero-width code points.
if err := validateHeaderValue(addr); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.dispositionNotificationTo = append(cp.dispositionNotificationTo, mail.Address{Name: name, Address: addr})
return cp
}
// Subject sets the Subject header.
// Non-ASCII characters are automatically RFC 2047 B-encoded.
// Returns an error builder if subject contains CR or LF.
func (b Builder) Subject(subject string) Builder {
if b.err != nil {
return b
}
if err := validateHeaderValue(subject); err != nil {
b.err = err
return b
}
b.subject = subject
return b
}
// Date sets the Date header. If not set, Build() uses time.Now().
func (b Builder) Date(date time.Time) Builder {
b.date = date
return b
}
// MessageID sets the Message-ID header value (without angle brackets).
// If not set, Build() generates a unique ID.
// Returns an error builder if id contains CR or LF.
func (b Builder) MessageID(id string) Builder {
if b.err != nil {
return b
}
if err := validateHeaderValue(id); err != nil {
b.err = err
return b
}
b.messageID = id
return b
}
// InReplyTo sets the In-Reply-To header (the smtp_message_id of the original mail,
// without angle brackets). Used for reply threading.
// Returns an error builder if id contains CR or LF.
func (b Builder) InReplyTo(id string) Builder {
if b.err != nil {
return b
}
if err := validateHeaderValue(id); err != nil {
b.err = err
return b
}
b.inReplyTo = id
return b
}
// LMSReplyToMessageID sets the Lark internal message_id of the original message.
// Written as X-LMS-Reply-To-Message-Id when In-Reply-To is also set.
// Returns an error builder if id contains CR or LF.
func (b Builder) LMSReplyToMessageID(id string) Builder {
if b.err != nil {
return b
}
if err := validateHeaderValue(id); err != nil {
b.err = err
return b
}
b.lmsReplyToMessageID = id
return b
}
// References sets the References header value verbatim.
// Typically a space-separated list of message IDs including angle brackets,
// e.g. "<id1@host> <id2@host>".
// Returns an error builder if refs contains CR or LF.
func (b Builder) References(refs string) Builder {
if b.err != nil {
return b
}
if err := validateHeaderValue(refs); err != nil {
b.err = err
return b
}
b.references = refs
return b
}
// TextBody sets the text/plain body.
func (b Builder) TextBody(body []byte) Builder {
b.textBody = body
return b
}
// HTMLBody sets the text/html body.
func (b Builder) HTMLBody(body []byte) Builder {
b.htmlBody = body
return b
}
// CalendarBody sets the text/calendar body (e.g. for meeting invitations).
// When combined with TextBody or HTMLBody, the calendar part is placed inside
// multipart/alternative alongside the body parts, matching the Feishu client
// convention for calendar invitation emails.
func (b Builder) CalendarBody(body []byte) Builder {
b.calendarBody = body
return b
}
// AddAttachment appends a file attachment.
// contentType should be a valid MIME type (e.g. "application/pdf").
// If contentType is empty, "application/octet-stream" is used.
// Returns an error builder if contentType or fileName contains CR or LF.
func (b Builder) AddAttachment(content []byte, contentType, fileName string) Builder {
if b.err != nil {
return b
}
if err := validateHeaderValue(fileName); err != nil {
b.err = err
return b
}
if contentType == "" {
contentType = "application/octet-stream"
}
if err := validateHeaderValue(contentType); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.attachments = append(cp.attachments, attachment{
content: content,
contentType: contentType,
fileName: fileName,
})
return cp
}
// AddFileAttachment reads a file from disk and appends it as an attachment.
// The backend canonicalizes regular attachments to application/octet-stream on
// save/readback, so the builder aligns with that behavior instead of inferring
// a richer MIME type from the local file extension. If reading the file fails,
// the error is stored and returned by Build().
func (b Builder) AddFileAttachment(path string) Builder {
if b.err != nil {
return b
}
if err := filecheck.CheckBlockedExtension(filepath.Base(path)); err != nil {
b.err = err
return b
}
content, err := readFile(b.fio, path)
if err != nil {
b.err = err
return b
}
name := filepath.Base(path)
return b.AddAttachment(content, "application/octet-stream", name)
}
// AddInline appends a CID-referenced inline part (e.g. an embedded image).
// The part is written with Content-Disposition: inline, causing most mail clients
// to render it inline rather than as a download.
// contentID is a unique identifier without angle brackets; it matches the "cid:"
// reference in the HTML body (e.g. contentID="logo.png" matches src="cid:logo.png").
// When inline parts are present, the message body is automatically wrapped in
// multipart/related.
// Returns an error builder if contentType or fileName contains CR or LF, or if
// contentID contains any ASCII control character.
func (b Builder) AddInline(content []byte, contentType, fileName, contentID string) Builder {
if b.err != nil {
return b
}
if contentType == "" {
contentType = "application/octet-stream"
}
if err := validateHeaderValue(contentType); err != nil {
b.err = err
return b
}
if err := validateHeaderValue(fileName); err != nil {
b.err = err
return b
}
if err := validateCID(contentID); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.inlines = append(cp.inlines, inline{
content: content,
contentType: contentType,
fileName: fileName,
contentID: contentID,
isOtherPart: false,
})
return cp
}
// AddFileInline reads a file from disk and appends it as a CID inline part.
// The content type is inferred from the file extension.
// If reading the file fails, the error is stored and returned by Build().
func (b Builder) AddFileInline(path, contentID string) Builder {
if b.err != nil {
return b
}
content, err := readFile(b.fio, path)
if err != nil {
b.err = err
return b
}
name := filepath.Base(path)
ct, err := filecheck.CheckInlineImageFormat(name, content)
if err != nil {
b.err = err
return b
}
return b.AddInline(content, ct, name, contentID)
}
// AddOtherPart appends a CID-referenced embedded part without Content-Disposition.
// Unlike AddInline, this part carries no Content-Disposition header, which is
// appropriate for resources referenced via "cid:" that should not appear as inline
// attachments in the client UI (e.g. calendar objects or custom data blobs).
// When other parts are present, the message body is automatically wrapped in
// multipart/related.
// Returns an error builder if contentType or fileName contains CR or LF, or if
// contentID contains any ASCII control character.
func (b Builder) AddOtherPart(content []byte, contentType, fileName, contentID string) Builder {
if b.err != nil {
return b
}
if contentType == "" {
contentType = "application/octet-stream"
}
if err := validateHeaderValue(contentType); err != nil {
b.err = err
return b
}
if err := validateHeaderValue(fileName); err != nil {
b.err = err
return b
}
if err := validateCID(contentID); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.inlines = append(cp.inlines, inline{
content: content,
contentType: contentType,
fileName: fileName,
contentID: contentID,
isOtherPart: true,
})
return cp
}
// AddFileOtherPart reads a file from disk and appends it as a CID other-part
// (no Content-Disposition header). See AddOtherPart for details.
// If reading the file fails, the error is stored and returned by Build().
func (b Builder) AddFileOtherPart(path, contentID string) Builder {
if b.err != nil {
return b
}
content, err := readFile(b.fio, path)
if err != nil {
b.err = err
return b
}
name := filepath.Base(path)
ct := mime.TypeByExtension(filepath.Ext(name))
if ct == "" {
ct = "application/octet-stream"
}
return b.AddOtherPart(content, ct, name, contentID)
}
// AllowNoRecipients tells Build() to skip the recipient-required check.
// Use this for draft creation, where saving without recipients is valid.
func (b Builder) AllowNoRecipients() Builder {
b.allowNoRecipients = true
return b
}
// IsReadReceiptMail marks this message as a read-receipt response.
// When true, Build() writes the private header "X-Lark-Read-Receipt-Mail: 1",
// which data-access extracts into MailBodyExtra.IsReadReceiptMail on draft
// creation so the subsequent DraftSend applies the READ_RECEIPT_SENT label.
//
// The header is a Lark-internal signal; smtp-out-mail-out is expected to
// strip X-Lark-* private headers before external delivery.
func (b Builder) IsReadReceiptMail(v bool) Builder {
if b.err != nil {
return b
}
b.isReadReceiptMail = v
return b
}
// Header appends an extra header to the message.
// Multiple calls with the same name result in multiple header lines.
// Returns an error builder if name or value contains CR, LF, or (for names) ':'.
func (b Builder) Header(name, value string) Builder {
if b.err != nil {
return b
}
if err := validateHeaderName(name); err != nil {
b.err = err
return b
}
if err := validateHeaderValue(value); err != nil {
b.err = err
return b
}
cp := b.copySlices()
cp.extraHeaders = append(cp.extraHeaders, [2]string{name, value})
return cp
}
// Error returns any stored error (e.g. from AddFileAttachment), or nil.
func (b Builder) Error() error {
return b.err
}
// AllRecipients returns all recipient addresses (To + CC + BCC).
// Useful for SMTP envelope construction.
func (b Builder) AllRecipients() []string {
out := make([]string, 0, len(b.to)+len(b.cc)+len(b.bcc))
for _, a := range b.to {
out = append(out, a.Address)
}
for _, a := range b.cc {
out = append(out, a.Address)
}
for _, a := range b.bcc {
out = append(out, a.Address)
}
return out
}
// Build validates the builder and returns the raw EML bytes.
//
// Constraints (Lark API requirements):
// - From is mandatory.
// - At least one of To/CC/BCC must be set.
// - Line endings are LF (\n), not CRLF.
// - Content-Type parameters are written on a single line (no header folding).
// - Non-ASCII body content is base64 (StdEncoding) encoded.
func (b Builder) Build() ([]byte, error) {
if b.err != nil {
return nil, b.err
}
if b.from.Address == "" {
return nil, fmt.Errorf("emlbuilder: From address is required") //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
if !b.allowNoRecipients && len(b.to)+len(b.cc)+len(b.bcc) == 0 {
return nil, fmt.Errorf("emlbuilder: at least one recipient (To/CC/BCC) is required") //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
}
date := b.date
if date.IsZero() {
date = time.Now()
}
msgID := b.messageID
if msgID == "" {
msgID = fmt.Sprintf("%d.%d@larksuite-cli", date.UnixNano(), rand.Int63())
}
var buf bytes.Buffer
// ── Top-level headers ──────────────────────────────────────────────────────
// Order follows common convention; Lark API does not require a specific order.
writeHeader(&buf, "Subject", encodeHeaderValue(b.subject))
writeHeader(&buf, "From", b.from.String())
writeHeader(&buf, "MIME-Version", "1.0")
writeHeader(&buf, "Date", date.Format(time.RFC1123Z))
writeHeader(&buf, "Message-ID", "<"+msgID+">")
if len(b.to) > 0 {
writeHeader(&buf, "To", joinAddresses(b.to))
}
if len(b.cc) > 0 {
writeHeader(&buf, "Cc", joinAddresses(b.cc))
}
if len(b.bcc) > 0 {
writeHeader(&buf, "Bcc", joinAddresses(b.bcc))
}
if len(b.replyTo) > 0 {
writeHeader(&buf, "Reply-To", joinAddresses(b.replyTo))
}
if len(b.dispositionNotificationTo) > 0 {
writeHeader(&buf, "Disposition-Notification-To", joinAddresses(b.dispositionNotificationTo))
}
if b.isReadReceiptMail {
writeHeader(&buf, "X-Lark-Read-Receipt-Mail", "1")
}
if b.inReplyTo != "" {
writeHeader(&buf, "In-Reply-To", "<"+b.inReplyTo+">")
if b.lmsReplyToMessageID != "" {
writeHeader(&buf, "X-LMS-Reply-To-Message-Id", b.lmsReplyToMessageID)
}
}
if b.references != "" {
writeHeader(&buf, "References", b.references)
}
for _, kv := range b.extraHeaders {
writeHeader(&buf, kv[0], kv[1])
}
// ── Body ───────────────────────────────────────────────────────────────────
// Full MIME hierarchy (outer layers only present when needed):
// multipart/mixed → multipart/related → multipart/alternative → body parts
//
// text/calendar lives inside multipart/alternative as an alternative
// representation of the message body, matching the Feishu client behavior.
if len(b.attachments) > 0 {
outerB := newBoundary()
writeHeader(&buf, "Content-Type", "multipart/mixed; boundary="+outerB)
buf.WriteByte('\n')
fmt.Fprintf(&buf, "--%s\n", outerB)
writePrimaryBody(&buf, b)
for _, att := range b.attachments {
fmt.Fprintf(&buf, "--%s\n", outerB)
writeAttachmentPart(&buf, att)
}
fmt.Fprintf(&buf, "--%s--\n", outerB)
} else {
writePrimaryBody(&buf, b)
}
raw := buf.Bytes()
if len(raw) > MaxEMLSize {
return nil, fmt.Errorf("emlbuilder: EML size %.1f MB exceeds the %.0f MB limit", //nolint:forbidigo // intermediate EML builder error; mail command layer wraps into typed ValidationError.
float64(len(raw))/1024/1024, float64(MaxEMLSize)/1024/1024)
}
return raw, nil
}
// BuildBase64URL returns the EML encoded as base64url (RFC 4648).
// This is the value to place in the Lark API "raw" field.
func (b Builder) BuildBase64URL() (string, error) {
raw, err := b.Build()
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(raw), nil
}
// ── internal helpers ──────────────────────────────────────────────────────────
// copySlices returns a shallow copy of b with independent slice headers,
// so append operations in setter methods do not mutate the original.
func (b Builder) copySlices() Builder {
cp := b
cp.to = append([]mail.Address{}, b.to...)
cp.cc = append([]mail.Address{}, b.cc...)
cp.bcc = append([]mail.Address{}, b.bcc...)
cp.replyTo = append([]mail.Address{}, b.replyTo...)
cp.dispositionNotificationTo = append([]mail.Address{}, b.dispositionNotificationTo...)
cp.attachments = append([]attachment{}, b.attachments...)
cp.inlines = append([]inline{}, b.inlines...)
cp.extraHeaders = append([][2]string{}, b.extraHeaders...)
return cp
}
// writePrimaryBody writes the body block of the message (text + inline parts,
// but not attachments). If inline/other parts are present, the body is wrapped
// in multipart/related.
//
// This function writes starting from a Content-Type header, which is either a
// top-level message header (when no attachments) or a sub-part header (inside
// multipart/mixed after a boundary line).
func writePrimaryBody(buf *bytes.Buffer, b Builder) {
if len(b.inlines) > 0 {
relatedB := newBoundary()
writeHeader(buf, "Content-Type", "multipart/related; boundary="+relatedB)
buf.WriteByte('\n')
fmt.Fprintf(buf, "--%s\n", relatedB)
writeAlternativeOrSingleBody(buf, b)
for _, il := range b.inlines {
fmt.Fprintf(buf, "--%s\n", relatedB)
writeInlinePart(buf, il)
}
fmt.Fprintf(buf, "--%s--\n", relatedB)
} else {
writeAlternativeOrSingleBody(buf, b)
}
}
// writeAlternativeOrSingleBody writes the body block. When multiple content
// types coexist (text/plain, text/html, text/calendar), they are wrapped in
// multipart/alternative. text/calendar lives inside alternative as an
// alternative representation, matching the Feishu client behavior.
func writeAlternativeOrSingleBody(buf *bytes.Buffer, b Builder) {
hasText := len(b.textBody) > 0
hasHTML := len(b.htmlBody) > 0
hasCal := len(b.calendarBody) > 0
partCount := 0
if hasText {
partCount++
}
if hasHTML {
partCount++
}
if hasCal {
partCount++
}
if partCount > 1 {
boundary := newBoundary()
writeHeader(buf, "Content-Type", "multipart/alternative; boundary="+boundary)
buf.WriteByte('\n')
if hasText {
writeBodyPart(buf, boundary, "text/plain", b.textBody)
}
if hasHTML {
writeBodyPart(buf, boundary, "text/html", b.htmlBody)
}
if hasCal {
fmt.Fprintf(buf, "--%s\n", boundary)
writeCalendarPart(buf, b.calendarBody)
}
fmt.Fprintf(buf, "--%s--\n", boundary)
} else if hasHTML {
writeSingleBodyPartHeaders(buf, "text/html", b.htmlBody)
} else if hasCal {
writeCalendarPart(buf, b.calendarBody)
} else {
writeSingleBodyPartHeaders(buf, "text/plain", b.textBody)
}
}
// writeInlinePart writes a CID-referenced inline or other-part MIME part.
// The part body is always base64 (StdEncoding), written in 76-character lines.
func writeInlinePart(buf *bytes.Buffer, il inline) {
rawCID := strings.TrimSpace(strings.TrimPrefix(strings.TrimSuffix(il.contentID, ">"), "<"))
cid := rawCID
if rawCID != "" {
cid = "<" + rawCID + ">"
}
encodedName := encodeHeaderValue(il.fileName)
fmt.Fprintf(buf, "Content-Type: %s; name=%q\n", il.contentType, encodedName)
writeHeader(buf, "Content-Id", cid)
writeHeader(buf, "Content-Transfer-Encoding", "base64")
if !il.isOtherPart {
fmt.Fprintf(buf, "Content-Disposition: inline; filename=%q\n", encodedName)
if rawCID != "" {
writeHeader(buf, "X-Attachment-Id", rawCID)
writeHeader(buf, "X-Image-Id", rawCID)
}
}
buf.WriteByte('\n')
encoded := base64.StdEncoding.EncodeToString(il.content)
for len(encoded) > 76 {
buf.WriteString(encoded[:76])
buf.WriteByte('\n')
encoded = encoded[76:]
}
if len(encoded) > 0 {
buf.WriteString(encoded)
buf.WriteByte('\n')
}
buf.WriteByte('\n')
}
// writeHeader writes "Name: value\n".
// NOTE: no folding — Lark's MIME parser does not handle folded headers.
// CR and LF are stripped as a last-resort defence against header injection;
// callers (validateHeaderValue, validateCID) already reject them explicitly.
func writeHeader(buf *bytes.Buffer, name, value string) {
name = strings.NewReplacer("\r", "", "\n", "").Replace(name)
value = strings.NewReplacer("\r", "", "\n", "").Replace(value)
fmt.Fprintf(buf, "%s: %s\n", name, value)
}
// encodeHeaderValue RFC 2047 B-encodes s if it contains non-ASCII characters.
func encodeHeaderValue(s string) string {
for _, r := range s {
if r > 127 {
return mime.BEncoding.Encode("utf-8", s)
}
}
return s
}
// hasNonASCII returns true if b contains any byte > 127.
func hasNonASCII(b []byte) bool {
for _, c := range b {
if c > 127 {
return true
}
}
return false
}
// selectCTE chooses the Content-Transfer-Encoding for a body:
// - "7bit" — pure ASCII content
// - "base64" — contains non-ASCII bytes (required by Lark API)
func selectCTE(body []byte) string {
if hasNonASCII(body) {
return "base64"
}
return "7bit"
}
// encodeBodyContent encodes body according to the chosen CTE.
// For base64, it uses StdEncoding (MIME internal standard).
func encodeBodyContent(body []byte, cte string) string {
if cte == "base64" {
return base64.StdEncoding.EncodeToString(body)
}
return string(body)
}
// lineWidthForCTE returns the appropriate line width for the given CTE.
// RFC 2045: base64 and quoted-printable lines MUST NOT exceed 76 characters.
// RFC 5322: 7bit/8bit lines MUST NOT exceed 998 characters.
func lineWidthForCTE(cte string) int {
switch cte {
case "base64", "quoted-printable":
return 76
default: // 7bit, 8bit
return 998
}
}
// writeFoldedBody writes the encoded part body with line wrapping.
// The width limit depends on the Content-Transfer-Encoding:
// base64/quoted-printable use 76 chars (RFC 2045), 7bit uses 998 (RFC 5322).
func writeFoldedBody(buf *bytes.Buffer, encoded string, width int) {
if width <= 0 {
width = 998
}
for _, line := range strings.Split(encoded, "\n") {
for len(line) > width {
buf.WriteString(line[:width])
buf.WriteByte('\n')
line = line[width:]
}
buf.WriteString(line)
buf.WriteByte('\n')
}
}
// writeBodyPart writes a MIME part within a multipart boundary:
//
// --<boundary>
// Content-Type: <ct>; charset=UTF-8
// Content-Transfer-Encoding: <cte>
// <blank line>
// <body>
// <blank line>
func writeBodyPart(buf *bytes.Buffer, boundary, ct string, body []byte) {
fmt.Fprintf(buf, "--%s\n", boundary)
cte := selectCTE(body)
fmt.Fprintf(buf, "Content-Type: %s; charset=UTF-8\n", ct)
fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte)
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
}
// writeSingleBodyPartHeaders writes the Content-Type / CTE headers and body
// for a single-part (non-multipart) message.
// The blank line separating headers from body is included.
func writeSingleBodyPartHeaders(buf *bytes.Buffer, ct string, body []byte) {
cte := selectCTE(body)
fmt.Fprintf(buf, "Content-Type: %s; charset=UTF-8\n", ct)
fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte)
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
}
// writeCalendarPart writes the text/calendar MIME part. The method= parameter
// is derived from the METHOD property in the ICS body (defaulting to REQUEST
// when absent) so that passthrough ICS with METHOD:CANCEL or METHOD:REPLY
// produce a Content-Type that matches the body.
func writeCalendarPart(buf *bytes.Buffer, body []byte) {
method := extractICSMethod(body)
if method == "" {
method = "REQUEST"
}
cte := selectCTE(body)
fmt.Fprintf(buf, "Content-Type: text/calendar; method=%s; charset=UTF-8\n", method)
fmt.Fprintf(buf, "Content-Transfer-Encoding: %s\n\n", cte)
writeFoldedBody(buf, encodeBodyContent(body, cte), lineWidthForCTE(cte))
buf.WriteByte('\n')
}
// extractICSMethod scans the ICS body for the top-level METHOD property and
// returns its value (e.g. "REQUEST", "CANCEL", "REPLY"). Returns "" when the
// property is absent so callers can apply their own default.
func extractICSMethod(body []byte) string {
for _, line := range strings.Split(string(body), "\n") {
line = strings.TrimRight(line, "\r")
if strings.HasPrefix(strings.ToUpper(line), "METHOD:") {
return strings.TrimSpace(line[7:])
}
}
return ""
}
// writeAttachmentPart writes a MIME attachment part.
// Body is always base64 (StdEncoding), written in 76-character lines per RFC 2045.
func writeAttachmentPart(buf *bytes.Buffer, att attachment) {
encodedName := encodeHeaderValue(att.fileName)
fmt.Fprintf(buf, "Content-Type: %s; name=%q\n", att.contentType, encodedName)
fmt.Fprintf(buf, "Content-Disposition: attachment; filename=%q\n", encodedName)
fmt.Fprintf(buf, "Content-Transfer-Encoding: base64\n\n")
encoded := base64.StdEncoding.EncodeToString(att.content)
for len(encoded) > 76 {
buf.WriteString(encoded[:76])
buf.WriteByte('\n')
encoded = encoded[76:]
}
if len(encoded) > 0 {
buf.WriteString(encoded)
buf.WriteByte('\n')
}
buf.WriteByte('\n')
}
// newBoundary generates a random MIME boundary string.
func newBoundary() string {
return fmt.Sprintf("lark-%016x", rand.Int63())
}
// joinAddresses formats a list of mail.Address as a comma-separated string.
func joinAddresses(addrs []mail.Address) string {
parts := make([]string, len(addrs))
for i, a := range addrs {
parts[i] = a.String()
}
return strings.Join(parts, ", ")
}