Files
larksuite-cli/shortcuts/mail/helpers_test.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

1674 lines
58 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/vfs/localfileio"
"github.com/larksuite/cli/shortcuts/common"
"github.com/larksuite/cli/shortcuts/mail/emlbuilder"
)
// TestDecodeBodyFields verifies decode body fields.
func TestDecodeBodyFields(t *testing.T) {
htmlEncoded := base64.URLEncoding.EncodeToString([]byte("<p>Hello</p>"))
plainEncoded := base64.RawURLEncoding.EncodeToString([]byte("Hello plain"))
src := map[string]interface{}{
"body_html": htmlEncoded,
"body_plain_text": plainEncoded,
"subject": "untouched",
}
dst := map[string]interface{}{}
decodeBodyFields(src, dst)
if dst["body_html"] != "<p>Hello</p>" {
t.Fatalf("body_html not decoded: %#v", dst["body_html"])
}
if dst["body_plain_text"] != "Hello plain" {
t.Fatalf("body_plain_text not decoded: %#v", dst["body_plain_text"])
}
if _, ok := dst["subject"]; ok {
t.Fatalf("subject should not be copied by decodeBodyFields")
}
// src must not be modified
if src["body_html"] != htmlEncoded {
t.Fatalf("src was mutated")
}
}
// TestDecodeBodyFieldsSkipsAbsent verifies decode body fields skips absent.
func TestDecodeBodyFieldsSkipsAbsent(t *testing.T) {
src := map[string]interface{}{"subject": "no body"}
dst := map[string]interface{}{}
decodeBodyFields(src, dst)
if len(dst) != 0 {
t.Fatalf("expected empty dst, got %#v", dst)
}
}
// TestMessageFieldPolicy verifies message field policy.
func TestMessageFieldPolicy(t *testing.T) {
if !shouldExposeRawMessageField("custom_meta") {
t.Fatalf("custom metadata should be auto-passed through")
}
if shouldExposeRawMessageField("body_plain_text") {
t.Fatalf("body_* fields should not be auto-passed through")
}
if !shouldExposeRawMessageField("head_from") {
t.Fatalf("head_from should be auto-passed through")
}
if shouldExposeRawMessageField("attachments") {
t.Fatalf("attachments should be derived, not auto-passed through")
}
if len(derivedMessageFields) == 0 {
t.Fatalf("derivedMessageFields should document derived output fields")
}
}
// TestToForwardSourceAttachments verifies to forward source attachments.
func TestToForwardSourceAttachments(t *testing.T) {
out := normalizedMessageForCompose{
Attachments: []mailAttachmentOutput{
{
ID: "att1",
Filename: "report.pdf",
ContentType: "application/pdf",
DownloadURL: "https://example.com/att1",
},
},
}
atts := toForwardSourceAttachments(out)
if len(atts) != 1 {
t.Fatalf("expected 1 attachment, got %d", len(atts))
}
if atts[0].Filename != "report.pdf" {
t.Fatalf("unexpected filename: %s", atts[0].Filename)
}
if atts[0].DownloadURL == "" {
t.Fatalf("expected download_url to be set")
}
}
// ---------------------------------------------------------------------------
// parseInlineSpecs
// ---------------------------------------------------------------------------
// TestParseInlineSpecs_Empty verifies parse inline specs empty.
func TestParseInlineSpecs_Empty(t *testing.T) {
specs, err := parseInlineSpecs("")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(specs) != 0 {
t.Fatalf("expected empty slice, got %v", specs)
}
}
// TestParseInlineSpecs_Whitespace verifies parse inline specs whitespace.
func TestParseInlineSpecs_Whitespace(t *testing.T) {
specs, err := parseInlineSpecs(" ")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(specs) != 0 {
t.Fatalf("expected empty slice for whitespace input, got %v", specs)
}
}
// TestParseInlineSpecs_Valid verifies parse inline specs valid.
func TestParseInlineSpecs_Valid(t *testing.T) {
raw := `[{"cid":"YmFubmVyLnBuZw","file_path":"./banner.png"},{"cid":"bG9nby5wbmc","file_path":"/abs/logo.png"}]`
specs, err := parseInlineSpecs(raw)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(specs) != 2 {
t.Fatalf("expected 2 specs, got %d", len(specs))
}
if specs[0].CID != "YmFubmVyLnBuZw" {
t.Errorf("specs[0].CID = %q, want YmFubmVyLnBuZw", specs[0].CID)
}
if specs[0].FilePath != "./banner.png" {
t.Errorf("specs[0].FilePath = %q, want ./banner.png", specs[0].FilePath)
}
if specs[1].CID != "bG9nby5wbmc" {
t.Errorf("specs[1].CID = %q, want bG9nby5wbmc", specs[1].CID)
}
if specs[1].FilePath != "/abs/logo.png" {
t.Errorf("specs[1].FilePath = %q, want /abs/logo.png", specs[1].FilePath)
}
}
// TestParseInlineSpecs_InvalidJSON verifies parse inline specs invalid JSON.
func TestParseInlineSpecs_InvalidJSON(t *testing.T) {
_, err := parseInlineSpecs(`not-json`)
if err == nil {
t.Fatal("expected error for invalid JSON, got nil")
}
}
// TestParseInlineSpecs_MissingCID verifies parse inline specs missing CID.
func TestParseInlineSpecs_MissingCID(t *testing.T) {
_, err := parseInlineSpecs(`[{"cid":"","file_path":"./banner.png"}]`)
if err == nil {
t.Fatal("expected error for empty cid, got nil")
}
}
// TestParseInlineSpecs_MissingFilePath verifies parse inline specs missing file path.
func TestParseInlineSpecs_MissingFilePath(t *testing.T) {
_, err := parseInlineSpecs(`[{"cid":"YmFubmVyLnBuZw","file_path":""}]`)
if err == nil {
t.Fatal("expected error for empty file_path, got nil")
}
}
// TestParseInlineSpecs_OldKeyRejected verifies parse inline specs old key rejected.
func TestParseInlineSpecs_OldKeyRejected(t *testing.T) {
// "file-path" (kebab) must not be recognised — only "file_path" (snake) is valid.
// The JSON decoder will silently ignore unknown keys, so file_path stays empty → validation error.
_, err := parseInlineSpecs(`[{"cid":"YmFubmVyLnBuZw","file-path":"./banner.png"}]`)
if err == nil {
t.Fatal("expected error when using old kebab-case key \"file-path\", got nil")
}
}
// ---------------------------------------------------------------------------
// inlineSpecFilePaths
// ---------------------------------------------------------------------------
// TestInlineSpecFilePaths verifies inline spec file paths.
func TestInlineSpecFilePaths(t *testing.T) {
specs := []InlineSpec{
{CID: "cid1", FilePath: "./a.png"},
{CID: "cid2", FilePath: "/b.jpg"},
}
paths := inlineSpecFilePaths(specs)
if len(paths) != 2 {
t.Fatalf("expected 2 paths, got %d", len(paths))
}
if paths[0] != "./a.png" {
t.Errorf("paths[0] = %q, want ./a.png", paths[0])
}
if paths[1] != "/b.jpg" {
t.Errorf("paths[1] = %q, want /b.jpg", paths[1])
}
}
// TestInlineSpecFilePaths_Nil verifies inline spec file paths nil.
func TestInlineSpecFilePaths_Nil(t *testing.T) {
if paths := inlineSpecFilePaths(nil); paths != nil {
t.Fatalf("expected nil for nil input, got %v", paths)
}
}
// ---------------------------------------------------------------------------
// validateForwardAttachmentURLs / validateInlineImageURLs
// ---------------------------------------------------------------------------
// TestValidateForwardAttachmentURLs_MissingDownloadURL verifies validate forward attachment URLs missing download URL.
func TestValidateForwardAttachmentURLs_MissingDownloadURL(t *testing.T) {
src := composeSourceMessage{
ForwardAttachments: []forwardSourceAttachment{
{ID: "att1", Filename: "report.pdf", DownloadURL: "https://example.com/att1"},
{ID: "att2", Filename: "budget.xlsx", DownloadURL: ""}, // missing
},
}
err := validateForwardAttachmentURLs(src)
if err == nil {
t.Fatal("expected error when attachment download URL is missing, got nil")
}
if !strings.Contains(err.Error(), "budget.xlsx") {
t.Errorf("error should mention missing attachment filename, got: %v", err)
}
}
// TestValidateForwardAttachmentURLs_IgnoresInlineImages verifies validate forward attachment URLs ignores inline images.
func TestValidateForwardAttachmentURLs_IgnoresInlineImages(t *testing.T) {
src := composeSourceMessage{
ForwardAttachments: []forwardSourceAttachment{
{ID: "att1", Filename: "report.pdf", DownloadURL: "https://example.com/att1"},
},
InlineImages: []inlineSourcePart{
{ID: "img1", Filename: "logo.png", CID: "cid1", DownloadURL: ""}, // missing but should NOT cause error
},
}
if err := validateForwardAttachmentURLs(src); err != nil {
t.Fatalf("inline image missing URL should not affect forward attachment validation: %v", err)
}
}
// TestValidateForwardAttachmentURLs_AllPresent verifies validate forward attachment URLs all present.
func TestValidateForwardAttachmentURLs_AllPresent(t *testing.T) {
src := composeSourceMessage{
ForwardAttachments: []forwardSourceAttachment{
{ID: "att1", Filename: "report.pdf", DownloadURL: "https://example.com/att1"},
},
InlineImages: []inlineSourcePart{
{ID: "img1", Filename: "logo.png", CID: "cid1", DownloadURL: "https://example.com/img1"},
},
}
if err := validateForwardAttachmentURLs(src); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// TestValidateInlineImageURLs_MissingDownloadURL verifies validate inline image URLs missing download URL.
func TestValidateInlineImageURLs_MissingDownloadURL(t *testing.T) {
src := composeSourceMessage{
ForwardAttachments: []forwardSourceAttachment{
{ID: "att1", Filename: "report.pdf", DownloadURL: ""}, // missing but should NOT cause error
},
InlineImages: []inlineSourcePart{
{ID: "img1", Filename: "banner.png", CID: "cid1", DownloadURL: ""}, // missing
},
}
err := validateInlineImageURLs(src)
if err == nil {
t.Fatal("expected error when inline image download URL is missing, got nil")
}
if !strings.Contains(err.Error(), "banner.png") {
t.Errorf("error should mention missing inline image filename, got: %v", err)
}
}
// TestValidateInlineImageURLs_IgnoresAttachments verifies validate inline image URLs ignores attachments.
func TestValidateInlineImageURLs_IgnoresAttachments(t *testing.T) {
// Inline images are fine; attachments have missing URLs but should NOT be checked.
src := composeSourceMessage{
ForwardAttachments: []forwardSourceAttachment{
{ID: "att1", Filename: "report.pdf", DownloadURL: ""}, // missing — irrelevant for this check
},
InlineImages: []inlineSourcePart{
{ID: "img1", Filename: "logo.png", CID: "cid1", DownloadURL: "https://example.com/img1"},
},
}
if err := validateInlineImageURLs(src); err != nil {
t.Fatalf("unexpected error — attachment missing URL should not affect inline-only validation: %v", err)
}
}
// TestToForwardSourceAttachments_PreservesMissingURL verifies to forward source attachments preserves missing URL.
func TestToForwardSourceAttachments_PreservesMissingURL(t *testing.T) {
out := normalizedMessageForCompose{
Attachments: []mailAttachmentOutput{
{ID: "att1", Filename: "ok.pdf", DownloadURL: "https://example.com/ok"},
{ID: "att2", Filename: "broken.pdf", DownloadURL: ""},
},
}
atts := toForwardSourceAttachments(out)
if len(atts) != 2 {
t.Fatalf("expected 2 attachments (including missing URL), got %d", len(atts))
}
}
// TestToInlineSourceParts_PreservesMissingURL verifies to inline source parts preserves missing URL.
func TestToInlineSourceParts_PreservesMissingURL(t *testing.T) {
out := normalizedMessageForCompose{
Images: []mailImageOutput{
{ID: "img1", Filename: "ok.png", CID: "cid1", DownloadURL: "https://example.com/ok"},
{ID: "img2", Filename: "broken.png", CID: "cid2", DownloadURL: ""},
},
}
parts := toInlineSourceParts(out)
if len(parts) != 2 {
t.Fatalf("expected 2 inline parts (including missing URL), got %d", len(parts))
}
}
// --- downloadAttachmentContent security tests ---
// newDownloadRuntime builds a minimal RuntimeContext that uses the given
// *http.Client for outbound requests.
func newDownloadRuntime(t *testing.T, client *http.Client) *common.RuntimeContext {
t.Helper()
f := &cmdutil.Factory{
HttpClient: func() (*http.Client, error) { return client, nil },
}
rt := common.TestNewRuntimeContextWithCtx(context.Background(), &cobra.Command{}, nil)
rt.Factory = f
return rt
}
// TestDownloadAttachmentContent_RejectsHTTP verifies download attachment content rejects h t t p.
func TestDownloadAttachmentContent_RejectsHTTP(t *testing.T) {
rt := newDownloadRuntime(t, &http.Client{})
_, err := downloadAttachmentContent(rt, "http://evil.example.com/file")
if err == nil || !strings.Contains(err.Error(), "https") {
t.Errorf("expected https-required error, got: %v", err)
}
}
// TestDownloadAttachmentContent_RejectsFileScheme verifies download attachment content rejects file scheme.
func TestDownloadAttachmentContent_RejectsFileScheme(t *testing.T) {
rt := newDownloadRuntime(t, &http.Client{})
_, err := downloadAttachmentContent(rt, "file:///etc/passwd")
if err == nil || !strings.Contains(err.Error(), "https") {
t.Errorf("expected https-required error, got: %v", err)
}
}
// TestDownloadAttachmentContent_RejectsEmptyHost verifies download attachment content rejects empty host.
func TestDownloadAttachmentContent_RejectsEmptyHost(t *testing.T) {
rt := newDownloadRuntime(t, &http.Client{})
_, err := downloadAttachmentContent(rt, "https:///no-host")
if err == nil || !strings.Contains(err.Error(), "host") {
t.Errorf("expected no-host error, got: %v", err)
}
}
// TestDownloadAttachmentContent_NoAuthorizationHeader verifies download attachment content no authorization header.
func TestDownloadAttachmentContent_NoAuthorizationHeader(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "" {
http.Error(w, "unexpected Authorization header", http.StatusForbidden)
return
}
fmt.Fprint(w, "attachment data")
}))
defer srv.Close()
rt := newDownloadRuntime(t, srv.Client())
data, err := downloadAttachmentContent(rt, srv.URL+"/file?code=presigned")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if string(data) != "attachment data" {
t.Errorf("unexpected content: %q", data)
}
}
// ---------------------------------------------------------------------------
// newOutputRuntime — helper for tests that call runtime.Out / runtime.IO()
// ---------------------------------------------------------------------------
func newOutputRuntime(t *testing.T) (*common.RuntimeContext, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
f := &cmdutil.Factory{
IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr},
}
rt := common.TestNewRuntimeContext(&cobra.Command{}, nil)
rt.Factory = f
return rt, stdout, stderr
}
// ---------------------------------------------------------------------------
// printMessageOutputSchema
// ---------------------------------------------------------------------------
// TestPrintMessageOutputSchema verifies print message output schema.
func TestPrintMessageOutputSchema(t *testing.T) {
rt, stdout, _ := newOutputRuntime(t)
printMessageOutputSchema(rt)
out := stdout.String()
// Verify key fields from the schema are present
for _, key := range []string{
"body_plain_text", "body_html", "attachments", "head_from",
"bcc", "date", "smtp_message_id", "in_reply_to", "references",
"internal_date", "message_state", "message_state_text",
"folder_id", "label_ids", "priority_type", "priority_type_text",
"security_level", "draft_id", "reply_to", "reply_to_smtp_message_id",
"body_preview", "thread_id", "message_count",
"date_formatted",
} {
if !strings.Contains(out, key) {
t.Errorf("printMessageOutputSchema output missing key %q", key)
}
}
}
// ---------------------------------------------------------------------------
// printWatchOutputSchema
// ---------------------------------------------------------------------------
// TestPrintWatchOutputSchema verifies print watch output schema.
func TestPrintWatchOutputSchema(t *testing.T) {
rt, stdout, _ := newOutputRuntime(t)
printWatchOutputSchema(rt)
out := stdout.String()
for _, key := range []string{
"event", "minimal", "metadata", "plain_text_full", "full",
"event_id", "message_id",
"body_plain_text", "body_html",
"attachments",
} {
if !strings.Contains(out, key) {
t.Errorf("printWatchOutputSchema output missing key %q", key)
}
}
}
// ---------------------------------------------------------------------------
// hintMarkAsRead — sanitizeForTerminal integration
// ---------------------------------------------------------------------------
// TestHintMarkAsRead verifies hint mark as read.
func TestHintMarkAsRead(t *testing.T) {
rt, _, stderr := newOutputRuntime(t)
// Inject ANSI escape + message ID to verify sanitization
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
out := stderr.String()
if strings.Contains(out, "\x1b[") {
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
}
if !strings.Contains(out, "msg-123") {
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out)
}
}
// ---------------------------------------------------------------------------
// intVal — json.Number
// ---------------------------------------------------------------------------
// TestIntVal_JsonNumber verifies int val json number.
func TestIntVal_JsonNumber(t *testing.T) {
n := json.Number("42")
got := intVal(n)
if got != 42 {
t.Errorf("intVal(json.Number(\"42\")) = %d, want 42", got)
}
}
// TestIntVal_JsonNumberInvalid verifies int val json number invalid.
func TestIntVal_JsonNumberInvalid(t *testing.T) {
n := json.Number("not-a-number")
got := intVal(n)
if got != 0 {
t.Errorf("intVal(json.Number(\"not-a-number\")) = %d, want 0", got)
}
}
// ---------------------------------------------------------------------------
// toOriginalMessageForCompose
// ---------------------------------------------------------------------------
// TestToOriginalMessageForCompose verifies to original message for compose.
func TestToOriginalMessageForCompose(t *testing.T) {
out := normalizedMessageForCompose{
Subject: "Test Subject\r\nBcc: evil@evil.com",
From: mailAddressOutput{Email: "alice@example.com", Name: "Alice"},
To: []mailAddressOutput{{Email: "bob@example.com", Name: "Bob"}},
CC: []mailAddressOutput{{Email: "carol@example.com", Name: "Carol"}},
SMTPMessageID: "<msg-1@example.com>",
ThreadID: "thread-1",
BodyHTML: "<p>Hello</p>",
BodyPlainText: "Hello",
InternalDate: "1711111111000",
References: []string{"<ref-1@example.com>"},
ReplyTo: "replyto@example.com",
}
orig := toOriginalMessageForCompose(out)
// Subject injection should be stripped
if strings.Contains(orig.subject, "\r") || strings.Contains(orig.subject, "\n") {
t.Errorf("subject should have CR/LF stripped, got: %q", orig.subject)
}
if !strings.Contains(orig.subject, "Test Subject") {
t.Errorf("subject should still contain original text, got: %q", orig.subject)
}
if orig.headFrom != "alice@example.com" {
t.Errorf("headFrom = %q, want alice@example.com", orig.headFrom)
}
if orig.headFromName != "Alice" {
t.Errorf("headFromName = %q, want Alice", orig.headFromName)
}
if orig.headTo != "bob@example.com" {
t.Errorf("headTo = %q, want bob@example.com", orig.headTo)
}
if orig.replyTo != "replyto@example.com" {
t.Errorf("replyTo = %q, want replyto@example.com", orig.replyTo)
}
if orig.smtpMessageId != "<msg-1@example.com>" {
t.Errorf("smtpMessageId = %q", orig.smtpMessageId)
}
if orig.threadId != "thread-1" {
t.Errorf("threadId = %q", orig.threadId)
}
if orig.bodyRaw != "<p>Hello</p>" {
t.Errorf("bodyRaw should prefer HTML, got: %q", orig.bodyRaw)
}
if orig.headDate == "" {
t.Error("headDate should be set from InternalDate")
}
if orig.references != "<ref-1@example.com>" {
t.Errorf("references = %q", orig.references)
}
if len(orig.toAddresses) != 1 || orig.toAddresses[0] != "bob@example.com" {
t.Errorf("toAddresses = %v", orig.toAddresses)
}
if len(orig.ccAddresses) != 1 || orig.ccAddresses[0] != "carol@example.com" {
t.Errorf("ccAddresses = %v", orig.ccAddresses)
}
if len(orig.toAddressesFull) != 1 {
t.Errorf("toAddressesFull = %v", orig.toAddressesFull)
}
if len(orig.ccAddressesFull) != 1 {
t.Errorf("ccAddressesFull = %v", orig.ccAddressesFull)
}
}
// TestToOriginalMessageForCompose_NoHTML verifies to original message for compose no HTML.
func TestToOriginalMessageForCompose_NoHTML(t *testing.T) {
out := normalizedMessageForCompose{
Subject: "Plain email",
From: mailAddressOutput{Email: "alice@example.com"},
BodyPlainText: "Just plain text",
}
orig := toOriginalMessageForCompose(out)
if orig.bodyRaw != "Just plain text" {
t.Errorf("bodyRaw should fall back to plaintext, got: %q", orig.bodyRaw)
}
if orig.headTo != "" {
t.Errorf("headTo should be empty when To list is empty, got: %q", orig.headTo)
}
}
// TestToOriginalMessageForCompose_EmptyReferences verifies to original message for compose empty references.
func TestToOriginalMessageForCompose_EmptyReferences(t *testing.T) {
out := normalizedMessageForCompose{
From: mailAddressOutput{Email: "alice@example.com"},
References: nil,
}
orig := toOriginalMessageForCompose(out)
if orig.references != "" {
t.Errorf("references should be empty, got: %q", orig.references)
}
}
// ---------------------------------------------------------------------------
// validateInlineCIDs — bidirectional CID consistency
// ---------------------------------------------------------------------------
// TestValidateInlineCIDs_UserOrphanError verifies validate inline c i ds user orphan error.
func TestValidateInlineCIDs_UserOrphanError(t *testing.T) {
// User-provided CID not referenced in body → error.
err := validateInlineCIDs(`<p>no image</p>`, []string{"orphan-cid"}, nil)
if err == nil {
t.Fatal("expected orphaned CID error")
}
if !strings.Contains(err.Error(), "orphan-cid") {
t.Fatalf("expected error mentioning orphan-cid, got: %v", err)
}
}
// TestValidateInlineCIDs_SourceOrphanAllowed verifies validate inline c i ds source orphan allowed.
func TestValidateInlineCIDs_SourceOrphanAllowed(t *testing.T) {
// Source-message CID not referenced in body → allowed (quoting may drop references).
err := validateInlineCIDs(`<p>no image</p>`, nil, []string{"source-unused"})
if err != nil {
t.Fatalf("source CID orphan should not error, got: %v", err)
}
}
// TestValidateInlineCIDs_SourceAndUserMixed verifies validate inline c i ds source and user mixed.
func TestValidateInlineCIDs_SourceAndUserMixed(t *testing.T) {
// Body references both a source CID and a user CID.
// Source has an extra unreferenced CID — should not error.
html := `<p><img src="cid:src-used" /><img src="cid:user-img" /></p>`
err := validateInlineCIDs(html, []string{"user-img"}, []string{"src-used", "src-unused"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// TestValidateInlineCIDs_MissingRefError verifies validate inline c i ds missing ref error.
func TestValidateInlineCIDs_MissingRefError(t *testing.T) {
// Body references a CID that nobody provided → error.
html := `<p><img src="cid:exists" /><img src="cid:missing" /></p>`
err := validateInlineCIDs(html, []string{"exists"}, nil)
if err == nil {
t.Fatal("expected missing CID error")
}
if !strings.Contains(err.Error(), "missing") {
t.Fatalf("expected error mentioning missing, got: %v", err)
}
}
// TestValidateInlineCIDs_MissingRefSatisfiedBySource verifies validate inline c i ds missing ref satisfied by source.
func TestValidateInlineCIDs_MissingRefSatisfiedBySource(t *testing.T) {
// Body references a CID that only exists in source (extraCIDs) → ok.
html := `<p><img src="cid:from-source" /></p>`
err := validateInlineCIDs(html, nil, []string{"from-source"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// TestValidateInlineCIDs_NoCIDsNoError verifies validate inline c i ds no c i ds no error.
func TestValidateInlineCIDs_NoCIDsNoError(t *testing.T) {
err := validateInlineCIDs(`<p>plain text</p>`, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ---------------------------------------------------------------------------
// downloadAttachmentContent — size limit enforcement
// ---------------------------------------------------------------------------
// TestDownloadAttachmentContent_HTTP4xx verifies download attachment content h t t p4xx.
func TestDownloadAttachmentContent_HTTP4xx(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "not found", http.StatusNotFound)
}))
defer srv.Close()
rt := newDownloadRuntime(t, srv.Client())
_, err := downloadAttachmentContent(rt, srv.URL+"/missing")
if err == nil || !strings.Contains(err.Error(), "HTTP 404") {
t.Errorf("expected HTTP 404 error, got: %v", err)
}
}
// TestDownloadAttachmentContent_SizeLimit verifies download attachment content size limit.
func TestDownloadAttachmentContent_SizeLimit(t *testing.T) {
// Return a response that claims to be larger than MaxAttachmentDownloadBytes
// We can't actually write 35MB in a test, but we can test the limit logic
// by creating a server that returns slightly more than the limit.
// For efficiency, just verify the error message pattern with a small payload
// and a temporarily reduced limit is not feasible. Instead test the boundary.
bigPayload := strings.Repeat("x", MaxAttachmentDownloadBytes+1)
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, bigPayload)
}))
defer srv.Close()
rt := newDownloadRuntime(t, srv.Client())
_, err := downloadAttachmentContent(rt, srv.URL+"/big")
if err == nil || !strings.Contains(err.Error(), "size limit") {
t.Errorf("expected size limit error, got: %v", err)
}
}
// ---------------------------------------------------------------------------
// buildReplyAllRecipients — no-mutation of excluded map (tests the copy fix)
// ---------------------------------------------------------------------------
// TestBuildReplyAllRecipients_DoesNotMutateExcluded verifies build reply all recipients does not mutate excluded.
func TestBuildReplyAllRecipients_DoesNotMutateExcluded(t *testing.T) {
excluded := map[string]bool{"blocked@example.com": true}
originalLen := len(excluded)
buildReplyAllRecipients("alice@example.com", nil, nil, "me@example.com", excluded, false)
if len(excluded) != originalLen {
t.Errorf("excluded map was mutated: had %d entries, now has %d", originalLen, len(excluded))
}
}
// ---------------------------------------------------------------------------
// addInlineImagesToBuilder — with empty CID skip
// ---------------------------------------------------------------------------
// TestAddInlineImagesToBuilder_EmptyCIDSkipped verifies add inline images to builder empty CID skipped.
func TestAddInlineImagesToBuilder_EmptyCIDSkipped(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "imagedata")
}))
defer srv.Close()
rt := newDownloadRuntime(t, srv.Client())
bld := emlbuilder.New().TextBody([]byte("test"))
images := []inlineSourcePart{
{ID: "img1", Filename: "logo.png", ContentType: "image/png", CID: "", DownloadURL: srv.URL + "/img1"},
}
_, _, totalBytes, err := addInlineImagesToBuilder(rt, bld, images)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if totalBytes != 0 {
t.Errorf("expected 0 totalBytes for skipped CID, got %d", totalBytes)
}
}
// TestAddInlineImagesToBuilder_Success verifies add inline images to builder success.
func TestAddInlineImagesToBuilder_Success(t *testing.T) {
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "imagedata")
}))
defer srv.Close()
rt := newDownloadRuntime(t, srv.Client())
bld := emlbuilder.New().
From("Test", "test@example.com").
To("Recipient", "to@example.com").
Subject("test").
HTMLBody([]byte("<img src='cid:banner'>"))
images := []inlineSourcePart{
{ID: "img1", Filename: "banner.png", ContentType: "image/png", CID: "cid:banner", DownloadURL: srv.URL + "/img1"},
}
result, _, totalBytes, err := addInlineImagesToBuilder(rt, bld, images)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if totalBytes != int64(len("imagedata")) {
t.Errorf("expected totalBytes=%d, got %d", len("imagedata"), totalBytes)
}
raw, err := result.BuildBase64URL()
if err != nil {
t.Fatalf("failed to build EML: %v", err)
}
if raw == "" {
t.Error("expected non-empty EML output")
}
}
// ---------------------------------------------------------------------------
// normalizeInlineCID
// ---------------------------------------------------------------------------
// TestNormalizeInlineCID verifies normalize inline CID.
func TestNormalizeInlineCID(t *testing.T) {
tests := []struct {
input, want string
}{
{"cid:banner", "banner"},
{"CID:banner", "banner"},
{"<banner>", "banner"},
{"cid:<banner>", "banner"},
{" cid:<banner> ", "banner"},
{"plain", "plain"},
{"", ""},
}
for _, tt := range tests {
got := normalizeInlineCID(tt.input)
if got != tt.want {
t.Errorf("normalizeInlineCID(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// TestResolveComposeMailboxID verifies resolve compose mailbox ID.
func TestResolveComposeMailboxID(t *testing.T) {
tests := []struct {
name string
mailbox string
from string
want string
}{
{"default", "", "", "me"},
{"explicit from", "", "shared@example.com", "shared@example.com"},
{"explicit mailbox", "owner@example.com", "", "owner@example.com"},
{"mailbox takes priority over from", "owner@example.com", "alias@example.com", "owner@example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("from", "", "")
cmd.Flags().String("mailbox", "", "")
if tt.from != "" {
_ = cmd.Flags().Set("from", tt.from)
}
if tt.mailbox != "" {
_ = cmd.Flags().Set("mailbox", tt.mailbox)
}
rt := &common.RuntimeContext{Cmd: cmd}
if got := resolveComposeMailboxID(rt); got != tt.want {
t.Errorf("resolveComposeMailboxID() = %q, want %q", got, tt.want)
}
})
}
}
// TestResolveComposeSenderEmail verifies resolve compose sender email.
func TestResolveComposeSenderEmail(t *testing.T) {
// Note: the "no flags" case falls through to fetchMailboxPrimaryEmail which
// requires an API client. That path is covered by integration/shortcut tests.
// Here we test the flag-based short-circuit paths only.
// Note: "mailbox=me without from" falls through to fetchMailboxPrimaryEmail
// (same as "no flags"), which requires an API client — covered by
// integration/shortcut tests.
tests := []struct {
name string
mailbox string
from string
want string
}{
{"from only", "", "alias@example.com", "alias@example.com"},
{"mailbox only", "shared@example.com", "", "shared@example.com"},
{"from takes priority over mailbox", "shared@example.com", "alias@example.com", "alias@example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("from", "", "")
cmd.Flags().String("mailbox", "", "")
if tt.from != "" {
_ = cmd.Flags().Set("from", tt.from)
}
if tt.mailbox != "" {
_ = cmd.Flags().Set("mailbox", tt.mailbox)
}
rt := &common.RuntimeContext{Cmd: cmd}
got := resolveComposeSenderEmail(rt)
if got != tt.want {
t.Errorf("resolveComposeSenderEmail() = %q, want %q", got, tt.want)
}
})
}
}
// TestParseNetAddrs_Dedup verifies parse net addrs dedup.
func TestParseNetAddrs_Dedup(t *testing.T) {
tests := []struct {
name string
input string
want []string // expected email addresses in order
}{
{"no duplicates", "a@x.com, b@x.com", []string{"a@x.com", "b@x.com"}},
{"exact duplicate", "a@x.com, a@x.com", []string{"a@x.com"}},
{"case-insensitive duplicate", "Alice@X.COM, alice@x.com", []string{"Alice@X.COM"}},
{"mixed with names", "Alice <a@x.com>, Bob <b@x.com>, a@x.com", []string{"a@x.com", "b@x.com"}},
{"triple duplicate", "a@x.com, a@x.com, a@x.com", []string{"a@x.com"}},
{"empty", "", nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseNetAddrs(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("parseNetAddrs(%q) returned %d addrs, want %d: %v", tt.input, len(got), len(tt.want), got)
}
for i, addr := range got {
if addr.Address != tt.want[i] {
t.Errorf("parseNetAddrs(%q)[%d].Address = %q, want %q", tt.input, i, addr.Address, tt.want[i])
}
}
})
}
// Verify dedup is per-field only, NOT cross-field: separate calls must
// maintain independent seen sets so the same address can appear in both
// To and CC.
t.Run("no cross-field dedup", func(t *testing.T) {
shared := "overlap@x.com"
toAddrs := parseNetAddrs(shared)
ccAddrs := parseNetAddrs(shared + ", other@x.com")
if len(toAddrs) != 1 || toAddrs[0].Address != shared {
t.Fatalf("to: got %v", toAddrs)
}
if len(ccAddrs) != 2 {
t.Fatalf("cc should have 2 addrs (no cross-field dedup), got %v", ccAddrs)
}
if ccAddrs[0].Address != shared {
t.Errorf("cc[0] = %q, want %q", ccAddrs[0].Address, shared)
}
})
}
// ---------------------------------------------------------------------------
// validateRecipientCount
// ---------------------------------------------------------------------------
// TestValidateRecipientCount verifies validate recipient count.
func TestValidateRecipientCount(t *testing.T) {
t.Run("under limit", func(t *testing.T) {
err := validateRecipientCount("a@x.com, b@x.com", "c@x.com", "d@x.com")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
t.Run("empty fields", func(t *testing.T) {
err := validateRecipientCount("", "", "")
if err != nil {
t.Fatalf("unexpected error for empty fields: %v", err)
}
})
t.Run("exactly at limit", func(t *testing.T) {
// Build a list of exactly MaxRecipientCount addresses
addrs := make([]string, MaxRecipientCount)
for i := range addrs {
addrs[i] = fmt.Sprintf("user%d@example.com", i)
}
all := strings.Join(addrs, ",")
err := validateRecipientCount(all, "", "")
if err != nil {
t.Fatalf("should accept exactly %d recipients, got error: %v", MaxRecipientCount, err)
}
})
t.Run("exceeds limit", func(t *testing.T) {
addrs := make([]string, MaxRecipientCount+1)
for i := range addrs {
addrs[i] = fmt.Sprintf("user%d@example.com", i)
}
all := strings.Join(addrs, ",")
err := validateRecipientCount(all, "", "")
if err == nil {
t.Fatal("expected error for exceeding recipient limit")
}
if !strings.Contains(err.Error(), "exceeds the limit") {
t.Fatalf("unexpected error message: %v", err)
}
})
t.Run("combined across fields", func(t *testing.T) {
// Split across To, CC, BCC to exceed limit
half := MaxRecipientCount / 2
toAddrs := make([]string, half)
for i := range toAddrs {
toAddrs[i] = fmt.Sprintf("to%d@example.com", i)
}
ccAddrs := make([]string, half)
for i := range ccAddrs {
ccAddrs[i] = fmt.Sprintf("cc%d@example.com", i)
}
// This puts us at MaxRecipientCount, add 1 BCC to exceed
err := validateRecipientCount(
strings.Join(toAddrs, ","),
strings.Join(ccAddrs, ","),
"bcc-extra@example.com",
)
if err == nil {
t.Fatal("expected error when To+CC+BCC exceeds limit")
}
})
t.Run("deduplication within field", func(t *testing.T) {
// ParseMailboxList deduplicates, so duplicates should not inflate count
err := validateRecipientCount("a@x.com, a@x.com, a@x.com", "", "")
if err != nil {
t.Fatalf("duplicates should be deduplicated, got error: %v", err)
}
})
}
// TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount verifies validate compose has at least one recipient also checks count.
func TestValidateComposeHasAtLeastOneRecipient_AlsoChecksCount(t *testing.T) {
// Verify that validateComposeHasAtLeastOneRecipient also enforces the count limit
addrs := make([]string, MaxRecipientCount+1)
for i := range addrs {
addrs[i] = fmt.Sprintf("user%d@example.com", i)
}
all := strings.Join(addrs, ",")
err := validateComposeHasAtLeastOneRecipient(all, "", "")
if err == nil {
t.Fatal("expected error for exceeding recipient limit via validateComposeHasAtLeastOneRecipient")
}
if !strings.Contains(err.Error(), "exceeds the limit") {
t.Fatalf("unexpected error message: %v", err)
}
}
// ---------------------------------------------------------------------------
// validateSendTime
// ---------------------------------------------------------------------------
func newSendTimeRuntime(t *testing.T, sendTime string, confirmSend bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("send-time", "", "")
cmd.Flags().Bool("confirm-send", false, "")
if sendTime != "" {
_ = cmd.Flags().Set("send-time", sendTime)
}
if confirmSend {
_ = cmd.Flags().Set("confirm-send", "true")
}
return &common.RuntimeContext{Cmd: cmd}
}
// TestValidateSendTime_Empty verifies validate send time empty.
func TestValidateSendTime_Empty(t *testing.T) {
rt := newSendTimeRuntime(t, "", false)
if err := validateSendTime(rt); err != nil {
t.Fatalf("expected nil when send-time is empty, got %v", err)
}
}
// TestValidateSendTime_RequiresConfirmSend verifies validate send time requires confirm send.
func TestValidateSendTime_RequiresConfirmSend(t *testing.T) {
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
rt := newSendTimeRuntime(t, future, false)
err := validateSendTime(rt)
if err == nil {
t.Fatal("expected error when --send-time is set without --confirm-send")
}
if !strings.Contains(err.Error(), "--confirm-send") {
t.Errorf("expected error to mention --confirm-send, got: %v", err)
}
}
// TestValidateSendTime_InvalidInteger verifies validate send time invalid integer.
func TestValidateSendTime_InvalidInteger(t *testing.T) {
rt := newSendTimeRuntime(t, "not-a-number", true)
err := validateSendTime(rt)
if err == nil {
t.Fatal("expected error when --send-time is not a valid integer")
}
if !strings.Contains(err.Error(), "Unix timestamp") {
t.Errorf("expected error to mention Unix timestamp, got: %v", err)
}
}
// TestValidateSendTime_TooSoon verifies validate send time too soon.
func TestValidateSendTime_TooSoon(t *testing.T) {
// Just 1 minute in the future — below the 5-minute minimum.
soon := strconv.FormatInt(time.Now().Unix()+60, 10)
rt := newSendTimeRuntime(t, soon, true)
err := validateSendTime(rt)
if err == nil {
t.Fatal("expected error when --send-time is less than 5 minutes in the future")
}
if !strings.Contains(err.Error(), "5 minutes") {
t.Errorf("expected error to mention 5 minute minimum, got: %v", err)
}
}
// TestValidateSendTime_Valid verifies validate send time valid.
func TestValidateSendTime_Valid(t *testing.T) {
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
rt := newSendTimeRuntime(t, future, true)
if err := validateSendTime(rt); err != nil {
t.Fatalf("expected nil for valid future send-time, got %v", err)
}
}
func TestValidateEventSendTimeExclusion(t *testing.T) {
future := strconv.FormatInt(time.Now().Unix()+10*60, 10)
cases := []struct {
name string
eventFlag string
eventVal string
}{
{"event-summary triggers exclusion", "event-summary", "Team meeting"},
{"event-start triggers exclusion", "event-start", "2026-05-01T10:00+08:00"},
{"event-end triggers exclusion", "event-end", "2026-05-01T11:00+08:00"},
{"event-location triggers exclusion", "event-location", "Room 5F"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("send-time", "", "")
for _, f := range []string{"event-summary", "event-start", "event-end", "event-location"} {
cmd.Flags().String(f, "", "")
}
_ = cmd.Flags().Set("send-time", future)
_ = cmd.Flags().Set(tc.eventFlag, tc.eventVal)
rt := &common.RuntimeContext{Cmd: cmd}
err := validateEventSendTimeExclusion(rt)
if err == nil {
t.Fatalf("expected error when --send-time and --%s are both set", tc.eventFlag)
}
if !strings.Contains(err.Error(), "--event-*") {
t.Errorf("expected error to mention --event-*, got: %v", err)
}
})
}
}
func TestParsePriority(t *testing.T) {
cases := []struct {
name string
input string
want string
wantErr bool
}{
{"empty", "", "", false},
{"high", "high", "1", false},
{"normal", "normal", "", false},
{"low", "low", "5", false},
{"case-insensitive HIGH", "HIGH", "1", false},
{"whitespace padding", " low ", "5", false},
{"invalid", "urgent", "", true},
{"numeric not accepted", "1", "", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := parsePriority(tc.input)
if tc.wantErr {
if err == nil {
t.Fatalf("parsePriority(%q): expected error, got nil", tc.input)
}
return
}
if err != nil {
t.Fatalf("parsePriority(%q): unexpected error: %v", tc.input, err)
}
if got != tc.want {
t.Errorf("parsePriority(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}
// TestBuildMessageOutput_PriorityFromLabels verifies build message output priority from labels.
func TestBuildMessageOutput_PriorityFromLabels(t *testing.T) {
cases := []struct {
name string
labels []interface{}
priorityType string
wantType string
wantText string
}{
{"high from label", []interface{}{"UNREAD", "HIGH_PRIORITY"}, "", "1", "high"},
{"low from label", []interface{}{"LOW_PRIORITY"}, "", "5", "low"},
{"no priority label", []interface{}{"UNREAD"}, "", "", ""},
{"label overrides priority_type field", []interface{}{"HIGH_PRIORITY"}, "5", "1", "high"},
{"priority_type fallback when no label", []interface{}{"UNREAD"}, "1", "1", "high"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
msg := map[string]interface{}{
"message_id": "m1",
"label_ids": tc.labels,
}
if tc.priorityType != "" {
msg["priority_type"] = tc.priorityType
}
out := buildMessageOutput(msg, false)
gotText, _ := out["priority_type_text"].(string)
if gotText != tc.wantText {
t.Errorf("priority_type_text = %q, want %q", gotText, tc.wantText)
}
gotType, _ := out["priority_type"].(string)
if gotType != tc.wantType {
t.Errorf("priority_type = %q, want %q", gotType, tc.wantType)
}
})
}
}
// TestApplyPriority verifies apply priority.
func TestApplyPriority(t *testing.T) {
// Empty priority: EML must not contain X-Cli-Priority header.
emptyBld := emlbuilder.New().
From("", "sender@example.com").
To("", "recipient@example.com").
Subject("no priority").
TextBody([]byte("body"))
emptyBld = applyPriority(emptyBld, "")
raw, err := emptyBld.BuildBase64URL()
if err != nil {
t.Fatalf("build EML failed: %v", err)
}
eml := decodeBase64URL(raw)
if strings.Contains(eml, "X-Cli-Priority") {
t.Errorf("expected no X-Cli-Priority header when priority is empty, got EML:\n%s", eml)
}
// Non-empty priority: header must be present with the exact value.
highBld := emlbuilder.New().
From("", "sender@example.com").
To("", "recipient@example.com").
Subject("high priority").
TextBody([]byte("body"))
highBld = applyPriority(highBld, "1")
raw, err = highBld.BuildBase64URL()
if err != nil {
t.Fatalf("build EML failed: %v", err)
}
eml = decodeBase64URL(raw)
if !strings.Contains(eml, "X-Cli-Priority: 1") {
t.Errorf("expected X-Cli-Priority: 1 in EML, got:\n%s", eml)
}
}
// TestValidatePriorityFlag verifies validate priority flag.
func TestValidatePriorityFlag(t *testing.T) {
makeRuntime := func(priority string) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("priority", "", "")
if priority != "" {
_ = cmd.Flags().Set("priority", priority)
}
return common.TestNewRuntimeContext(cmd, nil)
}
cases := []struct {
name string
priority string
wantErr bool
}{
{"empty ok", "", false},
{"high ok", "high", false},
{"normal ok", "normal", false},
{"low ok", "low", false},
{"invalid urgent", "urgent", true},
{"invalid numeric", "1", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validatePriorityFlag(makeRuntime(tc.priority))
if tc.wantErr && err == nil {
t.Errorf("validatePriorityFlag(%q): expected error, got nil", tc.priority)
}
if !tc.wantErr && err != nil {
t.Errorf("validatePriorityFlag(%q): unexpected error: %v", tc.priority, err)
}
})
}
}
// TestBuildMessageForCompose_InlineNoCID_ClassifiedAsAttachment verifies build message for compose inline no CID classified as attachment.
func TestBuildMessageForCompose_InlineNoCID_ClassifiedAsAttachment(t *testing.T) {
msg := map[string]interface{}{
"message_id": "msg1",
"subject": "test",
"attachments": []interface{}{
map[string]interface{}{"id": "att1", "filename": "with-cid.png", "is_inline": true, "cid": "cid123", "content_type": "image/png"},
map[string]interface{}{"id": "att2", "filename": "no-cid.png", "is_inline": true, "cid": "", "content_type": "image/png"},
map[string]interface{}{"id": "att3", "filename": "regular.pdf", "is_inline": false, "content_type": "application/pdf"},
},
}
out := buildMessageForCompose(msg, nil, true)
if len(out.Images) != 1 || out.Images[0].ID != "att1" {
t.Errorf("expected 1 image (att1), got %d: %+v", len(out.Images), out.Images)
}
if len(out.Attachments) != 2 {
t.Fatalf("expected 2 attachments, got %d: %+v", len(out.Attachments), out.Attachments)
}
ids := []string{out.Attachments[0].ID, out.Attachments[1].ID}
if ids[0] != "att2" || ids[1] != "att3" {
t.Errorf("expected attachments [att2, att3], got %v", ids)
}
}
// ---------------------------------------------------------------------------
// validateComposeInlineAndAttachments
// ---------------------------------------------------------------------------
// TestValidateComposeInlineAndAttachments verifies validate compose inline and attachments.
func TestValidateComposeInlineAndAttachments(t *testing.T) {
chdirTemp(t)
fio := &localfileio.LocalFileIO{}
t.Run("empty flags pass", func(t *testing.T) {
if err := validateComposeInlineAndAttachments(fio, "", "", false, ""); err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
t.Run("inline with plain-text rejected", func(t *testing.T) {
err := validateComposeInlineAndAttachments(fio, "", `[{"cid":"c1","file_path":"./img.png"}]`, true, "")
if err == nil || !strings.Contains(err.Error(), "--plain-text") {
t.Fatalf("expected plain-text rejection, got %v", err)
}
})
t.Run("inline with non-HTML body rejected", func(t *testing.T) {
err := validateComposeInlineAndAttachments(fio, "", `[{"cid":"c1","file_path":"./img.png"}]`, false, "plain text body")
if err == nil || !strings.Contains(err.Error(), "HTML body") {
t.Fatalf("expected HTML body rejection, got %v", err)
}
})
t.Run("inline with HTML body passes format check", func(t *testing.T) {
os.WriteFile("img.png", []byte("png"), 0o644)
err := validateComposeInlineAndAttachments(fio, "", `[{"cid":"c1","file_path":"./img.png"}]`, false, "<p>hello</p>")
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
t.Run("attach missing file rejected", func(t *testing.T) {
err := validateComposeInlineAndAttachments(fio, "nonexistent.pdf", "", false, "")
if err == nil || !strings.Contains(err.Error(), "stat") {
t.Fatalf("expected stat error for missing file, got %v", err)
}
})
t.Run("attach blocked extension rejected", func(t *testing.T) {
os.WriteFile("malware.exe", []byte("bad"), 0o644)
err := validateComposeInlineAndAttachments(fio, "malware.exe", "", false, "")
if err == nil || !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("expected blocked extension error, got %v", err)
}
})
t.Run("attach valid file passes", func(t *testing.T) {
os.WriteFile("report.pdf", []byte("pdf content"), 0o644)
err := validateComposeInlineAndAttachments(fio, "report.pdf", "", false, "")
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
})
t.Run("invalid inline JSON rejected", func(t *testing.T) {
err := validateComposeInlineAndAttachments(fio, "", "not-json", false, "")
if err == nil {
t.Fatal("expected error for invalid inline JSON")
}
})
}
// newRequestReceiptRuntime registers the --request-receipt bool flag alone
// (no --from), so requireSenderForRequestReceipt tests can drive the flag
// directly without pulling in unrelated compose plumbing.
func newRequestReceiptRuntime(t *testing.T, requestReceipt bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Bool("request-receipt", false, "")
if requestReceipt {
_ = cmd.Flags().Set("request-receipt", "true")
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestRequireSenderForRequestReceipt(t *testing.T) {
cases := []struct {
name string
requestReceipt bool
senderEmail string
wantErr bool
}{
{"flag unset, empty sender ok", false, "", false},
{"flag unset, with sender ok", false, "alice@example.com", false},
{"flag set, empty sender errors", true, "", true},
{"flag set, whitespace-only sender errors", true, " ", true},
{"flag set, with sender ok", true, "alice@example.com", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := requireSenderForRequestReceipt(
newRequestReceiptRuntime(t, tc.requestReceipt), tc.senderEmail)
if tc.wantErr && err == nil {
t.Errorf("expected error, got nil")
}
if !tc.wantErr && err != nil {
t.Errorf("unexpected error: %v", err)
}
if tc.wantErr && err != nil && !strings.Contains(err.Error(), "--request-receipt") {
t.Errorf("error message should mention --request-receipt, got: %v", err)
}
})
}
}
func TestShellQuoteForHint(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"plain", "user@example.com", "user@example.com"},
{"with single quote", "O'Brien", `O'\''Brien`},
{"with space", "hello world", "hello world"},
{"mixed", "it's a test", `it'\''s a test`},
{"empty", "", ""},
// The single-line sanitizer must strip embedded newlines so a crafted
// mailboxID / messageID can't forge extra lines in a hint.
{"with newline stripped", "abc\ndef", "abcdef"},
{"with CR + LF stripped", "abc\r\ndef", "abcdef"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := shellQuoteForHint(tc.in); got != tc.want {
t.Errorf("shellQuoteForHint(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestSanitizeForSingleLine(t *testing.T) {
cases := []struct {
name string
in string
want string
}{
{"plain passes through", "alice@example.com", "alice@example.com"},
{"strips LF", "alice@example.com\ntip: forged", "alice@example.comtip: forged"},
{"strips CR+LF", "x\r\ny", "xy"},
{"strips ANSI + LF", "\x1b[31mred\x1b[0m\nnext", "rednext"},
{"keeps tab", "a\tb", "a\tb"},
{"strips bidi override", "a\u202eb", "ab"},
{"empty", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := sanitizeForSingleLine(tc.in); got != tc.want {
t.Errorf("sanitizeForSingleLine(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestValidateHeaderAddress(t *testing.T) {
cases := []struct {
name string
in string
wantErr string // substring expected in error, "" = no error
}{
{"plain", "alice@example.com", ""},
{"tab allowed for folded headers", "alice@example.com\tcomment", ""},
{"lf rejected", "alice@example.com\nX-Injected: 1", "control character"},
{"cr rejected", "alice@example.com\rsomething", "control character"},
{"del rejected", "alice@example.com\x7f", "control character"},
{"bidi override rejected", "alice@example.com\u202e", "dangerous Unicode"},
{"zero-width rejected", "ali\u200bce@example.com", "dangerous Unicode"},
{"empty ok", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := validateHeaderAddress(tc.in)
if tc.wantErr == "" && err != nil {
t.Errorf("expected no error, got %v", err)
}
if tc.wantErr != "" {
if err == nil {
t.Errorf("expected error containing %q, got nil", tc.wantErr)
} else if !strings.Contains(err.Error(), tc.wantErr) {
t.Errorf("expected error containing %q, got %v", tc.wantErr, err)
}
}
})
}
}
// ---------------------------------------------------------------------------
// parseEventTimeRange
// ---------------------------------------------------------------------------
func TestParseEventTimeRange_OK(t *testing.T) {
s, e, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T15:00+08:00")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !e.After(s) {
t.Errorf("end should be after start; got start=%v end=%v", s, e)
}
}
func TestParseEventTimeRange_EndBeforeStart(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T15:00+08:00", "2026-04-25T14:00+08:00")
if err == nil {
t.Fatal("expected error when end < start")
}
if !strings.Contains(err.Error(), "end time must be after start time") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestParseEventTimeRange_EndEqualsStart(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "2026-04-25T14:00+08:00")
if err == nil {
t.Fatal("expected error when end == start (zero duration)")
}
}
func TestParseEventTimeRange_InvalidStart(t *testing.T) {
_, _, err := parseEventTimeRange("not-a-time", "2026-04-25T15:00+08:00")
if err == nil || !strings.Contains(err.Error(), "start: invalid ISO 8601") {
t.Errorf("expected start parse error, got: %v", err)
}
}
func TestParseEventTimeRange_InvalidEnd(t *testing.T) {
_, _, err := parseEventTimeRange("2026-04-25T14:00+08:00", "not-a-time")
if err == nil || !strings.Contains(err.Error(), "end: invalid ISO 8601") {
t.Errorf("expected end parse error, got: %v", err)
}
}
func TestPrefixEventRangeError(t *testing.T) {
start := fmt.Errorf("start: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--event-", start).Error(); got != `--event-start: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
end := fmt.Errorf("end: invalid ISO 8601 time %q", "x")
if got := prefixEventRangeError("--set-event-", end).Error(); got != `--set-event-end: invalid ISO 8601 time "x"` {
t.Errorf("got %q", got)
}
// Non-prefixed error passes through unchanged.
other := fmt.Errorf("end time must be after start time")
if got := prefixEventRangeError("--event-", other).Error(); got != "end time must be after start time" {
t.Errorf("got %q", got)
}
}
// ---------------------------------------------------------------------------
// validateEventFlags (runtime-backed)
// ---------------------------------------------------------------------------
func newEventFlagsRuntime(t *testing.T, summary, start, end string) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("event-summary", "", "")
cmd.Flags().String("event-start", "", "")
cmd.Flags().String("event-end", "", "")
cmd.Flags().String("event-location", "", "")
if summary != "" {
_ = cmd.Flags().Set("event-summary", summary)
}
if start != "" {
_ = cmd.Flags().Set("event-start", start)
}
if end != "" {
_ = cmd.Flags().Set("event-end", end)
}
return &common.RuntimeContext{Cmd: cmd}
}
func TestValidateEventFlags_AllEmptyOK(t *testing.T) {
rt := newEventFlagsRuntime(t, "", "", "")
if err := validateEventFlags(rt); err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestValidateEventFlags_AllSetOK(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00")
if err := validateEventFlags(rt); err != nil {
t.Errorf("expected no error, got %v", err)
}
}
func TestValidateEventFlags_PartialRejected(t *testing.T) {
cases := []struct {
name string
summary string
start string
end string
}{
{"only_summary", "Meeting", "", ""},
{"only_start", "", "2026-04-25T10:00+08:00", ""},
{"only_end", "", "", "2026-04-25T11:00+08:00"},
{"missing_end", "Meeting", "2026-04-25T10:00+08:00", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rt := newEventFlagsRuntime(t, tc.summary, tc.start, tc.end)
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "must all be provided together") {
t.Errorf("expected 'all together' error, got %v", err)
}
})
}
}
func TestValidateEventFlags_EndBeforeStartRejected(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "2026-04-25T11:00+08:00", "2026-04-25T10:00+08:00")
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "after start") {
t.Errorf("expected end-after-start error, got %v", err)
}
}
func TestValidateEventFlags_InvalidTimeFormatRejected(t *testing.T) {
rt := newEventFlagsRuntime(t, "Meeting", "not-a-time", "2026-04-25T11:00+08:00")
err := validateEventFlags(rt)
if err == nil || !strings.Contains(err.Error(), "--event-start") {
t.Errorf("expected --event-start error, got %v", err)
}
}
// ---------------------------------------------------------------------------
// buildCalendarBodyFromArgs
// ---------------------------------------------------------------------------
func TestBuildCalendarBodyFromArgs_EmptySummaryReturnsNil(t *testing.T) {
got := buildCalendarBodyFromArgs("", "2026-04-25T10:00+08:00", "2026-04-25T11:00+08:00", "", "sender@example.com", "to@example.com", "")
if got != nil {
t.Errorf("expected nil for empty summary, got %d bytes", len(got))
}
}
func TestBuildCalendarBodyFromArgs_IncludesSummaryAndAddresses(t *testing.T) {
got := buildCalendarBodyFromArgs(
"Product Review",
"2026-04-25T14:00+08:00",
"2026-04-25T15:00+08:00",
"5F Room",
"sender@example.com",
"a@example.com,b@example.com",
"c@example.com",
)
if got == nil {
t.Fatal("expected non-nil ICS bytes")
}
s := string(got)
checks := []string{
"BEGIN:VCALENDAR",
"SUMMARY:Product Review",
"LOCATION:5F Room",
"sender@example.com",
"a@example.com",
"b@example.com",
"c@example.com",
}
for _, want := range checks {
if !strings.Contains(s, want) {
t.Errorf("missing %q in generated ICS:\n%s", want, s)
}
}
}
func TestBuildCalendarBodyFromArgs_NoCcWorks(t *testing.T) {
got := buildCalendarBodyFromArgs(
"Meeting",
"2026-04-25T10:00+08:00",
"2026-04-25T11:00+08:00",
"",
"sender@example.com",
"to@example.com",
"",
)
if got == nil {
t.Fatal("expected non-nil ICS bytes")
}
if !strings.Contains(string(got), "to@example.com") {
t.Error("attendee missing")
}
}