// 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("

Hi Bob

")). // 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. " ". // 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: // // -- // Content-Type: ; charset=UTF-8 // Content-Transfer-Encoding: // // // 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, ", ") }