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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ the application.
| `PUT` | `/fmsg/:id` | Update a draft message |
| `DELETE` | `/fmsg/:id` | Delete a draft message |
| `POST` | `/fmsg/:id/send` | Send a message |
| `POST` | `/fmsg/:id/read` | Mark a message as read |
| `POST` | `/fmsg/:id/add-to` | Add recipients |
| `GET` | `/fmsg/:id/data` | Download message data |
| `POST` | `/fmsg/:id/attach` | Upload an attachment |
Expand Down Expand Up @@ -199,7 +200,7 @@ Returns messages where the authenticated user is a recipient (listed in `msg_to`
| `limit` | `20` | Max messages to return (1–100) |
| `offset` | `0` | Number of messages to skip for pagination |

**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.
**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), including the `read`/`time_read` fields reflecting the caller's per-recipient read state. Message body data and attachment contents are not included — use the dedicated download endpoints instead.

### GET `/fmsg/sent`

Expand Down Expand Up @@ -272,10 +273,16 @@ Retrieves a single message by ID. The authenticated user must be a participant
"type": "text/plain",
"size": 12,
"short_text": "hello world",
"read": false,
"time_read": null,
"attachments": []
}
```

The `read` and `time_read` fields reflect the calling user's per-recipient
read state (set by `POST /fmsg/:id/read`). For the sender's own messages
they are always `false`/`null` (read state is recipient-scoped).

The `short_text` field is included only when the message `type` is `text/*` and
the stored body is valid UTF-8. When `FMSG_API_SHORT_TEXT_SIZE` is greater than
`0`, it contains up to that many bytes (default 768) of the body, truncated on
Expand Down Expand Up @@ -329,6 +336,20 @@ Marks a draft message as sent by setting `time_sent` to the current timestamp. O
| `404` | Message not found |
| `409` | Message already sent |

### POST `/fmsg/:id/read`

Marks a message as read by the authenticated recipient by setting `time_read`
on the caller's `msg_to` or `msg_add_to` row. Idempotent: re-reading an
already-read message returns the original `time_read` without updating it.

**Response:** `200 OK` with `{"id": <int>, "time_read": <float64>}`.

**Errors:**

| Status | Condition |
| ------ | --------- |
| `404` | Authenticated user is not a recipient of this message (or message does not exist) |

### POST `/fmsg/:id/add-to`

Adds additional recipients to an existing message. The authenticated user must be an existing participant — the sender (`from`) or a primary recipient (listed in `to`).
Expand Down
97 changes: 95 additions & 2 deletions src/handlers/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ type messageListItem struct {
Type string `json:"type"`
Size int `json:"size"`
ShortText string `json:"short_text,omitempty"`
Read bool `json:"read"`
TimeRead *float64 `json:"time_read"`
Attachments []models.Attachment `json:"attachments"`
}

Expand Down Expand Up @@ -189,7 +191,11 @@ func (h *MessageHandler) List(c *gin.Context) {
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, m.filepath
`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, m.filepath,
COALESCE(
(SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = m.id AND mt.addr = $1),
(SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = m.id AND mat.addr = $1)
) AS time_read
FROM msg m
WHERE EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = m.id AND mt.addr = $1)
OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = m.id AND mat.addr = $1)
Expand All @@ -209,12 +215,13 @@ func (h *MessageHandler) List(c *gin.Context) {
for rows.Next() {
var m messageListItem
var dataPath string
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, &dataPath); err != nil {
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, &dataPath, &m.TimeRead); err != nil {
log.Printf("list messages scan: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"})
return
}
m.HasPid = m.PID != nil
m.Read = m.TimeRead != nil
m.ShortText = h.extractShortText(dataPath, m.Type)
messages = append(messages, m)
msgIDs = append(msgIDs, m.ID)
Expand Down Expand Up @@ -536,6 +543,23 @@ func (h *MessageHandler) Get(c *gin.Context) {
// Compute ShortText only after authorization has been confirmed.
msg.ShortText = h.extractShortText(dataPath, msg.Type)

// Populate per-recipient read state for the calling user. The sender
// has no read state of their own.
if msg.From != identity {
var timeRead *float64
err := h.DB.Pool.QueryRow(ctx,
`SELECT COALESCE(
(SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = $1 AND mt.addr = $2),
(SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = $1 AND mat.addr = $2)
)`,
msgID, identity,
).Scan(&timeRead)
if err == nil {
msg.TimeRead = timeRead
msg.Read = timeRead != nil
}
}

c.JSON(http.StatusOK, msg)
}

Expand Down Expand Up @@ -812,6 +836,75 @@ func (h *MessageHandler) Send(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"id": msgID, "time": now})
}

// MarkRead handles POST /fmsg/:id/read — marks a message as read by the
// calling recipient. Idempotent: re-reading an already-read message returns
// the original time_read without updating it.
func (h *MessageHandler) MarkRead(c *gin.Context) {
identity := middleware.GetIdentity(c)
msgID, ok := parseID(c)
if !ok {
return
}

ctx := c.Request.Context()

// Look up the caller's existing time_read across both recipient tables.
// COALESCE returns NULL if the user is not a recipient at all (in which
// case both subqueries return zero rows); we distinguish that case with
// an EXISTS check.
var existing *float64
var recipient bool
err := h.DB.Pool.QueryRow(ctx,
`SELECT
COALESCE(
(SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = $1 AND mt.addr = $2),
(SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = $1 AND mat.addr = $2)
),
EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = $1 AND mt.addr = $2)
OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = $1 AND mat.addr = $2)`,
msgID, identity,
).Scan(&existing, &recipient)
if err != nil {
log.Printf("mark read %d: lookup: %v", msgID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mark message read"})
return
}
if !recipient {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}
if existing != nil {
// Already read; return the original timestamp unchanged.
c.JSON(http.StatusOK, gin.H{"id": msgID, "time_read": *existing})
return
}

now := float64(time.Now().UnixMicro()) / 1e6
// Update whichever recipient row matches; only one of these will affect
// rows for any given (msg_id, addr) pair given the unique constraint on
// each table.
if _, err = h.DB.Pool.Exec(ctx,
`UPDATE msg_to SET time_read = $1
WHERE msg_id = $2 AND addr = $3 AND time_read IS NULL`,
now, msgID, identity,
); err != nil {
log.Printf("mark read %d: update msg_to: %v", msgID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mark message read"})
return
}
if _, err = h.DB.Pool.Exec(ctx,
`UPDATE msg_add_to SET time_read = $1
WHERE msg_id = $2 AND addr = $3 AND time_read IS NULL`,
now, msgID, identity,
); err != nil {
log.Printf("mark read %d: update msg_add_to: %v", msgID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mark message read"})
return
}

c.JSON(http.StatusOK, gin.H{"id": msgID, "time_read": now})
}

// addToInput is the JSON shape for the add-to request body.
type addToInput struct {
AddTo []string `json:"add_to"`
Expand Down
1 change: 1 addition & 0 deletions src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func main() {
fmsg.PUT("/:id", msgHandler.Update)
fmsg.DELETE("/:id", msgHandler.Delete)
fmsg.POST("/:id/send", msgHandler.Send)
fmsg.POST("/:id/read", msgHandler.MarkRead)
fmsg.POST("/:id/add-to", msgHandler.AddRecipients)
fmsg.GET("/:id/data", msgHandler.DownloadData)

Expand Down
2 changes: 2 additions & 0 deletions src/models/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,7 @@ type Message struct {
Type string `json:"type"`
Size int `json:"size"`
ShortText string `json:"short_text,omitempty"`
Read bool `json:"read"`
TimeRead *float64 `json:"time_read"`
Attachments []Attachment `json:"attachments"`
}
Loading