Skip to content

feat: opt-in found item contact page (resolves #13) - #1659

Open
JonGaydos wants to merge 13 commits into
sysadminsmedia:mainfrom
JonGaydos:feat/found-item-contact
Open

JonGaydos wants to merge 13 commits into
sysadminsmedia:mainfrom
JonGaydos:feat/found-item-contact

Conversation

@JonGaydos

Copy link
Copy Markdown

Summary

Adds the "lost item" page requested in #13: when someone scans a QR
label while signed out, they now see a public "Did you find this
item?" contact page instead of the login screen.

The feature is opt-in per collection and off by default, and it
addresses the privacy concern raised on #1487 (unconditional exposure
of the owner's email on an unauthenticated endpoint):

  • With SMTP configured, finders send a message through a contact form
    relayed server-side. The owner's email address is never sent to the
    client.
  • Without SMTP, the page falls back to a mailto link, per the
    guidance in the issue thread. The settings UI warns the owner about
    this exposure before they enable the feature.

Design notes

  • Two new Group fields (found_contact_enabled, default false, and
    found_contact_message) with goose migrations for sqlite and
    postgres. Existing installs see zero behavior change.
  • Public endpoints GET /v1/found/{kind}/{id} and
    POST /v1/found/{kind}/{id}/contact, both behind a per-IP request
    rate limiter (30/min), with an additional per-item send cap
    (3 per item / 10 min) so a single known item cannot be used to
    mailbomb its owner even from distributed IPs. Missing items,
    non-opted-in collections,
    archived items, and ambiguous asset IDs (asset IDs are only unique
    per collection) all return identical 404s, so a caller cannot probe
    an instance's inventory. After input validation the contact POST
    returns 204 whether or not the item resolved (forgot-password
    pattern), and the email send is backgrounded so the response does
    not wait on SMTP. Either endpoint may instead return 429 when the
    rate limiter trips; that response is IP-scoped and identical across
    items, so it does not leak item state.
  • Asset ID 0 is rejected before querying (every entity defaults to
    asset_id 0).
  • The finder's message and reply address are HTML-escaped before
    interpolation into the (HTML-only) mailer body.
  • GroupUpdate gains pointer-optional fields so existing API callers
    that PUT only name/currency cannot silently reset the new settings.
  • Changing the found-contact settings requires the group owner role
    (other collection settings remain member-editable). This prevents a
    non-owner member from publishing the owner's email via mailto mode.
  • Translations are en.json only, per the Weblate workflow. Roughly
    1,000 of the added lines are regenerated API specs
    (swagger/OpenAPI/TS types); the hand-written diff is much smaller
    than the total suggests.

Out of scope (future work per the issue thread)

Per-item "mark as lost" mode and reward fields, as floated by
@katosdev in the thread. Archiving an item already removes it from
the found page, which covers the per-item off switch. The per-item
lost mode layers cleanly on top of this change if wanted.

Notes for reviewers

  • Location entities share the entities table and carry asset IDs, so
    scanning a location label on an opted-in group resolves the found
    page ("Someone found your item: "). Left as-is since
    returning a mislaid storage bin is arguably valid, but flagging it
    in case you'd prefer to exclude is_location entities from the
    lookups.
  • The finder's optional reply address is included in the email body
    rather than as a real Reply-To header. Adding a true Reply-To
    would mean extending the shared mailer MessageBuilder (used by
    password-reset mail too), which is beyond this feature's footprint,
    so I deliberately kept the change self-contained. Happy to add it as
    a follow-up if you'd like the header.

Test plan

  • Repo layer: 9 tests covering lookups by item UUID and asset ID,
    disabled/archived/ambiguous/no-owner cases (all fail closed), and
    owner-resolution determinism.
  • Service layer: email builder tests including escaping of
    finder-controlled fields.
  • Handler layer: table-driven tests covering parse rejection,
    validation boundaries, opaque-404 shape, and always-204 behavior.
  • Rate limiter: per-item send-cap enforcement and per-item isolation.
  • Group update: pointer-preservation round-trip test.
  • Backend suite, go vet, and frontend lint pass (the only failing
    backend tests are pre-existing, environment-specific attachment/blob
    tests unrelated to this change).

Manually verified end to end against a copy of a real production
v0.26.2 database (goose migrations applied cleanly on startup):

  • Scanning a label while signed out redirects to the found page;
    signing in from there returns to the item.
  • mailto mode (no SMTP) shows the owner email; form mode (SMTP
    configured) shows the contact form and never exposes the email.
  • A real message submitted through the form was delivered via SMTP
    (Gmail) with the finder's text and HTML-escaping intact.
  • The send cap was confirmed live: repeated submissions for one item
    delivered 3 emails and then silently stopped, while the page
    continued to show the success state (no throttling signal to the
    sender). The cap's per-item isolation (each item having an
    independent budget) is covered by an automated unit test rather than
    the manual pass.
  • The settings toggle/message round-trips and is owner-gated.

