Skip to content

feat: unarchive message thread on new inbound message (per-phone setting)#954

Merged
AchoArnold merged 16 commits into
mainfrom
feature/unarchive-thread-on-receive
Jul 18, 2026
Merged

feat: unarchive message thread on new inbound message (per-phone setting)#954
AchoArnold merged 16 commits into
mainfrom
feature/unarchive-thread-on-receive

Conversation

@AchoArnold

@AchoArnold AchoArnold commented Jul 18, 2026

Copy link
Copy Markdown
Member

Summary

Adds a per-phone Unarchive Thread setting (JSON unarchive_thread). When enabled, an archived message thread is automatically moved back to the inbox when a new inbound message is received for that phone. Disabled by default.

Scope

API + web (no Android changes).

How it works (data flow)

  • Phone.UnarchiveThread entity field (gorm:"default:false").
  • MessageThreadService.UpdateThread uses the pure helper shouldCheckUnarchive (thread archived AND status=received) to gate a lazy phone load: only when an inbound message lands on an already-archived thread does it read the phone via PhoneRepository.Load and check UnarchiveThread. The common path (thread not archived) does zero extra DB reads.
  • On phone-load error it warns and leaves the thread archived (never fails the receive).
  • Phone upsert PUT /v1/phones accepts unarchive_thread via the existing partial-update pattern (applied only when present).

Web

  • updatePhone store action sends unarchive_thread.
  • Settings page: VSwitch bound to activePhone.unarchive_thread.
  • Regenerated TS API models from Swagger.

Testing

  • Unit test TestShouldCheckUnarchive (all branches of the predicate).
  • E2E integration tests TestUnarchiveThreadOnReceive_Enabled / _Disabled in tests/ (raw HTTP against the live Docker stack).
  • go build ./... clean; go test -vet=off ./... passes; web pnpm lint clean.

🤖 Generated with Copilot CLI

@codacy-production

codacy-production Bot commented Jul 18, 2026

Copy link
Copy Markdown

Not up to standards ⛔

🔴 Issues 9 minor

Alerts:
⚠ 9 issues (≤ 0 issues of at least minor severity)

Results:
9 new issues

Category Results
CodeStyle 9 minor

View in Codacy

🟢 Metrics 19 complexity · 12 duplication

Metric Results
Complexity 19
Duplication 12

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@AchoArnold
AchoArnold force-pushed the feature/unarchive-thread-on-receive branch from c600660 to 9495f26 Compare July 18, 2026 07:41

Copilot AI 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.

Pull request overview

Adds a per-phone unarchive_thread setting that automatically unarchives an archived message thread when a new inbound/received message arrives for that phone. This flows from the Phone entity → received-message event payload → thread listener → thread update service, and is exposed in the web settings UI with tests covering the behavior.

Changes:

  • API: Introduce Phone.UnarchiveThread, propagate it on the message.phone.received event, and unarchive threads during MessageThreadService.UpdateThread for inbound received messages.
  • Web: Send/persist unarchive_thread from the phones store and expose a settings toggle; regenerate TS API models from Swagger.
  • Tests/Docs: Add E2E integration tests and update test coverage docs; add design/plan documents.

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
web/shared/types/api.ts Regenerated API types to include unarchive_thread on phone entities and requests.
web/app/stores/phones.ts Sends unarchive_thread in the phone upsert/update request payload.
web/app/pages/settings/index.vue Adds a per-phone switch to toggle auto-unarchive-on-receive.
tests/unarchive_thread_integration_test.go New black-box integration tests validating enabled/disabled unarchive behavior.
tests/README.md Documents the new E2E test coverage item.
docs/superpowers/specs/2026-07-18-webhook-email-payload-formatting-design.md Adds a design doc (webhook email payload formatting).
docs/superpowers/specs/2026-07-18-unarchive-thread-on-receive-design.md Design doc for the unarchive-on-receive feature.
docs/superpowers/plans/2026-07-18-unarchive-thread-on-receive.md Implementation plan for the feature.
api/pkg/services/phone_service.go Applies UnarchiveThread during phone upsert when provided.
api/pkg/services/message_thread_service.go Adds unarchive decision helper and unarchives threads on inbound received updates.
api/pkg/services/message_thread_service_test.go Unit test coverage for the shouldUnarchive helper.
api/pkg/services/message_service.go Loads phone setting during receive and forwards it into the received-event payload.
api/pkg/requests/phone_update_request.go Accepts unarchive_thread and maps it into upsert params using the partial-update presence check.
api/pkg/listeners/message_thread_listener.go Forwards payload.UnarchiveThread into thread update params for received messages.
api/pkg/events/message_phone_received_event.go Extends received-message event payload with unarchive_thread.
api/pkg/entities/phone.go Adds UnarchiveThread to the Phone entity with default false.
api/docs/swagger.yaml Regenerated OpenAPI spec including the new field.
api/docs/swagger.json Regenerated OpenAPI JSON including the new field.
api/docs/docs.go Regenerated embedded Swagger template including the new field.
Files not reviewed (1)
  • api/docs/docs.go: Generated file

