From ddd2fb08f99c7cf43549daa1d6a80fc1efa9fc65 Mon Sep 17 00:00:00 2001 From: mayor <4678+seanb4t@users.noreply.github.com> Date: Wed, 4 Feb 2026 17:32:53 -0500 Subject: [PATCH] feat(cli): add mail read command to view email content (fc-h2at) Enhance MailService.Get() to fetch full email properties including from, to, cc, and body text via fetchTextBodyValues. Add mail read CLI subcommand that displays email headers and body in text or JSON format. Update convertEmails to populate address and body fields. Co-Authored-By: Claude Opus 4.5 --- cli/mail.go | 68 +++++++++++++++++++++++++++++++++++++++++++ cli/mail_test.go | 19 +++++++++++- docs/site/cli/mail.md | 45 ++++++++++++++++++++++++++++ pkg/fastmail/mail.go | 37 +++++++++++++++++++++-- 4 files changed, 165 insertions(+), 4 deletions(-) diff --git a/cli/mail.go b/cli/mail.go index 1528c0b..b6cf940 100644 --- a/cli/mail.go +++ b/cli/mail.go @@ -21,12 +21,45 @@ func newMailCommand() *cobra.Command { } cmd.AddCommand(newMailListCommand()) + cmd.AddCommand(newMailReadCommand()) cmd.AddCommand(newMailSendCommand()) cmd.AddCommand(newMailReplyCommand()) return cmd } +// newMailReadCommand creates the mail read command. +func newMailReadCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "read EMAIL_ID", + Short: "Read an email", + Long: "Display the full content of an email by its ID.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + emailID := args[0] + + client, err := createClient() + if err != nil { + return err + } + + ctx := context.Background() + if err := client.Connect(ctx); err != nil { + return fmt.Errorf("connecting: %w", err) + } + + email, err := client.Mail().Get(ctx, emailID) + if err != nil { + return fmt.Errorf("reading email: %w", err) + } + + return outputEmailDetail(cmd, email) + }, + } + + return cmd +} + // newMailListCommand creates the mail list command. func newMailListCommand() *cobra.Command { var limit uint64 @@ -242,6 +275,41 @@ func outputEmails(cmd *cobra.Command, emails []fastmail.Email) error { return nil } +// outputEmailDetail writes a single email with full headers and body. +func outputEmailDetail(cmd *cobra.Command, email *fastmail.Email) error { + if IsQuiet() { + return nil + } + + if IsJSONOutput() { + enc := json.NewEncoder(cmd.OutOrStdout()) + enc.SetIndent("", " ") + return enc.Encode(email) + } + + w := cmd.OutOrStdout() + _, _ = fmt.Fprintf(w, "From: %s\n", email.From) + if len(email.To) > 0 { + toStrs := make([]string, len(email.To)) + for i, addr := range email.To { + toStrs[i] = addr.String() + } + _, _ = fmt.Fprintf(w, "To: %s\n", strings.Join(toStrs, ", ")) + } + if len(email.Cc) > 0 { + ccStrs := make([]string, len(email.Cc)) + for i, addr := range email.Cc { + ccStrs[i] = addr.String() + } + _, _ = fmt.Fprintf(w, "Cc: %s\n", strings.Join(ccStrs, ", ")) + } + _, _ = fmt.Fprintf(w, "Date: %s\n", email.ReceivedAt.Format("2006-01-02 15:04")) + _, _ = fmt.Fprintf(w, "Subject: %s\n", email.Subject) + _, _ = fmt.Fprintf(w, "\n%s\n", email.Body) + + return nil +} + // outputSendResult writes the send result to output. func outputSendResult(cmd *cobra.Command, emailID string) error { if IsQuiet() { diff --git a/cli/mail_test.go b/cli/mail_test.go index ba32c3d..29bbb15 100644 --- a/cli/mail_test.go +++ b/cli/mail_test.go @@ -21,7 +21,7 @@ func TestMailHelp_ShowsSubcommands(t *testing.T) { output := buf.String() // Should show all subcommands - subcommands := []string{"list", "send", "reply"} + subcommands := []string{"list", "read", "send", "reply"} for _, sub := range subcommands { if !strings.Contains(output, sub) { t.Errorf("expected %q subcommand in help, got: %q", sub, output) @@ -29,6 +29,23 @@ func TestMailHelp_ShowsSubcommands(t *testing.T) { } } +func TestMailRead_RequiresEmailID(t *testing.T) { + cmd := NewRootCommand() + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"mail", "read"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("mail read without email ID should error") + } + + if !strings.Contains(err.Error(), "arg") { + t.Errorf("expected argument error, got: %v", err) + } +} + func TestMailSend_RequiresTo(t *testing.T) { cmd := NewRootCommand() buf := new(bytes.Buffer) diff --git a/docs/site/cli/mail.md b/docs/site/cli/mail.md index 842e5b9..7e7bf6b 100644 --- a/docs/site/cli/mail.md +++ b/docs/site/cli/mail.md @@ -5,6 +5,7 @@ Commands for reading, sending, and managing email. ## Commands - [mail list](#mail-list) - List emails +- [mail read](#mail-read) - Read an email - [mail send](#mail-send) - Send an email - [mail reply](#mail-reply) - Reply to an email @@ -54,6 +55,50 @@ fastmail-cli mail list -n 5 --- +## mail read + +Display the full content of an email by its ID. + +```bash +fastmail-cli mail read EMAIL_ID +``` + +### Arguments + +| Argument | Description | +|----------|-------------| +| `EMAIL_ID` | ID of the email to read | + +### Output + +Text output shows headers and body: +``` +From: Alice Smith +To: bob@example.com +Date: 2026-02-04 10:30 +Subject: Meeting tomorrow + +Hi Bob, can we meet at 2pm tomorrow? +``` + +JSON output includes the full Email struct. + +### Examples + +```bash +# Read an email by ID +fastmail-cli mail read M12345 + +# Read as JSON +fastmail-cli mail read M12345 --json + +# List then read the first email +EMAIL_ID=$(fastmail-cli mail list -n 1 --json | jq -r '.[0].id') +fastmail-cli mail read "$EMAIL_ID" +``` + +--- + ## mail send Compose and send a new email. diff --git a/pkg/fastmail/mail.go b/pkg/fastmail/mail.go index e21b912..1704bfc 100644 --- a/pkg/fastmail/mail.go +++ b/pkg/fastmail/mail.go @@ -79,7 +79,7 @@ func (s *MailService) List(ctx context.Context, folder string, limit uint64) ([] return convertEmails(getResp.List), nil } -// Get returns a single email by ID. +// Get returns a single email by ID with full content including body text. func (s *MailService) Get(ctx context.Context, id string) (*Email, error) { accountID, err := s.client.getAccountID(ctx) if err != nil { @@ -88,10 +88,14 @@ func (s *MailService) Get(ctx context.Context, id string) (*Email, error) { getBuilder := jmap.NewEmailGet(accountID). IDs(id). - Properties("id", "threadId", "subject", "preview", "receivedAt", "size", "keywords", "mailboxIds") + Properties("id", "threadId", "subject", "preview", "receivedAt", "size", "keywords", "mailboxIds", + "from", "to", "cc", "bodyValues", "textBody") + + args := getBuilder.Build() + args["fetchTextBodyValues"] = true req := jmap.NewRequest().WithCapabilities(jmap.CapCore, jmap.CapMail) - callID := req.Invoke("Email/get", getBuilder.Build()) + callID := req.Invoke("Email/get", args) resp, err := s.client.jmap.Call(ctx, req) if err != nil { @@ -412,11 +416,38 @@ func convertEmails(jmapEmails []jmap.Email) []Email { } } + // Convert address fields + var from EmailAddress + if len(je.From) > 0 { + from = EmailAddress{Name: je.From[0].Name, Email: je.From[0].Email} + } + to := make([]EmailAddress, len(je.To)) + for j, addr := range je.To { + to[j] = EmailAddress{Name: addr.Name, Email: addr.Email} + } + cc := make([]EmailAddress, len(je.Cc)) + for j, addr := range je.Cc { + cc[j] = EmailAddress{Name: addr.Name, Email: addr.Email} + } + + // Extract body text from bodyValues + var body string + if len(je.TextBody) > 0 && len(je.BodyValues) > 0 { + partID := je.TextBody[0].PartID + if bv, ok := je.BodyValues[partID]; ok { + body = bv.Value + } + } + emails[i] = Email{ ID: je.ID, ThreadID: je.ThreadID, Subject: je.Subject, + From: from, + To: to, + Cc: cc, Preview: je.Preview, + Body: body, ReceivedAt: receivedAt, Size: je.Size, Keywords: keywords,