Disclosure

This PR was developed with AI assistance (Claude), including repeated
AI review passes over the implementation. Every part of it was then
tested by a human on a real deployment to confirm it actually works
end to end, including live SMTP delivery. Happy to adjust anything in
review.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ca940813-1204-427e-8f0c-fd7bcd312d5a

📥 Commits

Reviewing files that changed from the base of the PR and between 617f1ae and 4e8b03a.

📒 Files selected for processing (3)
  • backend/app/api/handlers/v1/v1_ctrl_found.go
  • backend/app/api/handlers/v1/v1_ctrl_found_test.go
  • frontend/pages/found/[kind]/[id].vue
🚧 Files skipped from review as they are similar to previous changes (2)
  • frontend/pages/found/[kind]/[id].vue
  • backend/app/api/handlers/v1/v1_ctrl_found_test.go

Summary by CodeRabbit

  • New Features

    • Added public found-item pages for viewing details and contacting owners.
    • Added direct email links or in-page contact forms with optional reply-to addresses.
    • Added collection settings to enable contact and configure messages.
    • Added redirects from protected item and asset links to public found pages.
  • Security & Reliability

    • Added rate limits, input validation, privacy protections, and safe message handling.
    • Restricted contact settings to collection owners.

Walkthrough

Adds opt-in found-item contact settings, public item and asset lookup, rate-limited contact submission, asynchronous owner email relay, and frontend configuration and contact pages.

Changes

Found-item contact

Layer / File(s) Summary
Found-contact persistence
backend/internal/data/migrations/..., backend/internal/data/repo/...
Adds found-contact fields, conditional group updates, item and asset lookup methods, owner selection, and repository tests.
Backend lookup and contact flow
backend/app/api/..., backend/internal/core/services/...
Adds public GET and POST routes, opaque 404 handling, payload validation, per-IP and per-item rate limits, ownership checks, asynchronous email delivery, HTML escaping, and tests.
Frontend contracts and settings
frontend/lib/api/..., frontend/locales/en.json, frontend/pages/collection/index/settings.vue
Adds API contracts, public API methods, translations, and collection controls for enabling and configuring found-item contact.
Public found-item page
frontend/middleware/auth.ts, frontend/pages/found/[kind]/[id].vue
Redirects unauthenticated item and asset paths to found pages and adds lookup, mailto, contact-form, and submission states.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

Suggested labels: ⬆️ enhancement, review needed, go

Sequence Diagram(s)

sequenceDiagram
  participant Finder
  participant FoundPage
  participant PublicAPI
  participant GroupRepository
  participant FoundService
  Finder->>FoundPage: Open found item or asset URL
  FoundPage->>PublicAPI: Request found-item details
  PublicAPI->>GroupRepository: Resolve contact
  GroupRepository-->>PublicAPI: Return FoundContact
  PublicAPI-->>FoundPage: Return form or mailto response
  Finder->>FoundPage: Submit message
  FoundPage->>PublicAPI: POST contact request
  PublicAPI->>FoundService: Queue owner email
  FoundService-->>PublicAPI: Record delivery result
  PublicAPI-->>FoundPage: Return 204 response
Loading

Poem

