From ecca18288ec5c39533d75f9054146c469bcf913d Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Wed, 3 Jun 2026 16:03:39 +0800 Subject: [PATCH 1/2] support add to batches --- AGENTS.md | 5 +++-- README.md | 2 +- src/handlers/messages.go | 26 ++++++++++++++++---------- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ebd8fe8..d9c113e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,8 +23,9 @@ The API routes table and each route's section must reflect the live code in - Schema source of truth: `https://github.com/markmnl/fmsgd/blob/main/dd.sql` - Ensure all SQL in Go source files aligns with that schema. -- When adding recipients via the `add-to` route, update `msg.add_to_from` - in the same transaction as the `msg_add_to` inserts. +- When adding recipients via the `add-to` route, insert one `msg_add_to_batch` + row (`add_to_from` = authenticated identity, plus `time_added`) and insert the + `msg_add_to` rows with that batch's `batch_id`, all in the same transaction. - The WebSocket hub (`/fmsg/ws`) LISTENs on the `new_msg` LISTEN/NOTIFY channel for push delivery. `new_msg` fires once per recipient (payload `,`) whenever a message becomes sent/arrived. Do not rename or remove that trigger diff --git a/README.md b/README.md index eb0fd77..799682b 100644 --- a/README.md +++ b/README.md @@ -365,7 +365,7 @@ already-read message returns the original `time_read` without updating it. 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`). -This endpoint updates the message `add_to_from` field to the authenticated identity in the same transaction as the `msg_add_to` inserts. +This endpoint records the add-to as a new `msg_add_to_batch` row (capturing the authenticated identity as `add_to_from` and a timestamp) and inserts the recipients into `msg_add_to` referencing that batch — all in a single transaction. **Request body (JSON):** diff --git a/src/handlers/messages.go b/src/handlers/messages.go index 231ef9a..3eb3c58 100644 --- a/src/handlers/messages.go +++ b/src/handlers/messages.go @@ -871,10 +871,24 @@ func (h *MessageHandler) AddRecipients(c *gin.Context) { } defer tx.Rollback(ctx) + // Each add-to is recorded as a batch row capturing who added the recipients + // and when, then the recipients are inserted referencing that batch. + now := float64(time.Now().UnixMicro()) / 1e6 + var batchID int64 + if err = tx.QueryRow(ctx, + `INSERT INTO msg_add_to_batch (msg_id, add_to_from, time_added) + VALUES ($1, $2, $3) RETURNING id`, + msgID, identity, now, + ).Scan(&batchID); err != nil { + log.Printf("add recipients: insert batch for msg %d: %v", msgID, err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add recipients"}) + return + } + for _, addr := range input.AddTo { if _, err = tx.Exec(ctx, - "INSERT INTO msg_add_to (msg_id, addr) VALUES ($1, $2) ON CONFLICT DO NOTHING", - msgID, addr, + "INSERT INTO msg_add_to (msg_id, batch_id, addr) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + msgID, batchID, addr, ); err != nil { log.Printf("add recipients: insert %s into msg %d: %v", addr, msgID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add recipients"}) @@ -882,14 +896,6 @@ func (h *MessageHandler) AddRecipients(c *gin.Context) { } } - if _, err = tx.Exec(ctx, - "UPDATE msg SET add_to_from = $1 WHERE id = $2", identity, msgID, - ); err != nil { - log.Printf("add recipients: update add_to_from for msg %d: %v", msgID, err) - c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add recipients"}) - return - } - if err = tx.Commit(ctx); err != nil { log.Printf("add recipients: commit tx for msg %d: %v", msgID, err) c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to add recipients"}) From 55fc68a5e61cc054628ec6b0d7db8a490877b0e8 Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Wed, 3 Jun 2026 16:24:37 +0800 Subject: [PATCH 2/2] updated message struct add to to be batches --- AGENTS.md | 2 +- README.md | 21 +++-- src/handlers/messages.go | 167 ++++++++++++++++++++------------------- src/models/models.go | 11 ++- 4 files changed, 112 insertions(+), 89 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d9c113e..a91d811 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ The API routes table and each route's section must reflect the live code in ## Database -- Schema source of truth: `https://github.com/markmnl/fmsgd/blob/main/dd.sql` +- Schema source of truth: `https://github.com/markmnl/fmsgd/blob/add-to-batches/dd.sql` - Ensure all SQL in Go source files aligns with that schema. - When adding recipients via the `add-to` route, insert one `msg_add_to_batch` row (`add_to_from` = authenticated identity, plus `time_added`) and insert the diff --git a/README.md b/README.md index 799682b..9dd3aa9 100644 --- a/README.md +++ b/README.md @@ -232,7 +232,7 @@ This includes both sent messages and drafts (`time_sent` may be `NULL`). 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. -When `add_to` recipients are provided, `add_to_from` is automatically populated from the authenticated identity. +Add-to recipients are not part of this body — they are added later via `POST /fmsg/:id/add-to`. Any `add_to` field sent here is ignored. **Request body (JSON):** @@ -247,7 +247,6 @@ When `add_to` recipients are provided, `add_to_from` is automatically populated | `size` | `int` | yes | Data size in bytes | | `important` | `bool` | no | Mark message as important | | `no_reply` | `bool` | no | Indicate replies will be discarded | -| `add_to` | `string[]` | no | Additional recipients for add-to semantics | | `data` | `string` | no | Message body content | **Response:** `201 Created` with `{"id": }`. @@ -256,7 +255,7 @@ When `add_to` recipients are provided, `add_to_from` is automatically populated | Status | Condition | | ------ | --------- | -| `400` | Missing/invalid fields, empty `to`, `topic` set together with `pid`, or `add_to`/`add_to_from` set without `pid` | +| `400` | Missing/invalid fields, empty `to`, or `topic` set together with `pid` | | `403` | `from` does not match authenticated user | ### GET `/fmsg/:id` @@ -277,8 +276,13 @@ Retrieves a single message by ID. The authenticated user must be a participant "pid": null, "from": "@alice@example.com", "to": ["@bob@example.com"], - "add_to": [], - "add_to_from": null, + "add_to": [ + { + "add_to_from": "@bob@example.com", + "to": ["@carol@example.com", "@dave@example.com"], + "time": 1717459200.123 + } + ], "time": null, "topic": "Hello", "type": "text/plain", @@ -290,6 +294,11 @@ Retrieves a single message by ID. The authenticated user must be a participant } ``` +`add_to` is an array of add-to batches, one per `POST /fmsg/:id/add-to` call. +Each batch records who added the recipients (`add_to_from`), the recipients +added (`to`), and when the add-to happened (`time`, seconds since the Unix +epoch). `has_add_to` is `true` when the array is non-empty. + 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). @@ -316,7 +325,7 @@ Updates a draft message. Only the owner (`from`) may update, and the message mus | Status | Condition | | ------ | --------- | -| `400` | Invalid fields, `topic` set together with `pid`, or `add_to`/`add_to_from` set without `pid` | +| `400` | Invalid fields, or `topic` set together with `pid` | | `403` | Not the owner, or message already sent | | `404` | Message not found | diff --git a/src/handlers/messages.go b/src/handlers/messages.go index 3eb3c58..f76143d 100644 --- a/src/handlers/messages.go +++ b/src/handlers/messages.go @@ -51,7 +51,7 @@ type messageListItem struct { PID *int64 `json:"pid"` From string `json:"from"` To []string `json:"to"` - AddTo []string `json:"add_to"` + AddTo []models.AddToBatch `json:"add_to"` Time *float64 `json:"time"` Topic string `json:"topic"` Type string `json:"type"` @@ -63,9 +63,14 @@ type messageListItem struct { } // messageInput is used for JSON binding on Create/Update — includes Data for the message body. +// The outer AddTo field shadows models.Message.AddTo (same JSON name, shallower +// depth wins), capturing any add_to in the body into an ignored value of any +// shape. Recipients are only added via POST /fmsg/:id/add-to, never on +// create/update, so add_to here is intentionally discarded. type messageInput struct { models.Message - Data string `json:"data"` + AddTo any `json:"add_to"` + Data string `json:"data"` } // List handles GET /fmsg — lists messages where the authenticated user is a recipient. @@ -141,25 +146,11 @@ func (h *MessageHandler) List(c *gin.Context) { } } - // 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 add_to batches. + addToMap := h.loadAddToBatches(ctx, msgIDs) + for i := range messages { + messages[i].AddTo = addToMap[messages[i].ID] + messages[i].HasAddTo = len(messages[i].AddTo) > 0 } // Batch-load attachments. @@ -257,25 +248,11 @@ func (h *MessageHandler) Sent(c *gin.Context) { } } - // 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 add_to batches. + addToMap := h.loadAddToBatches(ctx, msgIDs) + for i := range messages { + messages[i].AddTo = addToMap[messages[i].ID] + messages[i].HasAddTo = len(messages[i].AddTo) > 0 } // Batch-load attachments. @@ -322,12 +299,12 @@ func (h *MessageHandler) Create(c *gin.Context) { return } - if err := validateAddresses(msg.From, msg.To, msg.AddTo, msg.AddToFrom); err != nil { + if err := validateAddresses(msg.From, msg.To); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := validatePidRelations(msg.PID, msg.Topic, msg.AddTo, msg.AddToFrom); err != nil { + if err := validatePidRelations(msg.PID, msg.Topic); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -424,7 +401,7 @@ func (h *MessageHandler) Get(c *gin.Context) { } // Authorization: owner or recipient (to or add_to). - if msg.From != identity && !isRecipient(msg.To, identity) && !isRecipient(msg.AddTo, identity) { + if msg.From != identity && !isRecipient(msg.To, identity) && !addToContains(msg.AddTo, identity) { c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) return } @@ -548,12 +525,12 @@ func (h *MessageHandler) Update(c *gin.Context) { return } - if err := validateAddresses(msg.From, msg.To, msg.AddTo, msg.AddToFrom); err != nil { + if err := validateAddresses(msg.From, msg.To); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - if err := validatePidRelations(msg.PID, msg.Topic, msg.AddTo, msg.AddToFrom); err != nil { + if err := validatePidRelations(msg.PID, msg.Topic); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } @@ -905,6 +882,51 @@ func (h *MessageHandler) AddRecipients(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"id": msgID, "added": len(input.AddTo)}) } +// loadAddToBatches loads the add-to batches for the given messages, keyed by +// msg_id. Each batch carries who added the recipients (add_to_from), when +// (time_added), and the recipients themselves. A LEFT JOIN is used so a batch +// whose addresses were all already present still records the provenance of the +// add-to. Batches are ordered by their insertion order (batch id). +func (h *MessageHandler) loadAddToBatches(ctx context.Context, msgIDs []int64) map[int64][]models.AddToBatch { + result := make(map[int64][]models.AddToBatch) + rows, err := h.DB.Pool.Query(ctx, + `SELECT b.msg_id, b.id, b.add_to_from, b.time_added, mat.addr + FROM msg_add_to_batch b + LEFT JOIN msg_add_to mat ON mat.batch_id = b.id + WHERE b.msg_id = ANY($1) + ORDER BY b.id, mat.id`, + msgIDs, + ) + if err != nil { + log.Printf("load add_to batches: %v", err) + return result + } + defer rows.Close() + + // Track the slice index of each batch so addresses append to the right one. + idx := make(map[int64]int) + for rows.Next() { + var msgID, batchID int64 + var addToFrom string + var timeAdded float64 + var addr *string + if err := rows.Scan(&msgID, &batchID, &addToFrom, &timeAdded, &addr); err != nil { + log.Printf("load add_to batches scan: %v", err) + continue + } + i, ok := idx[batchID] + if !ok { + result[msgID] = append(result[msgID], models.AddToBatch{AddToFrom: addToFrom, Time: timeAdded}) + i = len(result[msgID]) - 1 + idx[batchID] = i + } + if addr != nil { + result[msgID][i].To = append(result[msgID][i].To, *addr) + } + } + return result +} + // fetchMessage loads a message with its recipients and attachments from the DB. // It also returns the raw filepath stored in the database so callers can use it // after performing their own authorization checks. @@ -937,17 +959,8 @@ func (h *MessageHandler) fetchMessage(ctx context.Context, msgID int64) (*models rows.Close() } - // Load add_to recipients. - addToRows, err := h.DB.Pool.Query(ctx, "SELECT addr FROM msg_add_to WHERE msg_id = $1", msgID) - if err == nil { - for addToRows.Next() { - var addr string - if scanErr := addToRows.Scan(&addr); scanErr == nil { - msg.AddTo = append(msg.AddTo, addr) - } - } - addToRows.Close() - } + // Load add_to batches. + msg.AddTo = h.loadAddToBatches(ctx, []int64{msgID})[msgID] msg.HasAddTo = len(msg.AddTo) > 0 // Load attachments. @@ -1117,6 +1130,17 @@ func isRecipient(to []string, addr string) bool { return false } +// addToContains reports whether addr is a recipient in any add-to batch +// (case-insensitive). +func addToContains(batches []models.AddToBatch, addr string) bool { + for _, b := range batches { + if isRecipient(b.To, addr) { + return true + } + } + return false +} + // isZip reports whether data starts with the zip local file header signature. func isZip(data []byte) bool { return len(data) >= 4 && data[0] == 0x50 && data[1] == 0x4b && data[2] == 0x03 && data[3] == 0x04 @@ -1207,9 +1231,10 @@ func (h *MessageHandler) extractShortText(dataPath, mimeType string) string { return string(buf) } -// validateAddresses returns an error if any of the provided fmsg address -// fields is not a valid "@user@domain" address. addToFrom is optional. -func validateAddresses(from string, to, addTo []string, addToFrom *string) error { +// validateAddresses returns an error if the from address or any to address is +// not a valid "@user@domain" address. (add_to recipients are validated by the +// add-to route, not on create/update.) +func validateAddresses(from string, to []string) error { if !middleware.IsValidAddr(from) { return fmt.Errorf("invalid from address: %q", from) } @@ -1218,33 +1243,15 @@ func validateAddresses(from string, to, addTo []string, addToFrom *string) error return fmt.Errorf("invalid to address: %q", addr) } } - for _, addr := range addTo { - if !middleware.IsValidAddr(addr) { - return fmt.Errorf("invalid add_to address: %q", addr) - } - } - if addToFrom != nil && *addToFrom != "" && !middleware.IsValidAddr(*addToFrom) { - return fmt.Errorf("invalid add_to_from address: %q", *addToFrom) - } return nil } -// validatePidRelations enforces: -// - If pid is set, topic must be empty (replies inherit topic from parent). -// - If pid is not set, add_to and add_to_from must be empty (a thread -// must exist before recipients can be added to it). -func validatePidRelations(pid *int64, topic string, addTo []string, addToFrom *string) error { +// validatePidRelations enforces that if pid is set, topic must be empty +// (replies inherit their topic from the parent). +func validatePidRelations(pid *int64, topic string) error { if pid != nil && topic != "" { return fmt.Errorf("topic must be empty when pid is supplied") } - if pid == nil { - if len(addTo) > 0 { - return fmt.Errorf("add_to is only valid when pid is supplied") - } - if addToFrom != nil && *addToFrom != "" { - return fmt.Errorf("add_to_from is only valid when pid is supplied") - } - } return nil } diff --git a/src/models/models.go b/src/models/models.go index 0d93d19..e6e1298 100644 --- a/src/models/models.go +++ b/src/models/models.go @@ -6,6 +6,14 @@ type Attachment struct { Filename string `json:"filename"` } +// AddToBatch represents a single add-to delivery: the recipients added in one +// POST /fmsg/:id/add-to call, who added them (add_to_from), and when (time). +type AddToBatch struct { + AddToFrom string `json:"add_to_from"` + To []string `json:"to"` + Time float64 `json:"time"` +} + // Message represents a fmsg message as exchanged over the HTTP API. type Message struct { Version int `json:"version"` @@ -17,8 +25,7 @@ type Message struct { PID *int64 `json:"pid"` From string `json:"from"` To []string `json:"to"` - AddTo []string `json:"add_to"` - AddToFrom *string `json:"add_to_from"` + AddTo []AddToBatch `json:"add_to"` Time *float64 `json:"time"` Topic string `json:"topic"` Type string `json:"type"`