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
1066 行
34 KiB
Go
1066 行
34 KiB
Go
// 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 (0x00–0x1F, 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, ", ")
|
|
}
|