A found item opens a guarded page,
With limits set at every stage.
Escaped messages cross the mail stream,
While opaque errors guard the scheme.
Settings, forms, and redirects align.
The owner’s address stays confined.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 40.74% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main feature: an opt-in found-item contact page.
Description check ✅ Passed The description explains the feature, design, issue context, testing, scope, and reviewer notes in sufficient detail.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
✨ Simplify code
  • Create PR with simplified code

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot added go Pull requests that update Go code review needed A review is needed on this PR or Issue ⬆️ enhancement New feature or request labels Aug 1, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/internal/core/services/service_found.go`:
- Around line 57-68: Update buildFoundContactEmail to use html.EscapeString for
itemName, message, and replyTo before interpolating them into the HTML body, and
remove the redundant package-private escaping helper from
service_user_password_reset.go if it is only used for this purpose.

In `@frontend/pages/collection/index/settings.vue`:
- Around line 217-251: Gate the found-contact controls in the selectedCollection
settings block using the current user’s membership role for that group: only
owners may interact with the foundContactEnabled switch, foundContactMessage
field, and saveFoundContact button. For non-owners, disable or make the fields
read-only and replace or supplement the controls with a clear owner-only
message, while preserving the existing owner behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f93e453-3caf-4337-89de-d2702da27014

📥 Commits

Reviewing files that changed from the base of the PR and between 9ba5649 and 617f1ae.

⛔ Files ignored due to path filters (18)
  • backend/app/api/static/docs/docs.go is excluded by !backend/app/api/static/docs/**
  • backend/app/api/static/docs/openapi-3.json is excluded by !backend/app/api/static/docs/**
  • backend/app/api/static/docs/openapi-3.yaml is excluded by !backend/app/api/static/docs/**
  • backend/app/api/static/docs/swagger.json is excluded by !backend/app/api/static/docs/**
  • backend/app/api/static/docs/swagger.yaml is excluded by !backend/app/api/static/docs/**
  • backend/internal/data/ent/group.go is excluded by !backend/internal/data/ent/**
  • backend/internal/data/ent/group/group.go is excluded by !backend/internal/data/ent/**
  • backend/internal/data/ent/group/where.go is excluded by !backend/internal/data/ent/**
  • backend/internal/data/ent/group_create.go is excluded by !backend/internal/data/ent/**
  • backend/internal/data/ent/group_update.go is excluded by !backend/internal/data/ent/**
  • backend/internal/data/ent/migrate/schema.go is excluded by !backend/internal/data/ent/**
  • backend/internal/data/ent/mutation.go is excluded by !backend/internal/data/ent/**
  • backend/internal/data/ent/runtime.go is excluded by !backend/internal/data/ent/**
  • backend/internal/data/ent/schema/group.go is excluded by !backend/internal/data/ent/**
  • docs/public/api/openapi-3.0.json is excluded by !docs/public/api/**
  • docs/public/api/openapi-3.0.yaml is excluded by !docs/public/api/**
  • docs/public/api/swagger-2.0.json is excluded by !docs/public/api/**
  • docs/public/api/swagger-2.0.yaml is excluded by !docs/public/api/**
📒 Files selected for processing (22)
  • backend/app/api/app.go
  • backend/app/api/handlers/v1/controller.go
  • backend/app/api/handlers/v1/v1_ctrl_found.go
  • backend/app/api/handlers/v1/v1_ctrl_found_test.go
  • backend/app/api/handlers/v1/v1_ctrl_group.go
  • backend/app/api/middleware.go
  • backend/app/api/middleware_ratelimit_test.go
  • backend/app/api/routes.go
  • backend/internal/core/services/all.go
  • backend/internal/core/services/service_found.go
  • backend/internal/core/services/service_found_test.go
  • backend/internal/data/migrations/postgres/20260730120001_add_found_contact_fields.sql
  • backend/internal/data/migrations/sqlite3/20260730120000_add_found_contact_fields.sql
  • backend/internal/data/repo/repo_group.go
  • backend/internal/data/repo/repo_group_found_test.go
  • backend/internal/data/repo/repo_group_test.go
  • frontend/lib/api/public.ts
  • frontend/lib/api/types/data-contracts.ts
  • frontend/locales/en.json
  • frontend/middleware/auth.ts
  • frontend/pages/collection/index/settings.vue
  • frontend/pages/found/[kind]/[id].vue

Comment on lines +57 to +68
body = fmt.Sprintf(
`<pre style="white-space: pre-wrap; font-family: inherit;">Someone scanned the label on your item "%s" and sent you a message through Homebox:

