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
6 changes: 3 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
FMSG_API_URL=http://127.0.0.1:8000

# Secret used to sign JWT tokens (must match the server) — REQUIRED
# Plain string or base64-encoded with "base64:" prefix
FMSG_JWT_SECRET=
# Optional opaque API key for non-interactive sub-account use. If set, it
# overrides stored credentials and is exchanged for a short-lived access token.
FMSG_API_KEY=
53 changes: 40 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,21 +16,37 @@ go build -o fmsg

### Authentication

Before using any other command, log in:
Before using any other command, log in with either a main-account user JWT or a sub-account API key:

```sh
fmsg login [address]
fmsg login [api-key|jwt]
```

You can optionally provide the fmsg address directly (e.g. `@user@example.com`) to skip the prompt:
For main-account use, pass a JWT issued by the identity provider configured for your fmsg-webapi deployment:

```sh
fmsg login @user@example.com
fmsg login eyJ...
```

If the provided value contains no `@` symbols (argument or prompted input), it is treated as just the user part and expanded to `@<user>@<domain>` using the configured `FMSG_API_URL` domain.
The JWT must contain the fmsg address claim expected by the server, commonly `fmsg_address`. If the token uses a deployment-specific claim that the CLI cannot recognize, pass the address explicitly:

If no address argument is provided, you will be prompted interactively. A JWT token is generated locally and stored in `$XDG_CONFIG_HOME/fmsg/auth.json` (typically `~/.config/fmsg/auth.json`) with `0600` permissions. The token is valid for 24 hours.
```sh
fmsg login --address @user@example.com eyJ...
```

User JWTs are used directly until their JWT expiry. When they expire, run `fmsg login` again with a fresh JWT.

For sub-account or programmatic use, pass an opaque fmsg API key:

```sh
fmsg login fmsgk_<key_id>_<secret>
```

API keys are exchanged with `POST /fmsg/token` for short-lived first-party JWTs. The CLI caches the returned JWT and refreshes it automatically within five minutes of expiry. The default server token lifetime is 12 hours.

Credentials are stored in `$XDG_CONFIG_HOME/fmsg/auth.json` (typically `~/.config/fmsg/auth.json`) with `0600` permissions.

For non-interactive use, set `FMSG_API_KEY` instead of running `fmsg login`. Environment-provided API keys override stored credentials and are not written to disk.

### Configuration

Expand All @@ -39,24 +55,22 @@ If a `.env` file exists in the working directory it is loaded automatically on s
| Variable | Default | Description |
|---------------|--------------------------|---------------------------|
| `FMSG_API_URL` | `http://127.0.0.1:8000` | Base URL of the fmsg-webapi |
| `FMSG_JWT_SECRET` | *(required)* | Secret used to sign JWT tokens (must match the server) |
| `FMSG_API_KEY` | *(optional)* | Opaque API key used for non-interactive sub-account authentication |

`FMSG_JWT_SECRET` formats:
- Plain string (used as-is): `FMSG_JWT_SECRET=super-secret`
- Base64 with `base64:` prefix (decoded to raw bytes): `FMSG_JWT_SECRET=base64:c3VwZXItc2VjcmV0`
Programmatic clients use API keys issued by fmsg-webapi.

### Commands

| Command | Description |
|---------|-------------|
| `fmsg login [address]` | Authenticate and store a local token (optional address argument) |
| `fmsg login [api-key\|jwt] [--address @user@example.com]` | Authenticate and store credentials |
| `fmsg list` \| `fmsg ls [--limit N] [--offset N]` | List messages for the authenticated user |
| `fmsg sent [--limit N] [--offset N]` | List messages authored by the authenticated user |
| `fmsg get <message-id>` | Retrieve a message by ID, including the short text body for `text/*` messages |
| `fmsg send <recipient> <file\|text\|->` | Send a message (file path, text, or `-` for stdin) |
| `fmsg draft create <recipient> <file\|text\|->` | Create a draft message without sending |
| `fmsg draft send <message-id>` | Send a previously created draft |
| `fmsg update <message-id> [file\|text\|->` | Update a draft message |
| `fmsg update <message-id> [file\|text\|-]` | Update a draft message |
| `fmsg del <message-id>` | Delete a draft message by ID |
| `fmsg add-to <message-id> <recipient> [recipient...]` | Add additional recipients to a message |
| `fmsg attach <message-id> <file>` | Upload a file attachment to a message |
Expand All @@ -74,12 +88,25 @@ recent message without knowing its ID. The index is resolved against your inbox
| `-2` | Second most recent |
| `-N` | N-th most recent |

