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
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,29 @@ Downloads the binary body of a message. The authenticated identity must be a par
| `404` | Message not found or data file not available |
| `403` | Authenticated user is not a participant |

### GET `/fmsg/:id/thread`

Returns the message's thread as plain text: the direct ancestor lineage
(each message's `pid` followed to the root) plus the message itself, root
first. Intended for "copy thread" UI actions and for feeding a whole thread
to an agent as context.

The authenticated identity must be a participant of the *requested*
message. Each message is preceded by a one-line separator with its sender
and send time. Ancestors the caller is not a participant of appear as
`[message not visible to you]` (a recipient added mid-thread cannot read
what came before); non-text bodies appear as a `[non-text message: <type>,
<size> bytes]` placeholder. The walk is capped at 100 hops.

**Response:** `200 OK`, `text/plain; charset=utf-8`.

**Errors:**

| Status | Condition |
| ------ | --------- |
| `404` | Message not found |
| `403` | Authenticated user is not a participant of the requested message |

### POST `/fmsg/:id/attach`

Uploads a file attachment for a draft message. Only the owner may upload, and the message must not have been sent.
Expand Down
1 change: 1 addition & 0 deletions cmd/fmsg-webapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ func main() {
fmsg.POST("/:id/read", msgHandler.MarkRead)
fmsg.POST("/:id/add-to", msgHandler.AddRecipients)
fmsg.GET("/:id/data", msgHandler.DownloadData)
fmsg.GET("/:id/thread", msgHandler.ThreadText)

fmsg.POST("/:id/attach", attHandler.Upload)
fmsg.GET("/:id/attach/:filename", attHandler.Download)
Expand Down
161 changes: 161 additions & 0 deletions internal/handlers/thread.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package handlers

import (
"errors"
"fmt"
"log"
"net/http"
"os"
"strings"
"time"

"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
)

// threadMaxHops bounds the ancestor walk (defensive; a pid cycle cannot be
// created through the API but the cap keeps the query finite regardless).
const threadMaxHops = 100

// threadEntry is one message on the direct pid lineage, root first.
type threadEntry struct {
ID int64
From string
TimeSent *float64
Type string
Size int
Body string // empty when unreadable by the caller or non-text
Readable bool // caller is a participant of this message
Text bool // body is a text type that was inlined
}

// renderThreadText renders the lineage as plain text, root at the top. Each
// message gets a one-line separator with sender and send time; non-text
// bodies and messages the caller may not read become placeholders, so the
// chain's shape stays visible without leaking content.
func renderThreadText(entries []threadEntry) string {
var b strings.Builder
for i, e := range entries {
if i > 0 {
b.WriteString("\n\n")
}
ts := "draft"
if e.TimeSent != nil {
ts = time.Unix(int64(*e.TimeSent), 0).UTC().Format(time.RFC3339)
}
fmt.Fprintf(&b, "--- %s %s ---\n", e.From, ts)
switch {
case !e.Readable:
b.WriteString("[message not visible to you]")
case !e.Text:
fmt.Fprintf(&b, "[non-text message: %s, %d bytes]", e.Type, e.Size)
default:
b.WriteString(strings.TrimRight(e.Body, "\n"))
}
}
b.WriteString("\n")
return b.String()
}

// isTextType reports whether a MIME type's body can be inlined as text.
func isTextType(mimeType string) bool {
t := strings.ToLower(strings.TrimSpace(mimeType))
return strings.HasPrefix(t, "text/") || t == "application/json"
}

// ThreadText handles GET /fmsg/:id/thread — returns the message's direct
// ancestor lineage (pid walk to the root) plus the message itself as plain
// text, root first. The caller must be a participant of the requested
// message; ancestors the caller is not a participant of appear as
// placeholders (a recipient added mid-thread cannot read what came before).
func (h *MessageHandler) ThreadText(c *gin.Context) {
addrs, err := h.visibleAddrs(c)
if err != nil {
log.Printf("thread text: resolve visible addrs: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve thread"})
return
}
msgID, ok := parseID(c)
if !ok {
return
}
ctx := c.Request.Context()

rows, err := h.DB.Pool.Query(ctx, `
WITH RECURSIVE chain AS (
SELECT id, pid, from_addr, time_sent, type, size, filepath, 0 AS depth
FROM msg WHERE id = $1
UNION ALL
SELECT m.id, m.pid, m.from_addr, m.time_sent, m.type, m.size, m.filepath, c.depth + 1
FROM msg m JOIN chain c ON m.id = c.pid
WHERE c.depth < $3
)
SELECT c.id, c.from_addr, c.time_sent, c.type, c.size, c.filepath,
(c.from_addr = ANY($2)
OR EXISTS (SELECT 1 FROM msg_to t WHERE t.msg_id = c.id AND t.addr = ANY($2))
OR EXISTS (SELECT 1 FROM msg_add_to a WHERE a.msg_id = c.id AND a.addr = ANY($2))) AS readable
FROM chain c ORDER BY c.depth DESC`,
msgID, addrs, threadMaxHops,
)
if err != nil {
log.Printf("thread text: walk %d: %v", msgID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve thread"})
return
}
defer rows.Close()

var entries []threadEntry
paths := map[int64]string{}
for rows.Next() {
var e threadEntry
var dataPath string
if err := rows.Scan(&e.ID, &e.From, &e.TimeSent, &e.Type, &e.Size, &dataPath, &e.Readable); err != nil {
log.Printf("thread text: scan: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve thread"})
return
}
paths[e.ID] = dataPath
entries = append(entries, e)
}
if err := rows.Err(); err != nil && !errors.Is(err, pgx.ErrNoRows) {
log.Printf("thread text: rows %d: %v", msgID, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve thread"})
return
}
if len(entries) == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "message not found"})
return
}