Comment on lines +8 to +11
"io"
"net/http"
"testing"
"time"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in e1c3d1ffetchThreads now wraps owner with url.QueryEscape, so the E.164 + is no longer decoded to a space in the query string.

func fetchThreads(ctx context.Context, t *testing.T, owner string, archived bool) []integrationThread {
t.Helper()

url := fmt.Sprintf("%s/v1/message-threads?owner=%s&is_archived=%t&limit=20", apiBaseURL, owner, archived)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed in e1c3d1f — same fix: owner is now passed through url.QueryEscape when building the message-threads query.

Comment thread api/pkg/services/message_service.go Outdated
Comment on lines 30 to +34
MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"e.g. This phone cannot receive calls. Please send an SMS instead."`

// UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.
UnarchiveThread bool `json:"unarchive_thread" example:"false"`

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not changing this — the stated goal (keep the field optional in the OpenAPI schema) is not achievable in this repo. Swagger is generated with --requiredByDefault, which marks every field required regardless of pointer type. missed_call_auto_reply is a *string and is already listed under required for requests.PhoneUpsert, so switching unarchive_thread to *bool would not remove it from required. The non-pointer bool is consistent with the other partial-update fields (max_send_attempts, messages_per_minute), and the server never enforces required at runtime — absent keys are skipped via the fields presence check, so partial updates keep working.

Comment on lines +1 to +6
# Webhook Email Payload Formatting

- Date: 2026-07-18
- Status: Approved (design)
- Scope: `api/` email rendering only.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fixed — the docs: design webhook payload formatting commit was an unrelated stray carried over from another feature branch. Dropped it from this branch (the doc still lives on feature/webhook-email-payload-formatting), so this PR now contains only the unarchive-thread changes.

@greptile-apps

greptile-apps Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a per-phone unarchive_thread setting (default false): when enabled, any inbound message automatically moves an archived thread back to the inbox. The flag is wired end-to-end — entity field, event payload, listener, thread service — with unit and E2E integration tests.

  • Backend: Phone.UnarchiveThread (GORM default:false) is snapshotted onto the MessagePhoneReceivedPayload at receive time; MessageThreadService.UpdateThread calls the pure shouldUnarchive helper and calls thread.UpdateArchive(false) (in-place mutation) before the single repository.Update, so both the archive clear and the last-message update are persisted atomically.
  • Frontend: updatePhone store action always includes unarchive_thread in the PUT body; a VSwitch bound to activePhone.unarchive_thread in the settings page lets users toggle the feature per phone.
  • API contract: unarchive_thread is added to the Swagger required array for requests.PhoneUpsert (generated by --requiredByDefault for non-pointer bool fields), but the server correctly handles absent values through the existing fields map partial-update pattern.

Confidence Score: 4/5

Safe to merge — the core unarchive logic is correct and well-tested, with no data-loss or corruption risk.

The data flow is sound: UpdateArchive and Update both mutate the thread pointer in-place, so both the archive-clear and the last-message fields are saved in the single repository.Update call. The two early-return guards in UpdateThread are each gated on HasLastMessage(params.MessageID), which is false for any genuinely new message, so the unarchive path is always reached for real inbound messages. The only issues found are a new unconditional DB query per inbound message (even when the feature is disabled) and a test helper that quietly overwrites the phone's SIM as a side effect.

api/pkg/services/message_service.go (extra phone load on every receive path) and tests/unarchive_thread_integration_test.go (setUnarchiveThread helper's SIM side-effect).

Important Files Changed

Filename Overview
api/pkg/services/message_service.go Adds an extra phone DB lookup per inbound message to resolve UnarchiveThread; correctly guards against phone-load failure with a warn+default-false fallback. The err variable is safely scoped within the if block before subsequent re-assignment.
api/pkg/services/message_thread_service.go Clean implementation: shouldUnarchive is a pure helper, UpdateArchive mutates the thread pointer in-place before the single repository.Update, and both early-return guards are gated on HasLastMessage(params.MessageID) so new messages always reach the unarchive check.
api/pkg/requests/phone_update_request.go Follows the existing non-pointer + fields-map partial-update pattern; UnarchiveThread bool is correctly mapped only when the key is present in the raw JSON body. Marked as required in Swagger (consistent with other non-pointer fields) but server never enforces it at runtime.
tests/unarchive_thread_integration_test.go Comprehensive enabled/disabled E2E tests with proper polling helpers; setUnarchiveThread hardcodes "sim": "SIM1" which silently overwrites the phone's SIM on every call since sim is not a partial-update field.
web/app/stores/phones.ts Correctly includes unarchive_thread in the PUT body; the ?? false fallback is unnecessary (TypeScript type guarantees boolean) but harmless.
api/pkg/entities/phone.go UnarchiveThread bool added with correct gorm:"default:false" tag; GORM auto-migration handles the new column for existing rows.
api/pkg/listeners/message_thread_listener.go UnarchiveThread propagated only in OnMessagePhoneReceived (inbound), consistent with design intent that only received messages can unarchive.
web/app/pages/settings/index.vue VSwitch correctly bound to activePhone.unarchive_thread with persistent-hint; omitting hide-details (present in plan but absent in code) is the right call since persistent-hint requires the hint area to be visible.
api/pkg/services/message_thread_service_test.go All four decision branches of shouldUnarchive are covered (archived+inbound+flag-on, flag-off, outbound, not-archived); works correctly since shouldUnarchive doesn't rely on any service state.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Android as Android Phone
    participant API as MessageService.ReceiveMessage
    participant DB_Phone as Phone Repository
    participant Queue as Event Dispatcher
    participant Listener as MessageThreadListener
    participant ThreadSvc as MessageThreadService.UpdateThread
    participant DB_Thread as Thread Repository

    Android->>API: POST /v1/messages/receive
    API->>DB_Phone: Load(userID, ownerE164)
    Note over API,DB_Phone: Read UnarchiveThread flag
    DB_Phone-->>API: phone.UnarchiveThread
    API->>Queue: "Dispatch(MessagePhoneReceived{UnarchiveThread})"
    API-->>Android: 200 OK

    Queue->>Listener: OnMessagePhoneReceived
    Listener->>ThreadSvc: "UpdateThread(params{UnarchiveThread, Status=received})"
    ThreadSvc->>DB_Thread: LoadByOwnerContact
    DB_Thread-->>ThreadSvc: "thread{IsArchived}"
    Note over ThreadSvc: shouldUnarchive check
    ThreadSvc->>ThreadSvc: thread.UpdateArchive(false) [if needed]
    ThreadSvc->>DB_Thread: Update(thread)
    DB_Thread-->>ThreadSvc: saved
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Android as Android Phone
    participant API as MessageService.ReceiveMessage
    participant DB_Phone as Phone Repository
    participant Queue as Event Dispatcher
    participant Listener as MessageThreadListener
    participant ThreadSvc as MessageThreadService.UpdateThread
    participant DB_Thread as Thread Repository

    Android->>API: POST /v1/messages/receive
    API->>DB_Phone: Load(userID, ownerE164)
    Note over API,DB_Phone: Read UnarchiveThread flag
    DB_Phone-->>API: phone.UnarchiveThread
    API->>Queue: "Dispatch(MessagePhoneReceived{UnarchiveThread})"
    API-->>Android: 200 OK

    Queue->>Listener: OnMessagePhoneReceived
    Listener->>ThreadSvc: "UpdateThread(params{UnarchiveThread, Status=received})"
    ThreadSvc->>DB_Thread: LoadByOwnerContact
    DB_Thread-->>ThreadSvc: "thread{IsArchived}"
    Note over ThreadSvc: shouldUnarchive check
    ThreadSvc->>ThreadSvc: thread.UpdateArchive(false) [if needed]
    ThreadSvc->>DB_Thread: Update(thread)
    DB_Thread-->>ThreadSvc: saved
Loading

Comments Outside Diff (1)

  1. tests/unarchive_thread_integration_test.go, line 1553-1557 (link)

    P2 setUnarchiveThread silently overwrites the phone's SIM

    The helper sends "sim": "SIM1" in the PUT body, and sim is not a partial-update field — phone_service.go unconditionally applies phone.SIM = params.SIM. If setupPhone ever creates a test phone with a different SIM slot, calling setUnarchiveThread will silently change it to "SIM1". The helper name implies it only touches unarchive_thread, so the side-effect is not obvious to future test authors. Consider fetching the phone's current SIM first, or accepting it as a parameter.

Reviews (1): Last reviewed commit: "feat: fix english" | Re-trigger Greptile

Comment thread api/pkg/services/message_service.go Outdated
Comment on lines +349 to +357
owner := phonenumbers.Format(&params.Owner, phonenumbers.E164)

unarchiveThread := false
phone, err := service.phoneService.Load(ctx, params.UserID, owner)
if err != nil {
ctxLogger.Warn(stacktrace.Propagate(err, fmt.Sprintf("cannot load phone [%s] for user [%s] to resolve UnarchiveThread; defaulting to false", owner, params.UserID)))
} else {
unarchiveThread = phone.UnarchiveThread
}

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.

P2 Unconditional phone load on every inbound message

phoneService.Load is now called for every received message, regardless of whether the thread is archived or whether the user has enabled unarchive_thread. For users with the feature disabled (the default), this adds an extra round-trip to the database on every inbound message with no effect. The UnarchiveThread flag is false by default, so the vast majority of messages will read the phone only to find false.

A lighter alternative would be to carry the feature only when the thread is actually archived — but that would require restructuring the event-driven flow. A simpler mitigation is to read the flag lazily inside UpdateThread when thread.IsArchived is already known to be true, rather than snapshotting it upfront in ReceiveMessage.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This extra load is inherent to the chosen design: the flag is snapshotted onto the received-message event so the unarchive decision can be made without giving MessageThreadService a phone/repository dependency. Reading it lazily inside UpdateThread would reintroduce exactly that coupling, which was intentionally avoided. It is a single indexed lookup by (user, owner) on an already-hot receive path. Flagging for the maintainer to decide if the trade-off should change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Addressed in f355239. The phone is no longer loaded on every inbound message. The UnarchiveThread flag was removed from the event payload; instead MessageThreadService.UpdateThread lazily loads the phone only when an inbound message lands on an already-archived thread (the rare path), so the common case does zero extra reads.

AchoArnold and others added 13 commits July 18, 2026 10:56
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5
- URL-escape the E.164 owner in the message-threads query (+ decodes to space)
- Use native stacktrace.Propagate printf args (avoids go vet non-constant format warning)
- Document why setUnarchiveThread must send the required sim field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5
@AchoArnold
AchoArnold force-pushed the feature/unarchive-thread-on-receive branch from 443feaf to e1c3d1f Compare July 18, 2026 07:58
Consult the phone's UnarchiveThread setting inside
MessageThreadService.UpdateThread only when an inbound message lands on
an archived thread, instead of loading the phone on every received
message. Removes the per-message phone read in ReceiveMessage and drops
the UnarchiveThread field from the received-message event payload.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5

Copilot AI 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.

Pull request overview

Copilot reviewed 18 out of 20 changed files in this pull request and generated 3 comments.

Files not reviewed (1)
  • api/docs/docs.go: Generated file

Comment on lines +106 to +110
if service.shouldCheckUnarchive(thread, params) {
phone, phoneErr := service.phoneRepository.Load(ctx, params.UserID, params.Owner)
if phoneErr != nil {
ctxLogger.Warn(stacktrace.Propagate(phoneErr, "cannot load phone [%s] for user [%s] to resolve UnarchiveThread; leaving thread [%s] archived", params.Owner, params.UserID, thread.ID))
} else if phone.UnarchiveThread {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Updated the PR description to match the implemented flow. The event-based approach was intentionally replaced with a lazy phone load in UpdateThread (gated by shouldCheckUnarchive) so the common non-archived path does zero extra DB reads; the phone is only read when an inbound message hits an already-archived thread.

Comment on lines +19 to +22
assert.True(t, service.shouldCheckUnarchive(archived, received), "archived + inbound -> consult phone setting")
assert.False(t, service.shouldCheckUnarchive(archived, sent), "outbound status -> no check")
assert.False(t, service.shouldCheckUnarchive(notArchived, received), "already unarchived -> no check")
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added in a4fc0af. TestShouldCheckUnarchive now covers not-archived + non-received.

Comment on lines +1 to +7
# Webhook Email Payload Formatting Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Render the webhook failure email's Event Payload as readable indented JSON with safe lightweight syntax colors in HTML and readable indentation in plain text.

**Architecture:** Add a focused formatter in `api/pkg/emails` that produces a plain string and a safely constructed `template.HTML` code block. Teach the custom Hermes HTML dictionary template to use `hermes.Entry.UnsafeValue` when explicitly supplied, then wire only the webhook email's Event Payload entry to that path.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Removed in a4fc0af. The webhook email payload formatting plan doc doesn't belong on this branch.

AchoArnold and others added 2 commits July 18, 2026 11:23
The phone-to-API-key association runs asynchronously via the
phone_api_key event listener, so a freshly provisioned phone can return
401 on receive until the auth cache clears. Retry receiveInbound on that
transient 401 instead of failing immediately.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5
Remove an unrelated webhook email payload formatting plan doc that was
accidentally included in this feature branch, and add the missing
shouldCheckUnarchive case for a non-archived thread with a non-received
status so every branch of the predicate is exercised.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5
@AchoArnold
AchoArnold merged commit 8010ce3 into main Jul 18, 2026
9 of 11 checks passed
AchoArnold added a commit that referenced this pull request Jul 18, 2026
Integrate current origin/main (thread archive-view preservation #952,
unarchive-on-receive #954, webhook email payload formatting #955) with the
read-receipts feature set, preserving both.

Semantic resolutions:
- UpdateThread now resolves the per-phone unarchive decision and the inbound
  unread watermark in a single atomic repository.UpdateActivity call. Added an
  Unarchive flag to MessageThreadActivityUpdate so is_archived=false is applied
  in the same transaction as the read-state/last-message updates.
- Missed calls and inbound SMS both use MessageStatusReceived + MarksUnread, so
  they share the same unarchive + mark-unread rules.
- Combined the message thread service test suites (read-receipt stubs plus
  shouldCheckUnarchive coverage) and updated the service/handler test helpers to
  the 5-arg NewMessageThreadService signature (adds phoneRepository).
- Merged tests/README.md coverage entries for both E2E suites.
- Regenerated Swagger and web/shared/types/api.ts so both main's new fields and
  read-receipt fields are present.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: bf00a0ac-e11f-4015-b295-3cdd9b491229
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants