Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ go.work.sum
# env file
.env

# Build output
fmsg

# Editor/IDE
# .idea/
# .vscode/
75 changes: 74 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,75 @@
# fmsg-cli
Command Line Interface to fmsg-webapi fronting a fmsgd instance

Command-line interface to [fmsg-webapi](https://github.com/markmnl/fmsg-webapi) fronting a fmsgd instance.

## Requirements

- Go 1.24 or newer

## Build

```sh
go build -o fmsg
```

## Usage

### Authentication

Before using any other command, log in:

```sh
fmsg login
```

You will be prompted for your FMSG address (e.g. `@user@example.com`). 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.

### Configuration

| Variable | Default | Description |
|---------------|--------------------------|---------------------------|
| `FMSG_API_URL` | `http://localhost:4930` | Base URL of the fmsg-webapi |

### Commands

| Command | Description |
|---------|-------------|
| `fmsg login` | Authenticate and store a local token |
| `fmsg list [--limit N] [--offset N]` | List messages for the authenticated user |
| `fmsg get <message-id>` | Retrieve a message by ID |
| `fmsg send <recipient> <file\|text\|->` | Send a message (file path, text, or `-` for stdin) |
| `fmsg del <message-id>` | Delete a message by ID |
| `fmsg attach <message-id> <file>` | Upload a file attachment to a message |
| `fmsg get-attach <message-id> <filename> <output-file>` | Download an attachment |
| `fmsg rm-attach <message-id> <filename>` | Remove an attachment from a message |

### Examples

```sh
# Login
fmsg login

# List messages
fmsg list
fmsg list --limit 10 --offset 20

# Get a specific message
fmsg get 8f3c2c71

# Send a message
fmsg send @recipient@example.com "Hello, world!"
fmsg send @recipient@example.com ./message.txt
echo "Hello via stdin" | fmsg send @recipient@example.com -

# Delete a message
fmsg del 8f3c2c71

# Upload attachment
fmsg attach 8f3c2c71 ./report.pdf

# Download attachment
fmsg get-attach 8f3c2c71 report.pdf ./downloaded-report.pdf

# Remove attachment
fmsg rm-attach 8f3c2c71 report.pdf
```
39 changes: 39 additions & 0 deletions cmd/attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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"
)

var attachCmd = &cobra.Command{
Use: "attach <message-id> <file>",
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)
}

messageID := args[0]
filePath := args[1]

client := api.New(config.GetAPIURL(), creds.Token)
if err := client.UploadAttachment(messageID, filePath); err != nil {
return err
}

fmt.Printf("Attachment uploaded to message %s\n", messageID)
return nil
},
}

func init() {
rootCmd.AddCommand(attachCmd)
}
36 changes: 36 additions & 0 deletions cmd/del.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
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"
)

var delCmd = &cobra.Command{
Use: "del <message-id>",
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)
if err := client.DeleteMessage(args[0]); err != nil {
return err
}

fmt.Printf("Message %s deleted\n", args[0])
return nil
},
}

func init() {
rootCmd.AddCommand(delCmd)
}
50 changes: 50 additions & 0 deletions cmd/get.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
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"
)

var getCmd = &cobra.Command{
Use: "get <message-id>",
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)
msg, err := client.GetMessage(args[0])
if err != nil {
return err
}

to, _ := json.Marshal(msg.To)
fmt.Printf("ID: %s\n", msg.ID)
fmt.Printf("From: %s\n", msg.From)
fmt.Printf("To: %s\n", string(to))
if len(msg.Data) > 0 && string(msg.Data) != "null" {
var pretty interface{}
if err := json.Unmarshal(msg.Data, &pretty); err == nil {
b, _ := json.MarshalIndent(pretty, "", " ")
fmt.Printf("Data:\n%s\n", string(b))
} else {
fmt.Printf("Data: %s\n", string(msg.Data))
}
}
return nil
},
}

func init() {
rootCmd.AddCommand(getCmd)
}
40 changes: 40 additions & 0 deletions cmd/get_attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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"
)

var getAttachCmd = &cobra.Command{
Use: "get-attach <message-id> <filename> <output-file>",
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)
}

messageID := args[0]
filename := args[1]
outputPath := args[2]

client := api.New(config.GetAPIURL(), creds.Token)
if err := client.DownloadAttachment(messageID, filename, outputPath); err != nil {
return err
}

fmt.Printf("Attachment saved to %s\n", outputPath)
return nil
},
}

func init() {
rootCmd.AddCommand(getAttachCmd)
}
53 changes: 53 additions & 0 deletions cmd/list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
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"
)

var (
listLimit int
listOffset int
)

var listCmd = &cobra.Command{
Use: "list",
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)
messages, err := client.ListMessages(listLimit, listOffset)
if err != nil {
return err
}

if len(messages) == 0 {
fmt.Println("No messages.")
return nil
}

for _, msg := range messages {
to, _ := json.Marshal(msg.To)
fmt.Printf("ID: %s From: %s To: %s\n", msg.ID, msg.From, string(to))
}
return nil
},
}

func init() {
listCmd.Flags().IntVar(&listLimit, "limit", 20, "Max number of messages to return (1-100)")
listCmd.Flags().IntVar(&listOffset, "offset", 0, "Number of messages to skip")
rootCmd.AddCommand(listCmd)
}
54 changes: 54 additions & 0 deletions cmd/login.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package cmd

import (
"bufio"
"fmt"
"os"
"strings"

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

var loginCmd = &cobra.Command{
Use: "login",
Short: "Authenticate and store a local token",
Long: `Prompt for your FMSG address, generate a JWT token, and store it locally.

The token is stored in $XDG_CONFIG_HOME/fmsg/auth.json (or ~/.config/fmsg/auth.json)
and is valid for 24 hours.`,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Print("FMSG address (e.g. @user@example.com): ")

reader := bufio.NewReader(os.Stdin)
input, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("reading input: %w", err)
}
user := strings.TrimSpace(input)
if user == "" {
return fmt.Errorf("FMSG address must not be empty")
}

token, exp, err := auth.Generate(user)
if err != nil {
return fmt.Errorf("generating token: %w", err)
}

creds := auth.Credentials{
Token: token,
ExpiresAt: exp,
User: user,
}
if err := auth.Save(creds); err != nil {
return fmt.Errorf("saving credentials: %w", err)
}

fmt.Printf("Logged in as %s (token expires %s)\n", user, exp.Format("2006-01-02T15:04:05Z"))
return nil
},
}

func init() {
rootCmd.AddCommand(loginCmd)
}
Loading