%s
</pre>`,
htmlEscape(itemName), htmlEscape(message),
)
if replyTo != "" {
body += fmt.Sprintf(`<p>Reply to: %s</p>`, htmlEscape(replyTo))
}
return subject, body
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Locate the current htmlEscape definition to confirm behavior before replacing it.
rg -n -B2 -A10 'func htmlEscape' backend/internal/core/services/

Repository: sysadminsmedia/homebox

Length of output: 1235


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the imports and calls to htmlEscape in service_found.go.
wc -l backend/internal/core/services/service_found.go
sed -n '1,90p' backend/internal/core/services/service_found.go

Repository: sysadminsmedia/homebox

Length of output: 2742


🌐 Web query:

Go html package html.EscapeString documentation

💡 Result:

The html.EscapeString function is part of the Go standard library's html package. It is designed to escape special characters in a string to make it safe for inclusion in HTML [1]. Function Signature: func EscapeString(s string) string Behavior: EscapeString escapes only five specific characters, converting them into their corresponding HTML entities [1][2]: - < becomes < - > becomes > - & becomes & - ' becomes &#39; - " becomes &#34; Key Characteristics: - Safety: By escaping these five characters, the function helps prevent basic cross-site scripting (XSS) attacks when inserting strings into HTML text content [1][2]. - Inversion: The function is the inverse of html.UnescapeString in the sense that UnescapeString(EscapeString(s)) == s always holds [1][2]. However, the converse (EscapeString(UnescapeString(s)) == s) is not always true, because UnescapeString is capable of decoding a much broader range of HTML entities than EscapeString produces [1][3]. - Performance: The implementation is optimized to return the original string immediately if it contains none of the characters that require escaping [3][4]. Example Usage: package main import ( "fmt" "html") func main { const s = "Fran & Freddie's Diner" <tasty@example.com> fmt.Println(html.EscapeString(s)) } Output: &#34;Fran & Freddie&#39;s Diner&#34; <tasty@example.com>

Citations:


Use html.EscapeString for HTML escaping in the email body.

buildFoundContactEmail HTML-escapes user-controlled values before interpolation, and Go already provides this behavior through html.EscapeString. This keeps the email-path security behavior canonical and avoids maintaining package-private escape logic in service_user_password_reset.go.

🔒️ Suggested diff
+import "html"
+
 func buildFoundContactEmail(itemName, message, replyTo string) (subject, body string) {
 	subject = fmt.Sprintf("Someone found your item: %s", itemName)

 	body = fmt.Sprintf(
 		`<pre style="white-space: pre-wrap; font-family: inherit;">Someone scanned the label on your item "%s" and sent you a message through Homebox:

 %s
 </pre>`,
-		htmlEscape(itemName), htmlEscape(message),
+		html.EscapeString(itemName), html.EscapeString(message),
 	)
 	if replyTo != "" {
-		body += fmt.Sprintf(`<p>Reply to: %s</p>`, htmlEscape(replyTo))
+		body += fmt.Sprintf(`<p>Reply to: %s</p>`, html.EscapeString(replyTo))
+	}
+	return subject, body
+}
+
+func htmlEscape(s string) string {
+	r := strings.NewReplacer(
+		"&", "&amp;",
+		"<", "&lt;",
+		">", "&gt;",
+		`"`, "&quot;",
+		"'", "&`#39`;",
+	)
+	return r.Replace(s)
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/internal/core/services/service_found.go` around lines 57 - 68, Update
buildFoundContactEmail to use html.EscapeString for itemName, message, and
replyTo before interpolating them into the HTML body, and remove the redundant
package-private escaping helper from service_user_password_reset.go if it is
only used for this purpose.

Comment on lines +217 to +251

<div v-if="selectedCollection" class="mt-4 space-y-4 rounded-md border bg-card p-4">
<div>
<h2 class="text-lg font-medium">{{ $t("found.settings.title") }}</h2>
<p class="text-sm text-muted-foreground">{{ $t("found.settings.description") }}</p>
</div>

<div class="flex items-center gap-2">
<Switch id="found-contact-enabled" v-model="foundContactEnabled" />
<Label for="found-contact-enabled">{{ $t("found.settings.enable") }}</Label>
</div>

<FormTextArea
v-model="foundContactMessage"
:label="$t('found.settings.message_label')"
:placeholder="$t('found.settings.message_placeholder')"
:max-length="500"
/>

<div class="rounded-md border border-accent-foreground bg-accent p-4 text-accent-foreground">
<p class="text-sm">{{ $t("found.settings.no_smtp_warning") }}</p>
</div>

<div class="mt-4">
<Button
variant="secondary"
size="sm"
:disabled="savingFoundContact || utf8Length(foundContactMessage) > 500"
@click="saveFoundContact"
>
<MdiLoading v-if="savingFoundContact" class="mr-2 inline-block animate-spin" />
<span>{{ $t("global.save") }}</span>
</Button>
</div>
</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Look for existing ownership/role indicators available to the frontend.
rg -n -C3 'isOwner|ownerId|role' frontend/lib/api frontend/composables frontend/stores 2>/dev/null
rg -n -C3 'IsOwnerOf|isOwner' backend/app/api/handlers/v1 backend/internal/data/repo

Repository: sysadminsmedia/homebox

Length of output: 7075


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== candidate files =="
git ls-files | rg '(^frontend/pages/collection/index/settings.vue$|^frontend|backend/app/api/handlers/v1/v1_ctrl_group.go$|^backend/internal/data/repo/repo_group.go$)' | sed -n '1,80p'

echo
echo "== frontend settings outline =="
ast-grep outline frontend/pages/collection/index/settings.vue --view compact 2>/dev/null | sed -n '1,120p' || true

echo
echo "== frontend settings relevant lines =="
sed -n '1,320p' frontend/pages/collection/index/settings.vue

echo
echo "== api get group usage =="
rg -n -C4 'group\.get|\.Groups\.Get\(|usergroupRole|role:|role:' frontend --glob '*.{ts,vue}' | sed -n '1,220p'

echo
echo "== group update handling =="
sed -n '70,115p' backend/app/api/handlers/v1/v1_ctrl_group.go

echo
echo "== repo group owner implementation =="
sed -n '460,488p' backend/internal/data/repo/repo_group.go

echo
echo "== data-contracts membership type =="
sed -n '660,700p' frontend/lib/api/types/data-contracts.ts

Repository: sysadminsmedia/homebox

Length of output: 22226


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== all imports/usages of getMembers in settings or collection root =="
python3 - <<'PY'
from pathlib import Path
for p in [Path("frontend/pages/collection/index/settings.vue"), Path("frontend/pages/collection/index.vue")]:
    print(p)
    text = p.read_text(errors="replace")
    for i, line in enumerate(text.splitlines(), 1):
        if "getMembers" in line or "members" in line or "/members" in line or "selectedCollection" in line or "user_groups" in line or "owner" in line.lower():
            print(f"{i}: {line}")
    print()
PY

echo
echo "== group Get membership response backend =="
rg -n -C5 'func \(ctrl \*V1Controller\) HandleGroupGet|\.Group\.Get\(|GetGroup|getMembers|GetMembers|user_groups|UserGroups|UserGroup' backend/app/api/handlers/v1/backend/internal/data backend/internal/data/repo/repo_group.go backend/internal/data/repo/repo_user.go backend/internal/data/repo/repo_user_group.go 2>/dev/null || true

echo
echo "== generated group type fields =="
rg -n -C4 'export interface Group|user_groups|userGroups|IsOwner|owner' frontend/lib/api/types/data-contracts.ts backend/internal/data/repo/repo_group.go 2>/dev/null || true

Repository: sysadminsmedia/homebox

Length of output: 19109


Block found-contact edits for non-owner group members.

Found-contact field writes require role=owner via HandleGroupUpdate and return 403 for others. Gate the enable switch, message field, and save button with the current user’s per-group membership role, or show a clear owner-only message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/pages/collection/index/settings.vue` around lines 217 - 251, Gate
the found-contact controls in the selectedCollection settings block using the
current user’s membership role for that group: only owners may interact with the
foundContactEnabled switch, foundContactMessage field, and saveFoundContact
button. For non-owners, disable or make the fields read-only and replace or
supplement the controls with a clear owner-only message, while preserving the
existing owner behavior.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⬆️ enhancement New feature or request go Pull requests that update Go code review needed A review is needed on this PR or Issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant