Files
larksuite-cli/shortcuts/mail/draft/parse_test.go
梁硕 83dfb068ad feat: open-source lark-cli — the official CLI for Lark/Feishu
Change-Id: I113d9cdb5403cec347efe4595415e34a18b7decf
2026-03-28 10:36:25 +08:00

508 lines
17 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package draft
import (
"encoding/base64"
"testing"
)
func TestParseReplyDraftPrimaryBodies(t *testing.T) {
raw := DraftRaw{
DraftID: "d-1",
RawEML: encodeFixtureEML(`Subject: =?UTF-8?B?VGVzdA==?=
From: Alice <alice@example.com>
To: Bob <bob@example.com>
Reply-To: Team <reply@example.com>
Message-ID: <draft-1@example.com>
In-Reply-To: <orig-1@example.com>
References: <root@example.com> <orig-1@example.com>
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary=alt
--alt
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
hello
--alt
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
<p>hello</p>
--alt--
`),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.Subject != "Test" {
t.Fatalf("Subject = %q, want Test", snapshot.Subject)
}
if snapshot.InReplyTo != "<orig-1@example.com>" {
t.Fatalf("InReplyTo = %q", snapshot.InReplyTo)
}
if snapshot.References != "<root@example.com> <orig-1@example.com>" {
t.Fatalf("References = %q", snapshot.References)
}
if snapshot.PrimaryTextPartID != "1.1" {
t.Fatalf("PrimaryTextPartID = %q", snapshot.PrimaryTextPartID)
}
if snapshot.PrimaryHTMLPartID != "1.2" {
t.Fatalf("PrimaryHTMLPartID = %q", snapshot.PrimaryHTMLPartID)
}
}
func TestParsePreservesRepeatedHeadersAndNestedPartIDs(t *testing.T) {
raw := DraftRaw{
DraftID: "d-2",
RawEML: encodeFixtureEML(`Subject: Nested
From: Alice <alice@example.com>
To: Bob <bob@example.com>
X-Custom: one
X-Custom: two
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary=mix
--mix
Content-Type: multipart/alternative; boundary=alt
--alt
Content-Type: text/plain; charset=UTF-8
hello
--alt
Content-Type: text/html; charset=UTF-8
<p>hello</p>
--alt--
--mix
Content-Type: application/octet-stream; name=file.bin
Content-Disposition: attachment; filename=file.bin
Content-Transfer-Encoding: base64
YQ==
--mix--
`),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if got := headerValue(snapshot.Headers, "X-Custom"); got != "one" {
t.Fatalf("first X-Custom = %q", got)
}
if len(snapshot.Headers) < 5 || snapshot.Headers[4].Value != "two" {
t.Fatalf("expected repeated header to be preserved in order: %#v", snapshot.Headers)
}
if snapshot.PrimaryTextPartID != "1.1.1" {
t.Fatalf("PrimaryTextPartID = %q", snapshot.PrimaryTextPartID)
}
if snapshot.PrimaryHTMLPartID != "1.1.2" {
t.Fatalf("PrimaryHTMLPartID = %q", snapshot.PrimaryHTMLPartID)
}
if part := findPart(snapshot.Body, "1.2"); part == nil || part.FileName() != "file.bin" {
t.Fatalf("attachment part mismatch: %#v", part)
}
}
func TestParseDecodesQuotedPrintableTextBodyToUTF8(t *testing.T) {
raw := DraftRaw{
DraftID: "d-qp",
RawEML: encodeFixtureEML(`Subject: Encoded body
From: Alice <alice@example.com>
To: Bob <bob@example.com>
MIME-Version: 1.0
Content-Type: text/plain; charset=ISO-8859-1
Content-Transfer-Encoding: quoted-printable
caf=E9
`),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
part := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
if part == nil {
t.Fatalf("primary text part missing")
}
if got := string(part.Body); got != "café\n" {
t.Fatalf("decoded text body = %q", got)
}
}
func TestParseSignedDraftDoesNotExposeEditablePrimaryBody(t *testing.T) {
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/multipart_signed_draft.eml"))
if snapshot.PrimaryTextPartID != "" {
t.Fatalf("PrimaryTextPartID = %q, want empty for multipart/signed", snapshot.PrimaryTextPartID)
}
if snapshot.PrimaryHTMLPartID != "" {
t.Fatalf("PrimaryHTMLPartID = %q, want empty for multipart/signed", snapshot.PrimaryHTMLPartID)
}
if part := findPart(snapshot.Body, "1.1"); part == nil || part.MediaType != "text/plain" {
t.Fatalf("signed text part mismatch: %#v", part)
}
}
func TestParseMultipartPreservesPreambleAndEpilogue(t *testing.T) {
snapshot := mustParseFixtureDraft(t, mustReadFixture(t, "testdata/dirty_multipart_preamble.eml"))
if got := string(snapshot.Body.Preamble); got != "This is a preamble line.\nStill preamble.\n" {
t.Fatalf("preamble = %q", got)
}
if got := string(snapshot.Body.Epilogue); got != "This is an epilogue line.\nTrailing dirty text.\n" {
t.Fatalf("epilogue = %q", got)
}
if got := headerValue(snapshot.Headers, "X-Trace"); got != "first second" {
t.Fatalf("folded header = %q", got)
}
}
func TestParseMultipartWithEmptyBodyPart(t *testing.T) {
// Regression: bytes.TrimSpace in parseMultipartChildren stripped the
// header/body separator when a MIME part had headers but an empty body,
// causing "invalid EML: missing header/body separator".
raw := DraftRaw{
DraftID: "d-empty-body",
RawEML: encodeFixtureEML("Subject: Empty body part\r\nFrom: alice@example.com\r\nMime-Version: 1.0\r\nContent-Type: multipart/alternative;\r\n boundary=bound1\r\n\r\n--bound1\r\nContent-Transfer-Encoding: 7bit\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n\r\n--bound1\r\nContent-Transfer-Encoding: 7bit\r\nContent-Type: text/html; charset=UTF-8\r\n\r\n<p>hello</p>\r\n--bound1--\r\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.Subject != "Empty body part" {
t.Fatalf("Subject = %q", snapshot.Subject)
}
if snapshot.PrimaryTextPartID == "" {
t.Fatalf("PrimaryTextPartID should not be empty")
}
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
if textPart == nil {
t.Fatalf("text part not found")
}
if len(textPart.Body) != 0 {
t.Fatalf("text part body should be empty, got %q", string(textPart.Body))
}
}
func TestParseMultipartChildBodyNotCorruptedByLaterParts(t *testing.T) {
// Regression: flush() in parseMultipartChildren returned Body slices
// aliased to the shared bytes.Buffer backing array. When the next MIME
// part was written to the buffer (after Reset), earlier children's Body
// data was silently overwritten.
raw := DraftRaw{
DraftID: "d-alias",
RawEML: encodeFixtureEML(`Subject: Alias test
From: alice@example.com
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary=alt
--alt
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 7bit
hello plain
--alt
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
<p>hello html</p>
--alt--
`),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
if textPart == nil {
t.Fatalf("primary text part missing")
}
if got := string(textPart.Body); got != "hello plain" {
t.Fatalf("text body corrupted: got %q, want %q", got, "hello plain")
}
htmlPart := findPart(snapshot.Body, snapshot.PrimaryHTMLPartID)
if htmlPart == nil {
t.Fatalf("primary html part missing")
}
if got := string(htmlPart.Body); got != "<p>hello html</p>" {
t.Fatalf("html body = %q", got)
}
}
func TestParseMultipartChildWithNoHeaders(t *testing.T) {
// A MIME part with no headers (starts with blank line then body) is valid
// per RFC 2046 and defaults to text/plain.
raw := DraftRaw{
DraftID: "d-noheader",
RawEML: encodeFixtureEML("Subject: No header part\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=mix\n\n--mix\n\njust body text\n--mix--\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.Body == nil || len(snapshot.Body.Children) == 0 {
t.Fatalf("expected at least one child part")
}
child := snapshot.Body.Children[0]
if child.MediaType != "text/plain" {
t.Fatalf("MediaType = %q, want text/plain", child.MediaType)
}
if got := string(child.Body); got != "just body text" {
t.Fatalf("body = %q, want %q", got, "just body text")
}
}
func TestParseMultipartHeaderlessPartWithDoubleNewlineInBody(t *testing.T) {
// A header-less part whose body contains \n\n should not confuse the
// parser into treating body text as headers.
raw := DraftRaw{
DraftID: "d-noheader-dblnl",
RawEML: encodeFixtureEML("Subject: HeaderlessDoubleNL\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=mix\n\n--mix\n\nfirst paragraph\n\nsecond paragraph\n--mix--\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.Body == nil || len(snapshot.Body.Children) == 0 {
t.Fatalf("expected at least one child part")
}
child := snapshot.Body.Children[0]
if got := string(child.Body); got != "first paragraph\n\nsecond paragraph" {
t.Fatalf("body = %q, want %q", got, "first paragraph\n\nsecond paragraph")
}
}
func TestParseMalformedContentType(t *testing.T) {
// A part with an unparseable Content-Type should fallback to
// application/octet-stream instead of crashing the entire parse.
raw := DraftRaw{
DraftID: "d-badct",
RawEML: encodeFixtureEML("Subject: Bad CT\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=mix\n\n--mix\nContent-Type: text/plain; charset=UTF-8\n\nhello\n--mix\nContent-Type: totally broken content type !!!\n\nsome data\n--mix--\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
badPart := findPart(snapshot.Body, "1.2")
if badPart == nil {
t.Fatalf("expected part 1.2 to exist")
}
if badPart.MediaType != "application/octet-stream" {
t.Fatalf("MediaType = %q, want application/octet-stream", badPart.MediaType)
}
if !badPart.EncodingProblem {
t.Fatalf("EncodingProblem should be true for malformed Content-Type")
}
}
func TestParseMalformedContentDisposition(t *testing.T) {
// A part with an unparseable Content-Disposition should still parse
// successfully, just without structured disposition info.
raw := DraftRaw{
DraftID: "d-baddisp",
RawEML: encodeFixtureEML("Subject: Bad Disp\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=mix\n\n--mix\nContent-Type: text/plain; charset=UTF-8\n\nhello\n--mix\nContent-Type: application/pdf\nContent-Disposition: attachment; filename=my file.txt\nContent-Transfer-Encoding: base64\n\nYQ==\n--mix--\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
part := findPart(snapshot.Body, "1.2")
if part == nil {
t.Fatalf("expected part 1.2 to exist")
}
// Disposition is empty because it failed to parse, but the part is accessible.
if part.ContentDisposition != "" {
t.Fatalf("ContentDisposition = %q, want empty (parse failed)", part.ContentDisposition)
}
}
func TestParseMalformedBase64Attachment(t *testing.T) {
// A part with corrupted base64 should not crash the entire parse.
// The raw bytes are kept so the part can still round-trip via RawEntity.
raw := DraftRaw{
DraftID: "d-badbase64",
RawEML: encodeFixtureEML("Subject: Bad base64\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=mix\n\n--mix\nContent-Type: text/plain; charset=UTF-8\n\nhello\n--mix\nContent-Type: application/pdf; name=report.pdf\nContent-Disposition: attachment; filename=report.pdf\nContent-Transfer-Encoding: base64\n\n!!!not-valid-base64!!!\n--mix--\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
textPart := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
if textPart == nil || string(textPart.Body) != "hello" {
t.Fatalf("text body = %q", string(textPart.Body))
}
// The broken attachment part still exists and is flagged.
attachment := findPart(snapshot.Body, "1.2")
if attachment == nil {
t.Fatalf("expected broken attachment part to exist")
}
if !attachment.EncodingProblem {
t.Fatalf("EncodingProblem should be true for corrupted base64")
}
}
func TestParseMalformedBase64NoPadding(t *testing.T) {
// base64 without padding (e.g. "YQ" instead of "YQ==") should decode via
// RawStdEncoding fallback.
raw := DraftRaw{
DraftID: "d-nopad",
RawEML: encodeFixtureEML("Subject: No padding\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: application/octet-stream\nContent-Transfer-Encoding: base64\n\nYQ\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if got := string(snapshot.Body.Body); got != "a" {
t.Fatalf("body = %q, want %q", got, "a")
}
}
func TestParseMalformedAddressHeader(t *testing.T) {
// Malformed address headers should not prevent parsing the draft.
raw := DraftRaw{
DraftID: "d-badaddr",
RawEML: encodeFixtureEML("Subject: Bad addr\nFrom: not a valid address @@\nTo: also broken <<>>\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\n\nhello\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.Subject != "Bad addr" {
t.Fatalf("Subject = %q", snapshot.Subject)
}
// Addresses are nil because they couldn't be parsed, but the raw
// header values are preserved.
if snapshot.From != nil {
t.Fatalf("From = %v, want nil", snapshot.From)
}
if got := headerValue(snapshot.Headers, "From"); got != "not a valid address @@" {
t.Fatalf("raw From header = %q", got)
}
}
func TestParsePrimaryBodyPartTie(t *testing.T) {
// Two text/plain parts with the same score should not crash.
// The first match is returned.
raw := DraftRaw{
DraftID: "d-tie",
RawEML: encodeFixtureEML("Subject: Tie\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=mix\n\n--mix\nContent-Type: text/plain; charset=UTF-8\n\nfirst\n--mix\nContent-Type: text/plain; charset=UTF-8\n\nsecond\n--mix--\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.PrimaryTextPartID == "" {
t.Fatalf("PrimaryTextPartID should not be empty")
}
part := findPart(snapshot.Body, snapshot.PrimaryTextPartID)
if part == nil {
t.Fatalf("primary text part not found")
}
if got := string(part.Body); got != "first" {
t.Fatalf("body = %q, want first match", got)
}
}
func TestParseBrokenInlineCIDNotError(t *testing.T) {
// A draft whose HTML references a CID that doesn't exist should still
// parse successfully. The broken reference is surfaced as a warning in
// Project(), not as a parse error.
raw := DraftRaw{
DraftID: "d-brokencid",
RawEML: encodeFixtureEML(`Subject: Broken CID
From: alice@example.com
MIME-Version: 1.0
Content-Type: multipart/related; boundary=rel
--rel
Content-Type: text/html; charset=UTF-8
<p>hello <img src="cid:nonexistent"></p>
--rel--
`),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.PrimaryHTMLPartID == "" {
t.Fatalf("PrimaryHTMLPartID should not be empty")
}
}
func TestParseMalformedQuotedPrintableDoesNotCrash(t *testing.T) {
// Go's quotedprintable.Reader is lenient (e.g. =ZZ passes through).
// Verify that even with garbage QP content, parsing always succeeds and
// the body is non-empty.
raw := DraftRaw{
DraftID: "d-badqp",
RawEML: encodeFixtureEML("Subject: Bad QP\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\nContent-Transfer-Encoding: quoted-printable\n\nhello =ZZ world\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.Body == nil {
t.Fatalf("body part missing")
}
if len(snapshot.Body.Body) == 0 {
t.Fatalf("body should not be empty")
}
}
func TestParseBoundaryNotFound(t *testing.T) {
// A multipart that declares a boundary but the body doesn't contain it
// should be reclassified as text rather than producing an empty multipart.
raw := DraftRaw{
DraftID: "d-noboundary",
RawEML: encodeFixtureEML("Subject: No boundary\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: multipart/mixed; boundary=nonexistent\n\nThis body has no boundary markers at all.\nJust plain text.\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.Body == nil {
t.Fatalf("body part missing")
}
// Should be reclassified from multipart/mixed to text/plain.
if snapshot.Body.MediaType != "text/plain" {
t.Fatalf("MediaType = %q, want text/plain (reclassified)", snapshot.Body.MediaType)
}
if !snapshot.Body.EncodingProblem {
t.Fatalf("EncodingProblem should be true for reclassified multipart")
}
if len(snapshot.Body.Children) != 0 {
t.Fatalf("Children should be empty after reclassification")
}
if len(snapshot.Body.Body) == 0 {
t.Fatalf("body should contain the text content")
}
}
func TestParseHeaderLineWithoutColon(t *testing.T) {
// Header lines without a colon should be silently skipped.
raw := DraftRaw{
DraftID: "d-nocolon",
RawEML: encodeFixtureEML("Subject: With garbage\nThis line has no colon\nFrom: alice@example.com\nMIME-Version: 1.0\nContent-Type: text/plain; charset=UTF-8\n\nhello\n"),
}
snapshot, err := Parse(raw)
if err != nil {
t.Fatalf("Parse() error = %v", err)
}
if snapshot.Subject != "With garbage" {
t.Fatalf("Subject = %q", snapshot.Subject)
}
if got := string(snapshot.Body.Body); got != "hello\n" {
t.Fatalf("body = %q", got)
}
}
func encodeFixtureEML(raw string) string {
return base64.URLEncoding.EncodeToString([]byte(raw))
}