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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ server responds with `429 Too Many Requests`:
| Method | Path | Description |
| -------- | ------------------------------------------- | ------------------------ |
| `GET` | `/fmsg` | List messages for user |
| `GET` | `/fmsg/sent` | List authored messages (sent + drafts) |
| `GET` | `/fmsg/wait` | Long-poll for new messages |
| `POST` | `/fmsg` | Create a draft message |
| `GET` | `/fmsg/:id` | Retrieve a message |
Expand Down Expand Up @@ -162,6 +163,21 @@ Returns messages where the authenticated user is a recipient (listed in `msg_to`

**Response:** JSON array of message objects. Each object has the same shape as the single-message response from `GET /fmsg/:id` (with an additional `id` field). Message body data and attachment contents are not included — use the dedicated download endpoints instead.

### GET `/fmsg/sent`

Returns messages authored by the authenticated user (`msg.from_addr = <identity>`), ordered by message ID descending.

This includes both sent messages and drafts (`time_sent` may be `NULL`).

**Query parameters:**

| Parameter | Default | Description |
| --------- | ------- | ----------- |
| `limit` | `20` | Max messages to return (1–100) |
| `offset` | `0` | Number of messages to skip for pagination |

**Response:** JSON array of message objects with the same shape as `GET /fmsg`.

### POST `/fmsg`

Creates a draft message. The `from` address must match the authenticated user. The message is stored with `time_sent = NULL` (draft status) until explicitly sent.
Expand Down
168 changes: 145 additions & 23 deletions src/handlers/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,29 +177,9 @@ func (h *MessageHandler) latestMessageIDForRecipient(ctx context.Context, identi
func (h *MessageHandler) List(c *gin.Context) {
identity := middleware.GetIdentity(c)

// Parse limit query parameter (default 20, max 100).
limit := 20
if l := c.Query("limit"); l != "" {
parsed, err := strconv.Atoi(l)
if err != nil || parsed < 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
return
}
if parsed > 100 {
parsed = 100
}
limit = parsed
}

// Parse offset query parameter (default 0).
offset := 0
if o := c.Query("offset"); o != "" {
parsed, err := strconv.Atoi(o)
if err != nil || parsed < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"})
return
}
offset = parsed
limit, offset, ok := parseLimitOffset(c)
if !ok {
return
}

ctx := c.Request.Context()
Expand Down Expand Up @@ -303,6 +283,120 @@ func (h *MessageHandler) List(c *gin.Context) {
c.JSON(http.StatusOK, messages)
}

// Sent handles GET /fmsg/sent — lists messages authored by the authenticated user.
// Includes both sent messages and drafts (time_sent may be NULL).
func (h *MessageHandler) Sent(c *gin.Context) {
identity := middleware.GetIdentity(c)
if identity == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
return
}

limit, offset, ok := parseLimitOffset(c)
if !ok {
return
}

ctx := c.Request.Context()

rows, err := h.DB.Pool.Query(ctx,
`SELECT m.id, m.version, m.pid, m.no_reply, m.is_important, m.is_deflate, m.time_sent, m.from_addr, m.topic, m.type, m.size
FROM msg m
WHERE m.from_addr = $1
ORDER BY m.id DESC
LIMIT $2 OFFSET $3`,
identity, limit, offset,
)
if err != nil {
log.Printf("list sent messages: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list sent messages"})
return
}
defer rows.Close()

var messages []messageListItem
var msgIDs []int64
for rows.Next() {
var m messageListItem
if err := rows.Scan(&m.ID, &m.Version, &m.PID, &m.NoReply, &m.Important, &m.Deflate, &m.Time, &m.From, &m.Topic, &m.Type, &m.Size); err != nil {
log.Printf("list sent messages scan: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list sent messages"})
return
}
m.HasPid = m.PID != nil
messages = append(messages, m)
msgIDs = append(msgIDs, m.ID)
}

if len(messages) == 0 {
c.JSON(http.StatusOK, []messageListItem{})
return
}

// Batch-load recipients.
toRows, err := h.DB.Pool.Query(ctx,
"SELECT msg_id, addr FROM msg_to WHERE msg_id = ANY($1)",
msgIDs,
)
if err == nil {
toMap := make(map[int64][]string)
for toRows.Next() {
var id int64
var addr string
if scanErr := toRows.Scan(&id, &addr); scanErr == nil {
toMap[id] = append(toMap[id], addr)
}
}
toRows.Close()
for i := range messages {
messages[i].To = toMap[messages[i].ID]
}
}

// Batch-load add_to recipients.
addToRows, err := h.DB.Pool.Query(ctx,
"SELECT msg_id, addr FROM msg_add_to WHERE msg_id = ANY($1)",
msgIDs,
)
if err == nil {
addToMap := make(map[int64][]string)
for addToRows.Next() {
var id int64
var addr string
if scanErr := addToRows.Scan(&id, &addr); scanErr == nil {
addToMap[id] = append(addToMap[id], addr)
}
}
addToRows.Close()
for i := range messages {
messages[i].AddTo = addToMap[messages[i].ID]
messages[i].HasAddTo = len(messages[i].AddTo) > 0
}
}

// Batch-load attachments.
attRows, err := h.DB.Pool.Query(ctx,
"SELECT msg_id, filename, filesize FROM msg_attachment WHERE msg_id = ANY($1)",
msgIDs,
)
if err == nil {
attMap := make(map[int64][]models.Attachment)
for attRows.Next() {
var id int64
var a models.Attachment
if scanErr := attRows.Scan(&id, &a.Filename, &a.Size); scanErr == nil {
attMap[id] = append(attMap[id], a)
}
}
attRows.Close()
for i := range messages {
messages[i].Attachments = attMap[messages[i].ID]
}
}

c.JSON(http.StatusOK, messages)
}

// Create handles POST /api/v1/messages — creates a draft message.
func (h *MessageHandler) Create(c *gin.Context) {
identity := middleware.GetIdentity(c)
Expand Down Expand Up @@ -884,6 +978,34 @@ func parseID(c *gin.Context) (int64, bool) {
return id, true
}

// parseLimitOffset parses and validates list pagination query parameters.
func parseLimitOffset(c *gin.Context) (int, int, bool) {
limit := 20
if l := c.Query("limit"); l != "" {
parsed, err := strconv.Atoi(l)
if err != nil || parsed < 1 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid limit"})
return 0, 0, false
}
if parsed > 100 {
parsed = 100
}
limit = parsed
}

offset := 0
if o := c.Query("offset"); o != "" {
parsed, err := strconv.Atoi(o)
if err != nil || parsed < 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid offset"})
return 0, 0, false
}
offset = parsed
}

return limit, offset, true
}

// isRecipient checks whether addr appears in the to list (case-insensitive).
func isRecipient(to []string, addr string) bool {
for _, a := range to {
Expand Down
1 change: 1 addition & 0 deletions src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ func main() {
{
fmsg.GET("/wait", msgHandler.Wait)
fmsg.GET("", msgHandler.List)
fmsg.GET("/sent", msgHandler.Sent)
fmsg.POST("", msgHandler.Create)
fmsg.GET("/:id", msgHandler.Get)
fmsg.PUT("/:id", msgHandler.Update)
Expand Down
Loading