Skip to content

Commit bd43ee3

Browse files
markmnlclaude
andauthored
Fix add-to local delivery resolution using caller's domain instead of the instance's own (#33)
resolveLocalDelivery decided which newly-added recipients were local by parsing the domain out of the requesting identity, not this webapi instance's own domain. That's correct for Send (the owner is always local to their own home server) but wrong for AddRecipients, where any existing participant — including a federated one on a different domain — may add recipients. When a federated participant added a recipient who actually was local to this instance, resolveLocalDelivery treated them as remote and skipped them, while fmsgd's outbound sender also skips local-domain recipients assuming webapi already handled them. Neither side resolved delivery, leaving it stuck pending indefinitely. Adds a required FMSG_DOMAIN env var carrying this instance's own domain, threaded through MessageHandler as LocalDomain and used at both resolveLocalDelivery call sites instead of parsing it from the caller's identity. fmsg-docker's compose files already pass FMSG_DOMAIN to the fmsg-webapi service, so no deployment changes are needed there. Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 74353e1 commit bd43ee3

4 files changed

Lines changed: 23 additions & 8 deletions

File tree

‎.env.example‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
FMSG_DATA_DIR=/var/lib/fmsgd/
2+
FMSG_DOMAIN=example.com
23

34
# Production EdDSA JWT verification (uncomment to use JWKS mode).
45
# FMSG_JWT_AUDIENCE is optional; only set it if your identity provider

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ HTTP API providing user/client message handling for an fmsg host. Exposes CRUD o
2727
| Variable | Default | Description |
2828
| ------------------- | ------------------------ | ------------------------------------------------------- |
2929
| `FMSG_DATA_DIR` | *(required)* | Path where message data files are stored, e.g. `/var/lib/fmsgd/` |
30+
| `FMSG_DOMAIN` | *(required)* | The fmsg domain this instance serves, e.g. `example.com`. Used to tell local recipients (resolved directly via fmsgid) from federated ones (left to fmsgd), independent of which participant's identity happens to be making the request. |
3031
| `FMSG_JWT_JWKS_URL` | *(prod)* | JWKS endpoint for the configured identity provider (e.g. `https://idp.example.com/.well-known/jwks.json`). When set, the API verifies EdDSA (Ed25519) JWTs. Public keys are fetched and cached, refreshed and looked up by the token's `kid` header. |
3132
| `FMSG_JWT_ISSUER` | *(prod, required with JWKS)* | Expected `iss` claim value (e.g. `https://idp.example.com/`). Tokens with a different issuer are rejected. This must exactly match the token issuer. |
3233
| `FMSG_JWT_AUDIENCE` | *(optional)* | When set, tokens must include this value in their `aud` claim. Leave unset if your identity provider does not issue an `aud` claim. |
@@ -199,6 +200,7 @@ by default; override with `FMSG_API_PORT`.
199200

200201
```bash
201202
export FMSG_DATA_DIR=/opt/fmsg/data
203+
export FMSG_DOMAIN=example.com
202204
export FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json
203205
export FMSG_JWT_ISSUER=https://idp.example.com/
204206
export FMSG_JWT_ADDRESS_CLAIM=sub
@@ -227,6 +229,7 @@ proxying `https://fmsgapi.example.com/` to `http://127.0.0.1:8000/`).
227229

228230
```bash
229231
export FMSG_DATA_DIR=/var/lib/fmsgd/
232+
export FMSG_DOMAIN=example.com
230233
export FMSG_API_TOKEN_ED25519_PRIVATE_KEY=$(openssl rand -base64 32)
231234
export PGHOST=localhost
232235
export PGUSER=fmsg

‎cmd/fmsg-webapi/main.go‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ func main() {
3434

3535
// Required configuration.
3636
dataDir := mustEnv("FMSG_DATA_DIR")
37+
// The domain this instance serves, e.g. "example.com" — used to tell local
38+
// recipients (resolved via fmsgid here) from federated ones (left to fmsgd).
39+
localDomain := mustEnv("FMSG_DOMAIN")
3740

3841
// JWT configuration. EdDSA provider JWTs and first-party Ed25519 API
3942
// tokens can be enabled independently.
@@ -137,7 +140,7 @@ func main() {
137140
// Global rate limiting is handled by nftables at the host level.
138141

139142
// Instantiate handlers.
140-
msgHandler := handlers.NewMessageHandler(database, dataDir, maxDataSize, maxMsgSize, shortTextSize, apiStore, idURL)
143+
msgHandler := handlers.NewMessageHandler(database, dataDir, maxDataSize, maxMsgSize, shortTextSize, apiStore, idURL, localDomain)
141144
attHandler := handlers.NewAttachmentHandler(database, dataDir, maxAttachSize, maxMsgSize)
142145

143146
// Web Push handler: stores subscriptions and delivers VAPID pushes for

‎internal/handlers/messages.go‎

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,18 @@ type MessageHandler struct {
3434
ShortTextSize int
3535
SubAccounts *apiauth.Store
3636
IDURL string
37+
// LocalDomain is the domain this webapi instance serves (e.g. "example.com"),
38+
// used to decide which recipients are local vs. federated. It must NOT be
39+
// derived from the authenticated caller's own address — a federated
40+
// participant (e.g. @alice@other.example acting on a thread hosted here)
41+
// has a different domain than this instance, but that has no bearing on
42+
// which recipients are local to it. See resolveLocalDelivery.
43+
LocalDomain string
3744
}
3845

3946
// NewMessageHandler creates a MessageHandler.
40-
func NewMessageHandler(database *db.DB, dataDir string, maxDataSize, maxMsgSize int64, shortTextSize int, subAccounts *apiauth.Store, idURL string) *MessageHandler {
41-
return &MessageHandler{DB: database, DataDir: dataDir, MaxDataSize: maxDataSize, MaxMsgSize: maxMsgSize, ShortTextSize: shortTextSize, SubAccounts: subAccounts, IDURL: idURL}
47+
func NewMessageHandler(database *db.DB, dataDir string, maxDataSize, maxMsgSize int64, shortTextSize int, subAccounts *apiauth.Store, idURL, localDomain string) *MessageHandler {
48+
return &MessageHandler{DB: database, DataDir: dataDir, MaxDataSize: maxDataSize, MaxMsgSize: maxMsgSize, ShortTextSize: shortTextSize, SubAccounts: subAccounts, IDURL: idURL, LocalDomain: localDomain}
4249
}
4350

4451
// visibleAddrs returns the set of fmsg addresses whose messages the caller
@@ -765,10 +772,9 @@ func (h *MessageHandler) Send(c *gin.Context) {
765772

766773
// fmsgd's outbound sender skips the local domain entirely, so local
767774
// recipients need their delivery status resolved here instead.
768-
_, localDomain := parseAddr(identity)
769-
h.resolveLocalDelivery(ctx, "msg_to", msgID, localDomain, existing.To)
775+
h.resolveLocalDelivery(ctx, "msg_to", msgID, h.LocalDomain, existing.To)
770776
for _, b := range existing.AddTo {
771-
h.resolveLocalDelivery(ctx, "msg_add_to", msgID, localDomain, b.To)
777+
h.resolveLocalDelivery(ctx, "msg_add_to", msgID, h.LocalDomain, b.To)
772778
}
773779

774780
c.JSON(http.StatusOK, gin.H{"id": msgID, "time": now})
@@ -955,9 +961,11 @@ func (h *MessageHandler) AddRecipients(c *gin.Context) {
955961
// fmsgd only delivers add_to batches once the parent message is sent
956962
// (mirroring its own m.time_sent IS NOT NULL gate), and skips the local
957963
// domain entirely — so resolve local recipients here for sent messages.
964+
// Note: this must use this instance's own local domain, not the domain of
965+
// whoever called this endpoint — the caller adding recipients may be a
966+
// federated participant on a different domain than the recipients they add.
958967
if timeSent != nil {
959-
_, localDomain := parseAddr(identity)
960-
h.resolveLocalDelivery(ctx, "msg_add_to", msgID, localDomain, input.AddTo)
968+
h.resolveLocalDelivery(ctx, "msg_add_to", msgID, h.LocalDomain, input.AddTo)
961969
}
962970

963971
c.JSON(http.StatusOK, gin.H{"id": msgID, "added": len(input.AddTo)})

0 commit comments

Comments
 (0)