Message creation commands support these optional flags:

| Command | Flags |
|---------|-------|
| `fmsg send` | `--pid, -p`, `--topic`, `--important`, `--no-reply` |
| `fmsg draft create` | `--pid, -p`, `--topic`, `--important`, `--no-reply` |
| `fmsg update` | `--to`, `--topic`, `--type`, `--pid, -p`, `--important`, `--no-reply` |

### Examples

```sh
# Login
fmsg login
fmsg login @user@example.com
fmsg login eyJ...
fmsg login --address @user@example.com eyJ...
fmsg login fmsgk_<key_id>_<secret>

# Non-interactive sub-account auth
FMSG_API_KEY=fmsgk_<key_id>_<secret> fmsg list

# List messages
fmsg list
Expand Down
12 changes: 1 addition & 11 deletions cmd/add_to.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ package cmd

import (
"fmt"
"os"

"github.com/markmnl/fmsg-cli/internal/api"
"github.com/markmnl/fmsg-cli/internal/auth"
"github.com/markmnl/fmsg-cli/internal/config"
"github.com/spf13/cobra"
)

Expand All @@ -15,13 +11,7 @@ var addToCmd = &cobra.Command{
Short: "Add additional recipients to an existing message",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := auth.LoadValid()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

client := api.New(config.GetAPIURL(), creds.Token)
client, _ := newAuthenticatedClient()
msgID, err := resolveMessageID(client, args[0])
if err != nil {
return err
Expand Down
12 changes: 1 addition & 11 deletions cmd/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@ package cmd

import (
"fmt"
"os"
"strconv"

"github.com/markmnl/fmsg-cli/internal/api"
"github.com/markmnl/fmsg-cli/internal/auth"
"github.com/markmnl/fmsg-cli/internal/config"
"github.com/spf13/cobra"
)

Expand All @@ -16,13 +12,7 @@ var attachCmd = &cobra.Command{
Short: "Upload a file as an attachment to a message",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := auth.LoadValid()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

client := api.New(config.GetAPIURL(), creds.Token)
client, _ := newAuthenticatedClient()
resolvedID, err := resolveMessageID(client, args[0])
if err != nil {
return err
Expand Down
13 changes: 13 additions & 0 deletions cmd/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package cmd

import (
"github.com/markmnl/fmsg-cli/internal/api"
"github.com/markmnl/fmsg-cli/internal/auth"
"github.com/markmnl/fmsg-cli/internal/config"
)

func newAuthenticatedClient() (*api.Client, *auth.Manager) {
apiURL := config.GetAPIURL()
manager := auth.NewManager(apiURL)
return api.New(apiURL, manager), manager
}
12 changes: 1 addition & 11 deletions cmd/del.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ package cmd

import (
"fmt"
"os"

"github.com/markmnl/fmsg-cli/internal/api"
"github.com/markmnl/fmsg-cli/internal/auth"
"github.com/markmnl/fmsg-cli/internal/config"
"github.com/spf13/cobra"
)

Expand All @@ -15,13 +11,7 @@ var delCmd = &cobra.Command{
Short: "Delete a message by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := auth.LoadValid()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

client := api.New(config.GetAPIURL(), creds.Token)
client, _ := newAuthenticatedClient()
msgID, err := resolveMessageID(client, args[0])
if err != nil {
return err
Expand Down
22 changes: 6 additions & 16 deletions cmd/draft.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import (
"os"
"time"

"github.com/markmnl/fmsg-cli/internal/api"
"github.com/markmnl/fmsg-cli/internal/auth"
"github.com/markmnl/fmsg-cli/internal/config"
"github.com/spf13/cobra"
)

Expand All @@ -31,13 +28,13 @@ var draftCreateCmd = &cobra.Command{
Long: `Create a draft message for a recipient. The second argument can be:
- A path to a file (must exist on disk)
- A text string
- "-" to read from stdin`,
- "-" to read from stdin`,
Comment on lines 28 to +31
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := auth.LoadValid()
client, manager := newAuthenticatedClient()
user, err := manager.User(cmd.Context())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
return err
}

