mirror of
https://github.com/larksuite/cli.git
synced 2026-07-05 15:47:54 +08:00
* 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
1062 lines
33 KiB
Go
1062 lines
33 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/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 (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)
|
||
}
|
||
}
|
||
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, ", ")
|
||
}
|