Skip to content

Point app at real rhosys.cloud hosts instead of nonexistent numaeel.com - #3

Merged
wparad merged 10 commits into
mainfrom
claude/email-app-malware-warning-yh6de6
Aug 12, 2026
Merged

Point app at real rhosys.cloud hosts instead of nonexistent numaeel.com#3
wparad merged 10 commits into
mainfrom
claude/email-app-malware-warning-yh6de6

Conversation

@wparad

@wparad wparad commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

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:

numaeel.com          NO DNS RECORD
api.numaeel.com      NO DNS RECORD   <- BuildConfig.API_BASE_URL
login.numaeel.com    NO DNS RECORD   <- Authress OIDC issuer
live.rhosys.ch       RESOLVES        <- analytics (works fine)

The "Numaeel" product name and its domains were introduced in 8bdc2a0 as though they referred to an existing web app, then the full client was implemented against them in b3f6eff. The result is a published app where:

  • every API call resolves to NXDOMAIN
  • every login attempt resolves to NXDOMAIN
  • the autoVerify App Link claims a domain that cannot serve assetlinks.json, so Digital Asset Links verification can never succeed
  • the only network destination it actually reaches is the PostHog analytics endpoint

An 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

File Was Now
app/build.gradle.kts:24 https://api.numaeel.com/ https://email.rhosys.cloud/
app/build.gradle.kts:29 login.numaeel.com login.rhosys.cloud
app/src/main/AndroidManifest.xml:44 host="numaeel.com" host="email.rhosys.cloud"

API paths are relative (v1/accounts), so the base URL resolves to https://email.rhosys.cloud/v1/accounts. All three values remain overridable via -PapiBaseUrl / -PauthressDomain to 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.json must list this package together with the Play App Signing SHA-256 fingerprint (not the upload key), or autoVerify still fails.

Follow-up, not in this PR

  • App name. "Numaeel" remains the user-visible label in 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.
  • Authress application ID. build.gradle.kts:34 still reads numaeel_android; it must match a real application registered in Authress or login fails regardless of the corrected issuer domain.
  • Storage keys (numaeel.db, numaeel_prefs, numaeel_secure_prefs) are deliberately unchanged — renaming them orphans data on already-installed builds. Recommend leaving them as-is.
  • Separately identified while investigating: MainActivity declares six intent filters but never reads the incoming Intent (no onNewIntent, no getIntent() anywhere in app/src), the OAuth redirect scheme collides with AppAuth's RedirectUriReceiverActivity, PostHog captureDeepLinks can capture OAuth authorization codes, and the dataSync foreground service runs only while the activity is already foreground. These are tracked for a follow-up change.

Generated by Claude Code

claude added 3 commits August 6, 2026 13:37
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

wparad commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

CI is red on 4b4d786 on purpose — mid-migration, not a regression.

The first two commits (host + /api base path) were green. 4b4d786 regenerates the remote layer from the real OpenAPI spec, and the repositories and Room entities still speak the old model, so compileDebugKotlin fails until the next commit lands.

Context for why this grew past a URL fix: the remote layer was fabricated rather than misconfigured. Fetching the published spec at /.well-known/api-catalog (OpenAPI 3.1, 43 paths, 62 schemas) confirmed that all 41 previously declared endpoints were wrong — not only the invented v1/ prefix, but the shape: thread and signal routes nest under /accounts/{accountId}, and what the app called a "message" is a "signal".

Remaining before this goes green:

  • Rewrite 7 repositories against the signal model
  • Rewrite 4 Room entities (ThreadEntity, MessageEntity, AccountEntity, OtherEntities)
  • Remove UI for endpoints the API does not provide: billing, MFA devices, support tickets, attachment download, send cancellation

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 urgency (critical|high|normal|low|silent) takes its place, or it becomes device-local state in Room that never syncs.


Generated by Claude Code

claude added 7 commits August 6, 2026 16:21
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
@wparad
wparad merged commit 8a82155 into main Aug 12, 2026
2 checks passed
@wparad
wparad deleted the claude/email-app-malware-warning-yh6de6 branch August 12, 2026 13:39
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