// Authorization gate is the requested message itself, same as GET
// /fmsg/:id — a non-participant learns nothing, not even chain shape.
if !entries[len(entries)-1].Readable {
c.JSON(http.StatusForbidden, gin.H{"error": "access denied"})
return
}

for i := range entries {
e := &entries[i]
if !e.Readable || !isTextType(e.Type) {
continue
}
dataPath := paths[e.ID]
if dataPath == "" {
continue
}
cleanPath, ok := safeDataPath(dataPath, h.DataDir)
if !ok {
log.Printf("thread text: path traversal attempt: %s", dataPath)
continue
}
raw, rerr := os.ReadFile(cleanPath)
if rerr != nil {
log.Printf("thread text: read %d: %v", e.ID, rerr)
continue
}
e.Body = string(raw)
e.Text = true
}

c.Data(http.StatusOK, "text/plain; charset=utf-8", []byte(renderThreadText(entries)))
}
42 changes: 42 additions & 0 deletions internal/handlers/thread_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package handlers

import (
"strings"
"testing"
)

func TestRenderThreadText(t *testing.T) {
ts := float64(1754350000)
entries := []threadEntry{
{ID: 1, From: "@bob@example.com", TimeSent: &ts, Type: "text/markdown", Body: "root body\n", Readable: true, Text: true},
{ID: 2, From: "@alice@hairpin.local", TimeSent: &ts, Type: "image/png", Size: 123, Readable: true},
{ID: 3, From: "@bob@example.com", TimeSent: &ts, Type: "text/plain", Readable: false},
{ID: 4, From: "@alice@hairpin.local", TimeSent: &ts, Type: "text/plain", Body: "latest reply", Readable: true, Text: true},
}
got := renderThreadText(entries)

for _, want := range []string{
"--- @bob@example.com 2025-08-04T23:26:40Z ---\nroot body",
"[non-text message: image/png, 123 bytes]",
"[message not visible to you]",
"latest reply",
} {
if !strings.Contains(got, want) {
t.Fatalf("missing %q in:\n%s", want, got)
}
}
if strings.Index(got, "root body") > strings.Index(got, "latest reply") {
t.Fatal("root must come first (top to bottom)")
}
}

func TestIsTextType(t *testing.T) {
for typ, want := range map[string]bool{
"text/plain": true, "text/markdown": true, "TEXT/HTML": true,
"application/json": true, "image/png": false, "application/pdf": false, "": false,
} {
if isTextType(typ) != want {
t.Errorf("isTextType(%q) != %v", typ, want)
}
}
}
Loading