Point app at real rhosys.cloud hosts instead of nonexistent numaeel.com - #3
Conversation
The API base URL, Authress issuer, and the autoVerify App Link host were all pointing at numaeel.com and its subdomains. None of those domains have DNS records — they were invented alongside the "Numaeel" product name in 8bdc2a0 and then built against in b3f6eff, so every API call and every login attempt in the published build resolves to NXDOMAIN. That also left the app claiming ownership of a domain it cannot serve assetlinks.json from, so App Links verification could never succeed. An app that reaches none of its own endpoints while still reporting to an analytics host is the likely source of the Play Store malware warning. api.numaeel.com -> email.rhosys.cloud (API, paths are v1/* off the root) login.numaeel.com -> login.rhosys.cloud (Authress issuer) numaeel.com -> email.rhosys.cloud (App Link host) All three remain overridable via -PapiBaseUrl / -PauthressDomain for the planned domain change. Remaining "Numaeel" references are branding strings and storage keys, handled separately. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
The backend serves its routes under a /api base path, so the corrected host
alone still produced 404s: every endpoint in EmailApiService is declared
relative (v1/accounts, v1/threads/{id}, ...) and hung directly off the host
root.
before https://email.rhosys.cloud/v1/accounts
after https://email.rhosys.cloud/api/v1/accounts
Verified no endpoint uses a leading slash or @url, either of which would
resolve against the host root and bypass the base path — all 30+ routes are
relative and prefixed v1. The trailing slash is required: Retrofit throws on
a baseUrl without one, and dropping it would also swallow the /api segment.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
The remote layer was fabricated, not just misconfigured. Fetched the real contract from https://email.rhosys.cloud/.well-known/api-catalog (OpenAPI 3.1, 43 paths, 62 schemas) and regenerated EmailApiService and the DTOs against it. Every one of the previous 41 endpoints was wrong. Beyond the invented v1/ prefix, the shape was wrong too: thread and signal routes nest under /accounts/{accountId}, and the resource the app called a "message" is a "signal" in the API. The core model change is that a Signal is a ten-way polymorphic union discriminated by `type`, not a flat message. `type` separates every variant except inbound and outbound email, which both report type="email" and are told apart by their payload (outbound carries sendInitiatedAt). SignalDtoAdapter buffers via peekJson to dispatch, and unrecognised types fall back to SystemSignalDto so a new backend signal type degrades to a notice instead of failing the whole thread. Several things the app modelled as endpoints are really statuses on a signal: drafts (status=draft), blocking (block_hidden/block_reject) and quarantine (quarantine_*). Thread status=active|archived|deleted|report_violation replaces the invented folder + isRead fields. Dropped, because the API does not provide them: read/unread marking, folders, top-level drafts, attachment download, send cancellation, MFA device management, billing and support tickets. Timestamps are ISO-8601 strings on the wire, not epoch millis, and are kept as String at the DTO boundary. This commit covers the remote layer only. The repositories and Room entities still reference the old model, so the tree does not compile yet; they are the next step and are deliberately left for a separate change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
|
CI is red on The first two commits (host + Context for why this grew past a URL fix: the remote layer was fabricated rather than misconfigured. Fetching the published spec at Remaining before this goes green:
One open product decision is blocking the entity and UI work: the API has no read/unread concept anywhere — not a field, not an endpoint, in any of the 43 paths or 62 schemas. The inbox currently renders unread state. Either it goes and Generated by Claude Code |
Follows the API-layer regeneration by replacing the invented domain model with one that matches the backend, per the decision to drop features the API does not back rather than fake them. Read/unread is gone. It does not exist anywhere in the API — not a field, not an endpoint, across all 43 paths and 62 schemas — so the inbox now takes row emphasis from `urgency` (critical|high|normal|low|silent), which the backend does provide. Folders are gone too, replaced by thread `status`. Message becomes Signal, modelled as a sealed hierarchy of InboundEmail, OutboundEmail and SystemNotice. The last collapses the seven non-email variants so an unrecognised signal type renders as a notice instead of breaking a thread. Drafts are OutboundEmail with status=DRAFT rather than a separate type, matching the API where a draft is a signal on a thread. Room goes to version 2: signals replace messages, the drafts and attachments tables are dropped, and threads lose folder/isRead/snippet/participants while gaining status, urgency, summary and sender. There is no migration from v1 — that schema described an API that does not exist, so nothing cached under it is meaningful and the cache refetches. Blocking a sender moves to a per-domain policy on an alias, since the API has no block-sender endpoint. The UI layer still references the old model, so the tree does not compile yet. That is the remaining step. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
Completes the data layer. Mutations still write locally first with isPendingSync and then attempt the network, but archive, delete, snooze and relabel now all resolve to a single PATCH on the thread, because the API has no dedicated endpoints for any of them. ThreadRemoteMediator follows the real cursor, carried in `pagination.cursor` rather than a top-level nextCursor, and clears the account's cached rows on REFRESH so a server-side deletion cannot linger locally. Composition works on draft signals: create posts to the thread's signals collection, editing is a PUT on that signal, and sending promotes the same signal. This removes send-later and undo-send, which the previous code exposed against endpoints that never existed — the API has no scheduling parameter and no cancel route. Quarantine is resolved per signal via quarantineResponse, not per thread. Blocking a sender moves to AccountRepository as a per-domain alias policy. SupportRepository is deleted outright: there is no ticket endpoint, and SupportData in the spec is a signal workflow type, not an API for filing anything. SettingsRepository loses MFA and billing for the same reason, and its "DNS records" become the domains resource, whose records hang off an individual domain rather than the account. AdminRepository drops the invented v1/admin/* routes for the real healthcheck plus per-signal reprocess and raw fetch. The UI layer is still on the old model, so the build stays red until that lands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
None of these have a backend. Billing and support tickets have no endpoints at all; the admin screen called invented v1/admin/* routes; and spam has no equivalent concept — the closest thing is block_hidden/block_reject, which is a sender policy on an alias rather than a mailbox that can be listed. Filtered mail now surfaces solely under Quarantine, which maps cleanly onto signal status plus quarantineResponse. QuarantineScreen moves from threads to signals accordingly, and FolderListViewModel goes with the spam screen it was shared with. The inbox takes row emphasis from urgency now that unread state is gone: critical and high render bold, critical in the error colour, silent muted. domain/model/Settings.kt is deleted — MfaDevice and PlanInfo have no API behind them, while DnsRecord, ForwardingAddress and TeamMember are now DTOs off the domains, forwarding-addresses and users endpoints. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
CI caught a real defect: Kotlin nests block comments, so writing an admin route
glob inside a KDoc opened a nested comment that the closing delimiter never
balanced, and kspDebugKotlin failed with "Unclosed comment". Quoted the path
instead.
Also removes what the deleted screens and absent endpoints left stranded:
- PendingSendManager and the UndoSendReceiver manifest entry. Both existed to
schedule a send and cancel it during a grace window; the API has neither a
scheduling parameter nor a cancel route, so there is nothing to build on.
- AdminRepository, whose only consumer was the deleted admin screen. Its
healthcheck and per-signal reprocess endpoints are real and can come back
with that screen. StatsRepository moves to its own file.
AppContainer is rewired for the new DAOs and drops the deleted repositories.
Room gets fallbackToDestructiveMigration for the v1 to v2 jump, matching the
schema change: nothing cached under v1 describes an API that exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
ThreadViewModel drops mark-as-read on open and attachment download, neither of which the API supports, and blocking a sender now applies a BLOCK_REJECT policy to the sender's domain on the receiving alias instead of calling a per-thread endpoint that never existed. It takes an accountId, since no thread or signal route is addressable without one. WorkflowPanelView keys off the backend's 15-value Workflow enum rather than the invented 14-value WorkflowType. Structured fields come from a signal's typed workflowData payload; the free-form workflowFields map on a thread is gone. ComposeViewModel works on draft signals. Creating posts to the thread's signals collection, editing PUTs that signal, and sending promotes it. Send is immediate because the API has no scheduling parameter and no cancel route, so the send-later plus undo-send flow and its PendingSendManager dependency are gone. Compose is only reachable from a reply or forward: draft creation requires a threadId and the API has no route for a standalone draft, so canCompose gates the blank-slate entry point rather than inventing one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
The build compiles and lints clean for the first time since the migration started. Installing the Android SDK locally made it possible to iterate against a real compiler instead of CI. Two corrections from checking the web app rather than inferring: Sender policy. SenderPolicy and UnknownSenderPolicy are different enums, not one. The per-domain policy is allow | block_hidden | block_reject | report_violation; the alias-level default adds the two quarantine options and spells allow as allow_all. The wire field naming the domain is `sender`, not `domain`. The thread menu now opens a policy picker offering both settings, matching the web app's sender popup, in place of a single Block button — and it is explicit that a policy applies to the whole sending domain. Attachments. They live at a fixed URL on the signal, so there is nothing to download. Previously the repositories passed emptyList() into every mapping, which silently dropped them; they are now encoded into the cached signal row and restored with it, and the thread view opens one at its URL. Remaining UI changes follow the model: unread state is gone from the widget, rules summarise their actions and lock the toggle on IMMUTABLE rules, settings loses its MFA and billing tabs and reads DNS records off a domain, stats renders whatever shape the untyped endpoint returns, and thread routes carry an accountId because no thread or signal route resolves without one. Room's exported schema for version 2 is included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
The app module had no test sources at all — testDebugUnitTest reported NO-SOURCE — which is why nothing caught the attachment bug earlier in this branch. These 22 tests cover the parts of the migration most likely to break silently. SignalDtoAdapterTest pins the polymorphic dispatch. Eight of the ten signal variants are separable by `type`, but inbound and outbound email both report type="email" and differ only in their payload, so the tests assert that receivedAt selects inbound, sendInitiatedAt selects outbound, and an unknown type degrades to a system notice instead of throwing — a new backend signal type must not break a whole thread. SignalEntityTest covers the cache round trip, including the bug this branch already shipped once: attachments live at a fixed URL on the signal and have no download endpoint, so dropping them in the round trip loses them outright. It also pins that an attachment without a URL stays null rather than becoming the string "null". WireEnumTest asserts every enum's wire values against the spec. These fail silently in production because fromWire falls back rather than throwing, and the two policy enums are easy to conflate — SenderPolicy has four values and spells "allow", UnknownSenderPolicy has six and spells "allow_all". Tests need a real org.json: the Android framework's is a stub that throws "not mocked", and SignalEntity encodes attachments with it. Same dependency the Kinetic Jewelry app uses for the same reason. todo.md records what is left, including two items that block login and are deferred by decision: the placeholder Authress application id, and the OAuth redirect being claimed by both MainActivity and AppAuth's receiver. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL
Problem
Investigating the Play Store malware warning on the published email app, the root cause is that every domain the app is built around does not exist.
DNS resolution from a clean environment:
The "Numaeel" product name and its domains were introduced in
8bdc2a0as though they referred to an existing web app, then the full client was implemented against them inb3f6eff. The result is a published app where:autoVerifyApp Link claims a domain that cannot serveassetlinks.json, so Digital Asset Links verification can never succeedAn app that registers as the default email handler and claims a verified web domain, cannot reach any of its own endpoints, and reliably reports to an analytics collector is a strong match for automated deceptive-behavior detection.
Changes
app/build.gradle.kts:24https://api.numaeel.com/https://email.rhosys.cloud/app/build.gradle.kts:29login.numaeel.comlogin.rhosys.cloudapp/src/main/AndroidManifest.xml:44host="numaeel.com"host="email.rhosys.cloud"API paths are relative (
v1/accounts), so the base URL resolves tohttps://email.rhosys.cloud/v1/accounts. All three values remain overridable via-PapiBaseUrl/-PauthressDomainto support the planned domain change without a code change.Also added a manifest comment recording the App Links prerequisite:
https://email.rhosys.cloud/.well-known/assetlinks.jsonmust list this package together with the Play App Signing SHA-256 fingerprint (not the upload key), orautoVerifystill fails.Follow-up, not in this PR
strings.xml, the login and onboarding screens, the biometric prompt, the sync notification, and the Wear app. Needs a real product name before the next release.build.gradle.kts:34still readsnumaeel_android; it must match a real application registered in Authress or login fails regardless of the corrected issuer domain.numaeel.db,numaeel_prefs,numaeel_secure_prefs) are deliberately unchanged — renaming them orphans data on already-installed builds. Recommend leaving them as-is.MainActivitydeclares six intent filters but never reads the incomingIntent(noonNewIntent, nogetIntent()anywhere inapp/src), the OAuth redirect scheme collides with AppAuth'sRedirectUriReceiverActivity, PostHogcaptureDeepLinkscan capture OAuth authorization codes, and thedataSyncforeground service runs only while the activity is already foreground. These are tracked for a follow-up change.Generated by Claude Code