Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions cli/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
19 changes: 18 additions & 1 deletion cli/mail_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,31 @@ 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)
}
}
}

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)
Expand Down
45 changes: 45 additions & 0 deletions docs/site/cli/mail.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <alice@example.com>
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.
Expand Down
37 changes: 34 additions & 3 deletions pkg/fastmail/mail.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down