Files
larksuite-cli/shortcuts/mail/emlbuilder/builder.go
chanthuang dce2beb91c feat(mail): support calendar events in emails (#646)
* feat(ics): add RFC 5545 iCalendar generator and parser

Add shortcuts/mail/ics package:
- builder.go: generates METHOD:REQUEST ICS with VEVENT, ORGANIZER,
  ATTENDEE, DTSTART/DTEND with timezone, UID, and X-LARK-MAIL-DRAFT
- parser.go: parses ICS into ParsedEvent struct, detects IsLarkDraft
- Handles CN quoting, control-char sanitization, email validation,
  line folding per RFC 5545, and TZID edge cases

Change-Id: I01d13285a57a5a4de50891c54d655efa8423c3c1

* feat(mail): support calendar events in emails

- Add --event-summary/start/end/location flags to +send, +reply,
  +reply-all, +forward, +draft-create
- Build ICS and embed as text/calendar in multipart/alternative
- Validate event time range and enforce --event/--send-time mutual
  exclusion (extracted into validateEventSendTimeExclusion)
- CalendarBody() in emlbuilder places ICS correctly
- Exclude BCC from ATTENDEE list

Change-Id: Icf9e49ababebc4e8fcf36760ab613c64938c2744

* feat(mail): X-LARK-MAIL-DRAFT and read-only calendar guard

- ics.Build() writes X-LARK-MAIL-DRAFT:TRUE so Feishu client
  recognizes CLI-created calendar events as editable
- ics.ParseEvent() detects IsLarkDraft field
- +draft-edit rejects --set-event-* on calendars without
  X-LARK-MAIL-DRAFT marker (read-only after send)
- Export FindPartByMediaType from draft package for cross-package use
- Add set_calendar/remove_calendar patch ops with full test coverage

Change-Id: I7d547a4b40880e8d4ee3fecf68864d6ea89e66cd

* feat(mail): forward preserves original calendar ICS

When forwarding an email that contains a calendar event (body_calendar),
pass through the original ICS bytes as text/calendar part if no new
--event-* flags are specified.

Change-Id: I67d2e82604eaf969cee8c7e0bedcf32198d12d57

* docs(mail): document calendar invitation feature

- Add --event-* params to +send, +reply, +reply-all, +forward,
  +draft-create, +draft-edit reference docs
- Add calendar_event output section to +message reference
- Add calendar invitation workflow to skill-template/domains/mail.md
- Regenerate SKILL.md via gen-skills

Change-Id: Iccacd06990d91e1cf3beb896d5b772d27e5e29ff

* fix(mail): reject --set-event-start/end/location without --set-event-summary

Change-Id: Icb651ff28ede526ff96b22e7b304b7bdea86d01f
Co-Authored-By: AI

* fix(mail): include --event-location in validateEventFlags; fix stale comment

Change-Id: I2f47016b6bfa11957dfe2c8c499cf36737efba53
Co-Authored-By: AI

* fix(mail): clear stale headers when wrapping single-leaf body in multipart/alternative

Change-Id: I29fe883c9151570f7939d372523b128cbea0b1ed
Co-Authored-By: AI

* fix(mail): add method=REQUEST to text/calendar MIME part created by set_calendar

Change-Id: I4d23674e20e4c42adab36385ff5ee8bb6d97625d
Co-Authored-By: AI

* fix(mail): use post-edit recipients for ICS attendees when --set-to combined with --set-event-*

Change-Id: I659e06635dd043f798d2f2e90d7dbca6e13d7f3d
Co-Authored-By: AI

* fix(mail): cover add_recipient/remove_recipient in ICS attendee resolution

Extract effectiveRecipients() to replay all three recipient op types
(set_recipients, add_recipient, remove_recipient) before building the
ICS for set_calendar, so patch-file recipient changes are reflected in
ATTENDEE metadata.

Change-Id: I3a7a55f96df8fac7d924a4dbeedd5b3d0d9d443c
Co-Authored-By: AI

* fix(mail): derive method= from ICS body in writeCalendarPart instead of hardcoding REQUEST

Passthrough ICS (e.g. forwarded METHOD:CANCEL) previously emitted a
Content-Type with method=REQUEST, disagreeing with the body. Now
extractICSMethod() scans the ICS for METHOD: and falls back to REQUEST
when absent, keeping existing behavior for our own generated ICS.

Change-Id: I4bf6c3755a189a436c2d26b082372d9f838f4051
Co-Authored-By: AI

* fix(mail): normalize calendar_event start/end to UTC in output

Callers expect RFC 3339 UTC strings; source ICS with TZID offsets
previously emitted +08:00 instead of Z.

Change-Id: I88bd4b925f8fc3b4f569e41712ae58ab50d94a2f
Co-Authored-By: AI

* fix(mail): make ICS parser case-insensitive and handle parameterized property names

RFC 5545 §3.1 allows any case and optional parameters on all property
names. Unify UID/SUMMARY/LOCATION/DTSTART/etc. to compare via
strings.ToUpper(name) and add HasPrefix checks for the NAME; form,
consistent with how ORGANIZER and ATTENDEE were already handled.

Change-Id: I7dc642dd210a3256f2189a901a2d9518ea284815
Co-Authored-By: AI
2026-04-29 15:31:38 +08:00

1062 lines
33 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/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) {
f, err := fio.Open(path)
if err != nil {
return nil, fmt.Errorf("attachment %q: %w", path, err)
}
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)
}
if isHeaderDangerousUnicode(r) {
return fmt.Errorf("emlbuilder: header value contains dangerous Unicode character: %q", v)
}
}
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)
}
for _, r := range n {
if r < 0x21 || r > 0x7e {
return fmt.Errorf("emlbuilder: header name contains non-printable character: %q", n)
}
}
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)
}
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)
}
}
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")
}
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")
}
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",
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, ", ")
}