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,