From 91eac3b412b5139e20d65f50c03ab375119b6b1d Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 09:56:43 +0300 Subject: [PATCH 01/16] feat(api): add UnarchiveThread setting to Phone entity --- api/pkg/entities/phone.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/api/pkg/entities/phone.go b/api/pkg/entities/phone.go index 97df66317..4f3c33f28 100644 --- a/api/pkg/entities/phone.go +++ b/api/pkg/entities/phone.go @@ -24,6 +24,9 @@ type Phone struct { MissedCallAutoReply *string `json:"missed_call_auto_reply" example:"This phone cannot receive calls. Please send an SMS instead." validate:"optional"` + // UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone. + UnarchiveThread bool `json:"unarchive_thread" gorm:"default:false" example:"false"` + CreatedAt time.Time `json:"created_at" example:"2022-06-05T14:26:02.302718+03:00"` UpdatedAt time.Time `json:"updated_at" example:"2022-06-05T14:26:10.303278+03:00"` } From 2a046f55171b3f292dda3d486dc85ae0662de2af Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:00:05 +0300 Subject: [PATCH 02/16] feat(api): add UnarchiveThread to MessagePhoneReceivedPayload --- .../events/message_phone_received_event.go | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/api/pkg/events/message_phone_received_event.go b/api/pkg/events/message_phone_received_event.go index 04dd6c2ed..6616da599 100644 --- a/api/pkg/events/message_phone_received_event.go +++ b/api/pkg/events/message_phone_received_event.go @@ -13,13 +13,14 @@ const EventTypeMessagePhoneReceived = "message.phone.received" // MessagePhoneReceivedPayload is the payload of the EventTypeMessagePhoneReceived event type MessagePhoneReceivedPayload struct { - MessageID uuid.UUID `json:"message_id"` - UserID entities.UserID `json:"user_id"` - Owner string `json:"owner"` - Encrypted bool `json:"encrypted"` - Contact string `json:"contact"` - Timestamp time.Time `json:"timestamp"` - Content string `json:"content"` - SIM entities.SIM `json:"sim"` - Attachments []string `json:"attachments"` + MessageID uuid.UUID `json:"message_id"` + UserID entities.UserID `json:"user_id"` + Owner string `json:"owner"` + Encrypted bool `json:"encrypted"` + Contact string `json:"contact"` + Timestamp time.Time `json:"timestamp"` + Content string `json:"content"` + SIM entities.SIM `json:"sim"` + Attachments []string `json:"attachments"` + UnarchiveThread bool `json:"unarchive_thread"` } From ccf8c627025bee84e75b03ab8164247a7d65ed08 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:03:42 +0300 Subject: [PATCH 03/16] feat(api): populate UnarchiveThread from phone on received event --- api/pkg/services/message_service.go | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/api/pkg/services/message_service.go b/api/pkg/services/message_service.go index 56766c981..8961419f5 100644 --- a/api/pkg/services/message_service.go +++ b/api/pkg/services/message_service.go @@ -346,16 +346,27 @@ func (service *MessageService) ReceiveMessage(ctx context.Context, params *Messa return nil, service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) } + owner := phonenumbers.Format(¶ms.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 + } + eventPayload := events.MessagePhoneReceivedPayload{ - MessageID: messageID, - UserID: params.UserID, - Encrypted: params.Encrypted, - Owner: phonenumbers.Format(¶ms.Owner, phonenumbers.E164), - Contact: params.Contact, - Timestamp: params.Timestamp, - Content: params.Content, - SIM: params.SIM, - Attachments: attachmentURLs, + MessageID: messageID, + UserID: params.UserID, + Encrypted: params.Encrypted, + Owner: owner, + Contact: params.Contact, + Timestamp: params.Timestamp, + Content: params.Content, + SIM: params.SIM, + Attachments: attachmentURLs, + UnarchiveThread: unarchiveThread, } ctxLogger.Info(fmt.Sprintf("creating cloud event for received with ID [%s]", eventPayload.MessageID)) From ba3b372f72687fa50eefb57da2e2177e2c433ce5 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:10:10 +0300 Subject: [PATCH 04/16] feat(api): unarchive thread on inbound message when enabled Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/pkg/services/message_thread_service.go | 27 ++++++++++++++----- .../services/message_thread_service_test.go | 24 +++++++++++++++++ 2 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 api/pkg/services/message_thread_service_test.go diff --git a/api/pkg/services/message_thread_service.go b/api/pkg/services/message_thread_service.go index d2027506f..bf307370b 100644 --- a/api/pkg/services/message_thread_service.go +++ b/api/pkg/services/message_thread_service.go @@ -41,13 +41,21 @@ func NewMessageThreadService( // MessageThreadUpdateParams are parameters for updating a thread type MessageThreadUpdateParams struct { - Owner string - Status entities.MessageStatus - Contact string - Content string - UserID entities.UserID - MessageID uuid.UUID - Timestamp time.Time + Owner string + Status entities.MessageStatus + Contact string + Content string + UserID entities.UserID + MessageID uuid.UUID + Timestamp time.Time + UnarchiveThread bool +} + +// shouldUnarchive reports whether an archived thread should be moved back to +// the inbox because a new inbound message was received and the phone has the +// UnarchiveThread setting enabled. +func (service *MessageThreadService) shouldUnarchive(thread *entities.MessageThread, params MessageThreadUpdateParams) bool { + return thread.IsArchived && params.UnarchiveThread && params.Status == entities.MessageStatusReceived } // DeleteAllForUser deletes all entities.MessageThread for an entities.UserID. @@ -92,6 +100,11 @@ func (service *MessageThreadService) UpdateThread(ctx context.Context, params Me return nil } + if service.shouldUnarchive(thread, params) { + thread.UpdateArchive(false) + ctxLogger.Info(fmt.Sprintf("unarchiving thread [%s] after inbound message [%s]", thread.ID, params.MessageID)) + } + if err = service.repository.Update(ctx, thread.Update(params.Timestamp, params.MessageID, params.Content, params.Status)); err != nil { msg := fmt.Sprintf("cannot update message thread with id [%s] after adding message [%s]", thread.ID, params.MessageID) return service.tracer.WrapErrorSpan(span, stacktrace.Propagate(err, msg)) diff --git a/api/pkg/services/message_thread_service_test.go b/api/pkg/services/message_thread_service_test.go new file mode 100644 index 000000000..18efe4365 --- /dev/null +++ b/api/pkg/services/message_thread_service_test.go @@ -0,0 +1,24 @@ +package services + +import ( + "testing" + + "github.com/NdoleStudio/httpsms/pkg/entities" + "github.com/stretchr/testify/assert" +) + +func TestShouldUnarchive(t *testing.T) { + service := &MessageThreadService{} + + archived := &entities.MessageThread{IsArchived: true} + notArchived := &entities.MessageThread{IsArchived: false} + + received := MessageThreadUpdateParams{Status: entities.MessageStatusReceived, UnarchiveThread: true} + receivedFlagOff := MessageThreadUpdateParams{Status: entities.MessageStatusReceived, UnarchiveThread: false} + sentFlagOn := MessageThreadUpdateParams{Status: entities.MessageStatusSent, UnarchiveThread: true} + + assert.True(t, service.shouldUnarchive(archived, received), "archived + inbound + flag on -> unarchive") + assert.False(t, service.shouldUnarchive(archived, receivedFlagOff), "flag off -> no unarchive") + assert.False(t, service.shouldUnarchive(archived, sentFlagOn), "outbound status -> no unarchive") + assert.False(t, service.shouldUnarchive(notArchived, received), "already unarchived -> no change") +} From 5987b0acbeb34bbd168fdb8a452725b319b99a56 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:13:57 +0300 Subject: [PATCH 05/16] feat(api): forward UnarchiveThread flag from received event to thread update --- api/pkg/listeners/message_thread_listener.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/api/pkg/listeners/message_thread_listener.go b/api/pkg/listeners/message_thread_listener.go index e86c8092f..aedcb04c9 100644 --- a/api/pkg/listeners/message_thread_listener.go +++ b/api/pkg/listeners/message_thread_listener.go @@ -222,13 +222,14 @@ func (listener *MessageThreadListener) OnMessagePhoneReceived(ctx context.Contex } updateParams := services.MessageThreadUpdateParams{ - Owner: payload.Owner, - Contact: payload.Contact, - Timestamp: payload.Timestamp, - UserID: payload.UserID, - Status: entities.MessageStatusReceived, - Content: payload.Content, - MessageID: payload.MessageID, + Owner: payload.Owner, + Contact: payload.Contact, + Timestamp: payload.Timestamp, + UserID: payload.UserID, + Status: entities.MessageStatusReceived, + Content: payload.Content, + MessageID: payload.MessageID, + UnarchiveThread: payload.UnarchiveThread, } if err := listener.service.UpdateThread(ctx, updateParams); err != nil { From 313b6997852899e4ed577611d5102f90025fe621 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:19:26 +0300 Subject: [PATCH 06/16] feat(api): accept unarchive_thread in phone upsert request Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/docs/docs.go | 16 ++++++++++++++-- api/docs/swagger.json | 16 ++++++++++++++-- api/docs/swagger.yaml | 14 +++++++++++++- api/pkg/requests/phone_update_request.go | 9 +++++++++ api/pkg/services/phone_service.go | 5 +++++ 5 files changed, 55 insertions(+), 5 deletions(-) diff --git a/api/docs/docs.go b/api/docs/docs.go index c57458255..c7fcd1145 100644 --- a/api/docs/docs.go +++ b/api/docs/docs.go @@ -3379,7 +3379,7 @@ const docTemplate = `{ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3760,6 +3760,7 @@ const docTemplate = `{ "messages_per_minute", "phone_number", "sim", + "unarchive_thread", "updated_at", "user_id" ], @@ -3804,6 +3805,11 @@ const docTemplate = `{ "sim": { "$ref": "#/definitions/entities.SIM" }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:10.303278+03:00" @@ -4436,7 +4442,8 @@ const docTemplate = `{ "messages_per_minute", "missed_call_auto_reply", "phone_number", - "sim" + "sim", + "unarchive_thread" ], "properties": { "fcm_token": { @@ -4473,6 +4480,11 @@ const docTemplate = `{ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", "type": "string", "example": "SIM1" + }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false } } }, diff --git a/api/docs/swagger.json b/api/docs/swagger.json index 009797ca9..25db6fc31 100644 --- a/api/docs/swagger.json +++ b/api/docs/swagger.json @@ -3376,7 +3376,7 @@ }, "request_id": { "type": "string", - "example": "bulk-csv-a1B2c3D4e5" + "example": "bulk-httpsms-file.csv" }, "scheduled_count": { "type": "integer", @@ -3757,6 +3757,7 @@ "messages_per_minute", "phone_number", "sim", + "unarchive_thread", "updated_at", "user_id" ], @@ -3801,6 +3802,11 @@ "sim": { "$ref": "#/definitions/entities.SIM" }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false + }, "updated_at": { "type": "string", "example": "2022-06-05T14:26:10.303278+03:00" @@ -4433,7 +4439,8 @@ "messages_per_minute", "missed_call_auto_reply", "phone_number", - "sim" + "sim", + "unarchive_thread" ], "properties": { "fcm_token": { @@ -4470,6 +4477,11 @@ "description": "SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot", "type": "string", "example": "SIM1" + }, + "unarchive_thread": { + "description": "UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone.", + "type": "boolean", + "example": false } } }, diff --git a/api/docs/swagger.yaml b/api/docs/swagger.yaml index bb0041717..892c9ae45 100644 --- a/api/docs/swagger.yaml +++ b/api/docs/swagger.yaml @@ -58,7 +58,7 @@ definitions: example: 30 type: integer request_id: - example: bulk-csv-a1B2c3D4e5 + example: bulk-httpsms-file.csv type: string scheduled_count: example: 50 @@ -388,6 +388,11 @@ definitions: type: string sim: $ref: '#/definitions/entities.SIM' + unarchive_thread: + description: UnarchiveThread moves an archived message thread back to the + inbox when a new message is received on this phone. + example: false + type: boolean updated_at: example: "2022-06-05T14:26:10.303278+03:00" type: string @@ -402,6 +407,7 @@ definitions: - messages_per_minute - phone_number - sim + - unarchive_thread - updated_at - user_id type: object @@ -908,6 +914,11 @@ definitions: 1 SIM slot example: SIM1 type: string + unarchive_thread: + description: UnarchiveThread moves an archived thread back to the inbox when + a new message is received on this phone. + example: false + type: boolean required: - fcm_token - max_send_attempts @@ -916,6 +927,7 @@ definitions: - missed_call_auto_reply - phone_number - sim + - unarchive_thread type: object requests.UserNotificationUpdate: properties: diff --git a/api/pkg/requests/phone_update_request.go b/api/pkg/requests/phone_update_request.go index 462d6428e..96b2882e1 100644 --- a/api/pkg/requests/phone_update_request.go +++ b/api/pkg/requests/phone_update_request.go @@ -29,6 +29,9 @@ type PhoneUpsert struct { 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"` + // SIM is the SIM slot of the phone in case the phone has more than 1 SIM slot SIM string `json:"sim" example:"SIM1"` @@ -75,6 +78,11 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin maxSendAttempts = &input.MaxSendAttempts } + var unarchiveThread *bool + if _, exists := fields["unarchive_thread"]; exists { + unarchiveThread = &input.UnarchiveThread + } + var scheduleID *uuid.UUID if _, exists := fields["message_send_schedule_id"]; exists { if parsed, err := uuid.Parse(strings.TrimSpace(input.MessageSendScheduleID)); err == nil { @@ -92,6 +100,7 @@ func (input *PhoneUpsert) ToUpsertParams(user entities.AuthContext, source strin FcmToken: fcmToken, UserID: user.ID, SIM: entities.SIM(input.SIM), + UnarchiveThread: unarchiveThread, MessageSendScheduleID: scheduleID, } } diff --git a/api/pkg/services/phone_service.go b/api/pkg/services/phone_service.go index ae863d32b..2e80abfa1 100644 --- a/api/pkg/services/phone_service.go +++ b/api/pkg/services/phone_service.go @@ -104,6 +104,7 @@ type PhoneUpsertParams struct { WebhookURL *string MessageExpirationDuration *time.Duration MissedCallAutoReply *string + UnarchiveThread *bool SIM entities.SIM MessageSendScheduleID *uuid.UUID Source string @@ -312,6 +313,10 @@ func (service *PhoneService) update(phone *entities.Phone, params *PhoneUpsertPa phone.MissedCallAutoReply = params.MissedCallAutoReply } + if params.UnarchiveThread != nil { + phone.UnarchiveThread = *params.UnarchiveThread + } + phone.SIM = params.SIM phone.MessageSendScheduleID = params.MessageSendScheduleID From 77a108a62962b5ae087b6af39328f36fd8dbb5d6 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:23:53 +0300 Subject: [PATCH 07/16] chore(web): regenerate api models with unarchive_thread Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 --- web/shared/types/api.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/web/shared/types/api.ts b/web/shared/types/api.ts index df85420ad..3f19b8534 100644 --- a/web/shared/types/api.ts +++ b/web/shared/types/api.ts @@ -61,7 +61,7 @@ export interface EntitiesBulkMessage { failed_count: number; /** @example 30 */ pending_count: number; - /** @example "bulk-csv-a1B2c3D4e5" */ + /** @example "bulk-httpsms-file.csv" */ request_id: string; /** @example 50 */ scheduled_count: number; @@ -244,6 +244,11 @@ export interface EntitiesPhone { /** @example "+18005550199" */ phone_number: string; sim: EntitiesSIM; + /** + * UnarchiveThread moves an archived message thread back to the inbox when a new message is received on this phone. + * @example false + */ + unarchive_thread: boolean; /** @example "2022-06-05T14:26:10.303278+03:00" */ updated_at: string; /** @example "WB7DRDWrJZRGbYrv2CKGkqbzvqdC" */ @@ -525,6 +530,11 @@ export interface RequestsPhoneUpsert { * @example "SIM1" */ sim: string; + /** + * UnarchiveThread moves an archived thread back to the inbox when a new message is received on this phone. + * @example false + */ + unarchive_thread: boolean; } export interface RequestsUserNotificationUpdate { From 604889e422609f47d6abd537a67d5e8555134db7 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:25:07 +0300 Subject: [PATCH 08/16] feat(web): send unarchive_thread in updatePhone Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 --- web/app/stores/phones.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/web/app/stores/phones.ts b/web/app/stores/phones.ts index b867e874e..276fe2928 100644 --- a/web/app/stores/phones.ts +++ b/web/app/stores/phones.ts @@ -60,6 +60,7 @@ export const usePhonesStore = defineStore('phones', () => { max_send_attempts: parseInt(phone.max_send_attempts.toString()), messages_per_minute: parseInt(phone.messages_per_minute.toString()), message_send_schedule_id: phone.message_send_schedule_id ?? null, + unarchive_thread: phone.unarchive_thread ?? false, }, }) notificationsStore.addNotification({ From db085c1eb87d2f49ec80cef6e8fd76c6d066ed5a Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:27:54 +0300 Subject: [PATCH 09/16] feat(web): add per-phone unarchive-thread toggle to settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 --- web/app/pages/settings/index.vue | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/web/app/pages/settings/index.vue b/web/app/pages/settings/index.vue index 17e35c7c6..acefdb43c 100644 --- a/web/app/pages/settings/index.vue +++ b/web/app/pages/settings/index.vue @@ -1688,6 +1688,15 @@ onMounted(async () => { placeholder="We are currently closed at the moment, please send us a text message from 09:00 to 17:00" hint="Here you can configure an automated SMS message which is sent to the caller when this phone has a missed call" /> + From fa32d02d8be1c999a86ff0856c840477d588cc56 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:32:16 +0300 Subject: [PATCH 10/16] test(integration): verify unarchive thread on receive Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: bf78429e-573c-406c-9f7c-1d6e1bddbbb5 --- tests/README.md | 1 + tests/unarchive_thread_integration_test.go | 219 +++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 tests/unarchive_thread_integration_test.go diff --git a/tests/README.md b/tests/README.md index a37653e53..345d84d1e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -53,6 +53,7 @@ The API's Firebase SDK is configured (via `FCM_ENDPOINT` env var) to redirect al - [x] **Send SMS E2E** — Full send lifecycle: API → FCM push → emulator responds with SENT/DELIVERED events → message reaches `delivered` status - [x] **Receive SMS E2E** — Phone submits received message to API → message is stored and retrievable via GET endpoint +- [x] **Unarchive Thread on Receive E2E** — Archived thread returns to the inbox on inbound message when the phone's `unarchive_thread` setting is enabled, and stays archived when disabled ## Prerequisites diff --git a/tests/unarchive_thread_integration_test.go b/tests/unarchive_thread_integration_test.go new file mode 100644 index 000000000..6c3a41fef --- /dev/null +++ b/tests/unarchive_thread_integration_test.go @@ -0,0 +1,219 @@ +package tests + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "testing" + "time" + + httpsms "github.com/NdoleStudio/httpsms-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type integrationThread struct { + ID string `json:"id"` + Contact string `json:"contact"` + Owner string `json:"owner"` + IsArchived bool `json:"is_archived"` + LastMessageContent *string `json:"last_message_content"` +} + +// setUnarchiveThread flips the per-phone unarchive_thread flag via a raw PUT +// (the httpsms-go client has no field for it). +func setUnarchiveThread(ctx context.Context, t *testing.T, phoneNumber string, enabled bool) { + t.Helper() + + payload := map[string]interface{}{ + "phone_number": phoneNumber, + "sim": "SIM1", + "unarchive_thread": enabled, + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/phones", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "set unarchive_thread failed: %s", string(respBody)) +} + +// receiveInbound submits an inbound message as the phone and returns the message ID. +func receiveInbound(ctx context.Context, t *testing.T, phoneAPIKey, from, to, content string, ts time.Time) string { + t.Helper() + + payload := map[string]interface{}{ + "from": from, + "to": to, + "content": content, + "sim": "SIM1", + "timestamp": ts.UTC().Format(time.RFC3339), + } + body, err := json.Marshal(payload) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiBaseURL+"/v1/messages/receive", bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", phoneAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "receive failed: %s", string(respBody)) + + var result httpsms.MessageResponse + require.NoError(t, json.Unmarshal(respBody, &result)) + id := result.Data.ID.String() + require.NotEmpty(t, id) + return id +} + +// fetchThreads returns threads for an owner filtered by archived state. +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) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + require.NoError(t, err) + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "fetch threads failed: %s", string(respBody)) + + var result struct { + Data []integrationThread `json:"data"` + } + require.NoError(t, json.Unmarshal(respBody, &result)) + return result.Data +} + +func findThreadByContact(threads []integrationThread, contact string) *integrationThread { + for i := range threads { + if threads[i].Contact == contact { + return &threads[i] + } + } + return nil +} + +// waitForThread polls the archived/unarchived thread list until a thread for the +// contact appears (optionally matching a last-message content), then returns it. +func waitForThread(ctx context.Context, t *testing.T, owner, contact string, archived bool, wantContent string, timeout time.Duration) *integrationThread { + t.Helper() + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + thread := findThreadByContact(fetchThreads(ctx, t, owner, archived), contact) + if thread != nil && (wantContent == "" || (thread.LastMessageContent != nil && *thread.LastMessageContent == wantContent)) { + return thread + } + time.Sleep(500 * time.Millisecond) + } + return nil +} + +// archiveThread archives a thread by ID. +func archiveThread(ctx context.Context, t *testing.T, threadID string) { + t.Helper() + + body, err := json.Marshal(map[string]interface{}{"is_archived": true}) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(ctx, http.MethodPut, apiBaseURL+"/v1/message-threads/"+threadID, bytes.NewReader(body)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("x-api-key", userAPIKey) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode, "archive thread failed: %s", string(respBody)) +} + +func TestUnarchiveThreadOnReceive_Enabled(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) + setUnarchiveThread(ctx, t, phone.PhoneNumber, true) + + contact := randomPhoneNumber() + content1 := "first inbound " + randomEncryptionKey() + content2 := "second inbound " + randomEncryptionKey() + + // First inbound message creates the thread. + msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute)) + pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second) + + thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second) + require.NotNil(t, thread, "thread not created for contact %s", contact) + + // Archive it. + archiveThread(ctx, t, thread.ID) + archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second) + require.NotNil(t, archived, "thread was not archived") + + // Second inbound message should unarchive it. + msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now()) + pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second) + + unarchived := waitForThread(ctx, t, phone.PhoneNumber, contact, false, content2, 20*time.Second) + require.NotNil(t, unarchived, "thread was not unarchived after inbound message") + assert.False(t, unarchived.IsArchived) + require.NotNil(t, unarchived.LastMessageContent) + assert.Equal(t, content2, *unarchived.LastMessageContent) +} + +func TestUnarchiveThreadOnReceive_Disabled(t *testing.T) { + ctx := context.Background() + phone := setupPhone(ctx, t, 60) // unarchive_thread defaults to false; do not enable it + + contact := randomPhoneNumber() + content1 := "first inbound " + randomEncryptionKey() + content2 := "second inbound " + randomEncryptionKey() + + msgID1 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content1, time.Now().Add(-1*time.Minute)) + pollMessageStatus(ctx, t, msgID1, "received", 15*time.Second) + + thread := waitForThread(ctx, t, phone.PhoneNumber, contact, false, "", 15*time.Second) + require.NotNil(t, thread, "thread not created for contact %s", contact) + + archiveThread(ctx, t, thread.ID) + archived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, "", 10*time.Second) + require.NotNil(t, archived, "thread was not archived") + + // Second inbound message must NOT unarchive it. Sync on the archived thread's + // last_message_content updating to content2 (proves the listener processed it), + // then assert it is still archived. + msgID2 := receiveInbound(ctx, t, phone.PhoneAPIKey, contact, phone.PhoneNumber, content2, time.Now()) + pollMessageStatus(ctx, t, msgID2, "received", 15*time.Second) + + stillArchived := waitForThread(ctx, t, phone.PhoneNumber, contact, true, content2, 20*time.Second) + require.NotNil(t, stillArchived, "archived thread did not reflect the second inbound message") + assert.True(t, stillArchived.IsArchived, "thread should remain archived when unarchive_thread is disabled") + + // And it must not have leaked into the unarchived list. + assert.Nil(t, findThreadByContact(fetchThreads(ctx, t, phone.PhoneNumber, false), contact), + "thread should not appear in the unarchived list") +} From 7e5ca434dc64aa7825d32a35b63e711a4f375189 Mon Sep 17 00:00:00 2001 From: Acho Arnold Ewin Date: Sat, 18 Jul 2026 10:43:46 +0300 Subject: [PATCH 11/16] feat: fix english --- .gitignore | 1 - api/pkg/handlers/message_handler.go | 4 +- ...-07-18-webhook-email-payload-formatting.md | 584 ++++++++++++++++++ ...26-07-05-affiliates-landing-page-design.md | 109 ++++ .../2026-07-13-getting-started-page-design.md | 88 +++ web/app/pages/settings/index.vue | 4 +- 6 files changed, 785 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-18-webhook-email-payload-formatting.md create mode 100644 docs/superpowers/specs/2026-07-05-affiliates-landing-page-design.md create mode 100644 docs/superpowers/specs/2026-07-13-getting-started-page-design.md diff --git a/.gitignore b/.gitignore index e15d590a9..8f4caf4b5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,6 @@ SECURITY_AUDIT_REPORT.md *.exe -docs/ .output .agents/ skills-lock.json diff --git a/api/pkg/handlers/message_handler.go b/api/pkg/handlers/message_handler.go index afe0127a4..9da0ef1e5 100644 --- a/api/pkg/handlers/message_handler.go +++ b/api/pkg/handlers/message_handler.go @@ -265,7 +265,7 @@ func (h *MessageHandler) Index(c fiber.Ctx) error { messages, err := h.service.GetMessages(ctx, request.ToGetParams(h.userIDFomContext(c))) if err != nil { - msg := fmt.Sprintf("cannot get messgaes with params [%+#v]", request) + msg := fmt.Sprintf("cannot get messages with params [%+#v]", request) ctxLogger.Error(stacktrace.Propagate(err, msg)) return h.responseInternalServerError(c) } @@ -383,7 +383,7 @@ func (h *MessageHandler) PostReceive(c fiber.Ctx) error { message, err := h.service.ReceiveMessage(ctx, request.ToMessageReceiveParams(h.userIDFomContext(c), c.OriginalURL())) if err != nil { - msg := fmt.Sprintf("cannot receive message with paylod [%s]", c.Body()) + msg := fmt.Sprintf("cannot receive message with payload [%s]", c.Body()) ctxLogger.Error(stacktrace.Propagate(err, msg)) return h.responseInternalServerError(c) } diff --git a/docs/superpowers/plans/2026-07-18-webhook-email-payload-formatting.md b/docs/superpowers/plans/2026-07-18-webhook-email-payload-formatting.md new file mode 100644 index 000000000..1fe56a219 --- /dev/null +++ b/docs/superpowers/plans/2026-07-18-webhook-email-payload-formatting.md @@ -0,0 +1,584 @@ +# 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. + +**Tech Stack:** Go 1.25.8 module, Go standard library (`bytes`, `encoding/json`, `html`, `html/template`, `strings`), go-hermes v2.6.2, testify v1.11.1. + +## Global Constraints + +- Format only the webhook email's **Event Payload** value. +- Leave SMS content, failure reasons, HTTP responses, and every other email field unchanged. +- Pretty-print valid JSON with indentation. +- Render valid JSON in HTML with lightweight colors for keys, strings, numbers, booleans, and `null`. +- Render invalid JSON in the same monospace block with original whitespace and no syntax colors. +- Add no third-party dependency. +- HTML-escape every payload token before converting the completed markup to `template.HTML`. +- Invalid JSON must not prevent email generation or delivery. +- Preserve the existing plain-text email behavior except for readable payload indentation. +- Use `stacktrace.Propagate` for existing Hermes generation errors; this feature introduces no new returned error. + +--- + +## File Structure + +- Create `api/pkg/emails/event_payload_formatter.go`: JSON indentation, token highlighting, HTML escaping, and code-block construction. +- Create `api/pkg/emails/event_payload_formatter_test.go`: focused tests for valid JSON tokens, escaping, and invalid JSON fallback. +- Create `api/pkg/emails/hermes_theme_test.go`: verifies safe opt-in HTML dictionary rendering and unchanged plain-text rendering. +- Modify `api/pkg/emails/hermes_theme.go:325-334`: render `Entry.UnsafeValue` only when present. +- Create `api/pkg/emails/hermes_notification_email_factory_test.go`: end-to-end factory coverage for the webhook notification email. +- Modify `api/pkg/emails/hermes_notification_email_factory.go:78-122`: format and attach only the Event Payload dictionary entry. + +### Task 1: Build the event payload formatter + +**Files:** +- Create: `api/pkg/emails/event_payload_formatter.go` +- Create: `api/pkg/emails/event_payload_formatter_test.go` + +**Interfaces:** +- Consumes: a raw webhook event payload `string`. +- Produces: `formatEventPayload(payload string) (string, template.HTML)`. +- Produces: plain indented JSON for `hermes.Entry.Value` and an escaped styled code block for `hermes.Entry.UnsafeValue`. + +- [ ] **Step 1: Write the failing formatter tests** + +Create `api/pkg/emails/event_payload_formatter_test.go`: + +```go +package emails + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFormatEventPayloadIndentsAndHighlightsJSON(t *testing.T) { + payload := `{"message":"hello","count":2,"ratio":1.5,"enabled":true,"disabled":false,"missing":null,"nested":{"value":"ok"}}` + + plain, rich := formatEventPayload(payload) + html := string(rich) + + assert.Equal(t, `{ + "message": "hello", + "count": 2, + "ratio": 1.5, + "enabled": true, + "disabled": false, + "missing": null, + "nested": { + "value": "ok" + } +}`, plain) + assert.Contains(t, html, `"message"`) + assert.Contains(t, html, `"hello"`) + assert.Contains(t, html, `2`) + assert.Contains(t, html, `1.5`) + assert.Contains(t, html, `true`) + assert.Contains(t, html, `false`) + assert.Contains(t, html, `null`) + assert.Contains(t, html, `white-space:pre-wrap`) +} + +func TestFormatEventPayloadEscapesPayloadHTML(t *testing.T) { + plain, rich := formatEventPayload(`{"message":"&"}`) + html := string(rich) + + assert.Contains(t, plain, `&"}`) - html := string(rich) - - assert.Contains(t, plain, `