recipient := args[0]
Expand All @@ -59,7 +56,7 @@ var draftCreateCmd = &cobra.Command{
}

msg := map[string]interface{}{
"from": creds.User,
"from": user,
"to": []string{recipient},
"version": 1,
"type": "text/plain",
Expand All @@ -84,7 +81,6 @@ var draftCreateCmd = &cobra.Command{
return fmt.Errorf("encoding message: %w", err)
}

client := api.New(config.GetAPIURL(), creds.Token)
draft, err := client.CreateMessage(payload)
if err != nil {
return fmt.Errorf("creating draft: %w", err)
Expand All @@ -101,13 +97,7 @@ var draftSendCmd = &cobra.Command{
Short: "Send a previously created draft",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := auth.LoadValid()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

client := api.New(config.GetAPIURL(), creds.Token)
client, _ := newAuthenticatedClient()
msgID, err := resolveMessageID(client, args[0])
if err != nil {
return err
Expand Down
12 changes: 1 addition & 11 deletions cmd/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@ package cmd
import (
"encoding/json"
"fmt"
"os"
"strconv"

"github.com/markmnl/fmsg-cli/internal/api"
"github.com/markmnl/fmsg-cli/internal/auth"
"github.com/markmnl/fmsg-cli/internal/config"
"github.com/spf13/cobra"
)

Expand All @@ -17,13 +13,7 @@ var getCmd = &cobra.Command{
Short: "Retrieve a message by ID",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := auth.LoadValid()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

client := api.New(config.GetAPIURL(), creds.Token)
client, _ := newAuthenticatedClient()
msgID, err := resolveMessageID(client, args[0])
if err != nil {
return err
Expand Down
12 changes: 1 addition & 11 deletions cmd/get_attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,8 @@ package cmd

import (
"fmt"
"os"
"strconv"

"github.com/markmnl/fmsg-cli/internal/api"
"github.com/markmnl/fmsg-cli/internal/auth"
"github.com/markmnl/fmsg-cli/internal/config"
"github.com/spf13/cobra"
)

Expand All @@ -16,13 +12,7 @@ var getAttachCmd = &cobra.Command{
Short: "Download an attachment from a message",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := auth.LoadValid()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

client := api.New(config.GetAPIURL(), creds.Token)
client, _ := newAuthenticatedClient()
resolvedID, err := resolveMessageID(client, args[0])
if err != nil {
return err
Expand Down
11 changes: 1 addition & 10 deletions cmd/get_data.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,6 @@ import (
"os"
"strconv"

"github.com/markmnl/fmsg-cli/internal/api"
"github.com/markmnl/fmsg-cli/internal/auth"
"github.com/markmnl/fmsg-cli/internal/config"
"github.com/spf13/cobra"
)

Expand All @@ -16,15 +13,9 @@ var getDataCmd = &cobra.Command{
Short: "Download the body data of a message",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := auth.LoadValid()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

messageID := args[0]

client := api.New(config.GetAPIURL(), creds.Token)
client, _ := newAuthenticatedClient()
resolvedID, err := resolveMessageID(client, messageID)
if err != nil {
return err
Expand Down
12 changes: 1 addition & 11 deletions cmd/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,7 @@ package cmd
import (
"encoding/json"
"fmt"
"os"

"github.com/markmnl/fmsg-cli/internal/api"
"github.com/markmnl/fmsg-cli/internal/auth"
"github.com/markmnl/fmsg-cli/internal/config"
"github.com/spf13/cobra"
)

Expand All @@ -21,13 +17,7 @@ var listCmd = &cobra.Command{
Aliases: []string{"ls"},
Short: "List messages for the authenticated user",
RunE: func(cmd *cobra.Command, args []string) error {
creds, err := auth.LoadValid()
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

client := api.New(config.GetAPIURL(), creds.Token)
client, _ := newAuthenticatedClient()
messages, err := client.ListMessages(listLimit, listOffset)
if err != nil {
return err
Expand Down
Loading