From 4a20af8ec4ee76bb6c5ae8d464da869b53ef3ee0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:37:16 +0000 Subject: [PATCH 01/10] Point app at real rhosys.cloud hosts instead of nonexistent numaeel.com MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- app/build.gradle.kts | 6 +++--- app/src/main/AndroidManifest.xml | 7 +++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 992a019..6eeb00e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,17 +16,17 @@ android { versionCode = (findProperty("versionCode") as? String)?.toIntOrNull() ?: 1 versionName = (findProperty("versionName") as? String) ?: "1.0.0" - // Backend shared with the Numaeel web app (SES-Email-Adapter-UI). Override + // Backend shared with the web app (SES-Email-Adapter-UI). Override // per-environment via gradle.properties / -P flags in CI. buildConfigField( "String", "API_BASE_URL", - "\"${(findProperty("apiBaseUrl") as? String) ?: "https://api.numaeel.com/"}\"", + "\"${(findProperty("apiBaseUrl") as? String) ?: "https://email.rhosys.cloud/"}\"", ) buildConfigField( "String", "AUTHRESS_CUSTOM_DOMAIN", - "\"${(findProperty("authressDomain") as? String) ?: "login.numaeel.com"}\"", + "\"${(findProperty("authressDomain") as? String) ?: "login.rhosys.cloud"}\"", ) buildConfigField( "String", diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12909b4..d9d36b1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,12 +36,15 @@ - + - + From 123816592f89856aec998082f5374e6397fabfd0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 13:55:23 +0000 Subject: [PATCH 02/10] Add the /api base path to the API URL so v1 routes resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- app/build.gradle.kts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6eeb00e..9ff156d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,10 +18,12 @@ android { // Backend shared with the web app (SES-Email-Adapter-UI). Override // per-environment via gradle.properties / -P flags in CI. + // Must keep the trailing slash: Retrofit rejects a baseUrl without one, + // and the /api base path is what the endpoint paths (v1/*) hang off. buildConfigField( "String", "API_BASE_URL", - "\"${(findProperty("apiBaseUrl") as? String) ?: "https://email.rhosys.cloud/"}\"", + "\"${(findProperty("apiBaseUrl") as? String) ?: "https://email.rhosys.cloud/api/"}\"", ) buildConfigField( "String", From 4b4d7863711cf30b682556a3e139fca720944f90 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:03:36 +0000 Subject: [PATCH 03/10] Regenerate the API layer from the published OpenAPI spec 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- .../email/data/remote/api/EmailApiService.kt | 418 +++++++++++++----- .../email/data/remote/dto/AccountDtos.kt | 96 +++- .../email/data/remote/dto/CommonDtos.kt | 45 ++ .../rhosys/email/data/remote/dto/OtherDtos.kt | 225 ++++++++-- .../email/data/remote/dto/SignalDtos.kt | 228 ++++++++++ .../email/data/remote/dto/ThreadDtos.kt | 104 ++--- .../java/ch/rhosys/email/di/AppContainer.kt | 6 +- 7 files changed, 928 insertions(+), 194 deletions(-) create mode 100644 app/src/main/java/ch/rhosys/email/data/remote/dto/CommonDtos.kt create mode 100644 app/src/main/java/ch/rhosys/email/data/remote/dto/SignalDtos.kt diff --git a/app/src/main/java/ch/rhosys/email/data/remote/api/EmailApiService.kt b/app/src/main/java/ch/rhosys/email/data/remote/api/EmailApiService.kt index f0f0960..f9b7b8e 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/api/EmailApiService.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/api/EmailApiService.kt @@ -1,24 +1,45 @@ package ch.rhosys.email.data.remote.api import ch.rhosys.email.data.remote.dto.AccountDto +import ch.rhosys.email.data.remote.dto.AccountListResponse +import ch.rhosys.email.data.remote.dto.AccountUserListResponse import ch.rhosys.email.data.remote.dto.AliasDto -import ch.rhosys.email.data.remote.dto.DnsRecordDto -import ch.rhosys.email.data.remote.dto.DraftDto -import ch.rhosys.email.data.remote.dto.ForwardingAddressDto +import ch.rhosys.email.data.remote.dto.AliasListResponse +import ch.rhosys.email.data.remote.dto.AliasSenderDto +import ch.rhosys.email.data.remote.dto.AliasSenderListResponse +import ch.rhosys.email.data.remote.dto.CreateDraftSignalRequest +import ch.rhosys.email.data.remote.dto.CreateForwardingTargetRequest +import ch.rhosys.email.data.remote.dto.CreateLabelRequest +import ch.rhosys.email.data.remote.dto.CreateRuleRequest +import ch.rhosys.email.data.remote.dto.DomainListResponse +import ch.rhosys.email.data.remote.dto.DomainWithRecordsDto +import ch.rhosys.email.data.remote.dto.EmailTemplateDto +import ch.rhosys.email.data.remote.dto.ForwardingTargetDto +import ch.rhosys.email.data.remote.dto.ForwardingTargetListResponse import ch.rhosys.email.data.remote.dto.HealthCheckDto import ch.rhosys.email.data.remote.dto.LabelDto -import ch.rhosys.email.data.remote.dto.MessageDto -import ch.rhosys.email.data.remote.dto.MfaDeviceDto -import ch.rhosys.email.data.remote.dto.MoveThreadRequest -import ch.rhosys.email.data.remote.dto.PlanInfoDto +import ch.rhosys.email.data.remote.dto.LabelListResponse +import ch.rhosys.email.data.remote.dto.PatchAccountRequest +import ch.rhosys.email.data.remote.dto.PatchAliasRequest +import ch.rhosys.email.data.remote.dto.PatchLabelRequest +import ch.rhosys.email.data.remote.dto.PatchRuleRequest +import ch.rhosys.email.data.remote.dto.PatchSignalRequest +import ch.rhosys.email.data.remote.dto.PatchThreadRequest +import ch.rhosys.email.data.remote.dto.QuarantineResponseRequest import ch.rhosys.email.data.remote.dto.RuleDto -import ch.rhosys.email.data.remote.dto.SendMessageRequest -import ch.rhosys.email.data.remote.dto.StatsSummaryDto -import ch.rhosys.email.data.remote.dto.SupportTicketRequest -import ch.rhosys.email.data.remote.dto.TeamMemberDto -import ch.rhosys.email.data.remote.dto.TemplateDto +import ch.rhosys.email.data.remote.dto.RuleListResponse +import ch.rhosys.email.data.remote.dto.SetAliasSenderRequest +import ch.rhosys.email.data.remote.dto.SignalDto +import ch.rhosys.email.data.remote.dto.SignalListResponse +import ch.rhosys.email.data.remote.dto.TemplateListResponse import ch.rhosys.email.data.remote.dto.ThreadDto -import ch.rhosys.email.data.remote.dto.ThreadPage +import ch.rhosys.email.data.remote.dto.ThreadListResponse +import ch.rhosys.email.data.remote.dto.UnsubscribeResultDto +import ch.rhosys.email.data.remote.dto.UpdateDraftSignalRequest +import ch.rhosys.email.data.remote.dto.UpsertTemplateRequest +import ch.rhosys.email.data.remote.dto.UserConfigurationDto +import ch.rhosys.email.data.remote.dto.ViewListResponse +import okhttp3.ResponseBody import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE @@ -31,142 +52,329 @@ import retrofit2.http.Query import retrofit2.http.Streaming /** - * Backend contract shared with the Numaeel web app (SES-Email-Adapter-UI). - * Paths follow that app's existing REST conventions; adjust base paths here - * if the deployed API differs — this interface is the single seam. + * Backend contract, transcribed from the OpenAPI 3.1 document published at + * https://email.rhosys.cloud/.well-known/api-catalog (SES Email Adapter 1.0.0). + * + * Two conventions to keep in mind when adding to this interface: + * + * - There is no `v1/` prefix. Paths are relative to the `/api` base path set in + * BuildConfig.API_BASE_URL. + * - Almost everything nests under `/accounts/{accountId}`. Thread and signal + * routes are not addressable without the account id. + * + * Operations the app previously declared that this API does not provide, and + * which are therefore absent here: read/unread marking, folders, top-level + * drafts, attachment download, send cancellation, MFA device management, + * billing, and support tickets. */ interface EmailApiService { - @GET("v1/accounts") - suspend fun getAccounts(): List + // ── Accounts ──────────────────────────────────────────────────────────── - @GET("v1/accounts/{accountId}/aliases") - suspend fun getAliases(@Path("accountId") accountId: String): List + @GET("accounts") + suspend fun getAccounts(): AccountListResponse - @GET("v1/accounts/{accountId}/threads") + @GET("accounts/{accountId}") + suspend fun getAccount(@Path("accountId") accountId: String): AccountDto + + @PATCH("accounts/{accountId}") + suspend fun patchAccount( + @Path("accountId") accountId: String, + @Body body: PatchAccountRequest, + ): AccountDto + + // ── Aliases ───────────────────────────────────────────────────────────── + + @GET("accounts/{accountId}/aliases") + suspend fun getAliases( + @Path("accountId") accountId: String, + @Query("domain") domain: String? = null, + ): AliasListResponse + + @PATCH("accounts/{accountId}/aliases/{address}") + suspend fun patchAlias( + @Path("accountId") accountId: String, + @Path("address") address: String, + @Body body: PatchAliasRequest, + ): AliasDto + + @GET("accounts/{accountId}/aliases/{address}/senders") + suspend fun getAliasSenders( + @Path("accountId") accountId: String, + @Path("address") address: String, + ): AliasSenderListResponse + + /** Sender-domain policy. This is how a sender is blocked or approved. */ + @PUT("accounts/{accountId}/aliases/{address}/senders/{domain}") + suspend fun setAliasSenderPolicy( + @Path("accountId") accountId: String, + @Path("address") address: String, + @Path("domain") domain: String, + @Body body: SetAliasSenderRequest, + ): AliasSenderDto + + @DELETE("accounts/{accountId}/aliases/{address}/senders/{domain}") + suspend fun deleteAliasSenderPolicy( + @Path("accountId") accountId: String, + @Path("address") address: String, + @Path("domain") domain: String, + ): Response + + // ── Threads ───────────────────────────────────────────────────────────── + + @GET("accounts/{accountId}/threads") suspend fun getThreads( @Path("accountId") accountId: String, - @Query("folder") folder: String, + @Query("workflow") workflow: String? = null, + @Query("label") label: String? = null, + @Query("status") status: String? = null, + @Query("q") query: String? = null, @Query("cursor") cursor: String? = null, - @Query("since") since: Long? = null, - ): ThreadPage + @Query("limit") limit: Int? = null, + @Query("refresh") refresh: String? = null, + ): ThreadListResponse - @GET("v1/threads/{threadId}") - suspend fun getThread(@Path("threadId") threadId: String): ThreadDto + @GET("accounts/{accountId}/threads/{threadId}") + suspend fun getThread( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + ): ThreadDto - @GET("v1/threads/{threadId}/messages") - suspend fun getMessages(@Path("threadId") threadId: String): List + /** Archive, delete, relabel and set follow-up all go through this one call. */ + @PATCH("accounts/{accountId}/threads/{threadId}") + suspend fun patchThread( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Body body: PatchThreadRequest, + ): ThreadDto - @PATCH("v1/threads/{threadId}") - suspend fun moveThread(@Path("threadId") threadId: String, @Body request: MoveThreadRequest): ThreadDto + @POST("accounts/{accountId}/threads/{threadId}/unsubscribe") + suspend fun unsubscribeThread( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + ): UnsubscribeResultDto - @POST("v1/threads/{threadId}/read") - suspend fun markRead(@Path("threadId") threadId: String) + // ── Signals ───────────────────────────────────────────────────────────── - @DELETE("v1/threads/{threadId}") - suspend fun deleteThread(@Path("threadId") threadId: String) + @GET("accounts/{accountId}/threads/{threadId}/signals") + suspend fun getThreadSignals( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): SignalListResponse - @POST("v1/threads/{threadId}/labels/{labelId}") - suspend fun addLabel(@Path("threadId") threadId: String, @Path("labelId") labelId: String) + /** Account-wide signal listing. `status` is required — see SignalStatus. */ + @GET("accounts/{accountId}/signals") + suspend fun getSignals( + @Path("accountId") accountId: String, + @Query("status") status: String, + @Query("cursor") cursor: String? = null, + @Query("limit") limit: Int? = null, + ): SignalListResponse - @DELETE("v1/threads/{threadId}/labels/{labelId}") - suspend fun removeLabel(@Path("threadId") threadId: String, @Path("labelId") labelId: String) + /** Creates a draft signal on a thread. Drafts are signals with status "draft". */ + @POST("accounts/{accountId}/threads/{threadId}/signals") + suspend fun createDraftSignal( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Body body: CreateDraftSignalRequest, + ): SignalDto - @POST("v1/threads/{threadId}/unsubscribe") - suspend fun unsubscribe(@Path("threadId") threadId: String) + @PUT("accounts/{accountId}/threads/{threadId}/signals/{signalId}") + suspend fun updateDraftSignal( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Path("signalId") signalId: String, + @Body body: UpdateDraftSignalRequest, + ): SignalDto - @POST("v1/threads/{threadId}/block-sender") - suspend fun blockSender(@Path("threadId") threadId: String) + @PATCH("accounts/{accountId}/threads/{threadId}/signals/{signalId}") + suspend fun patchSignal( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Path("signalId") signalId: String, + @Body body: PatchSignalRequest, + ): SignalDto - @POST("v1/threads/{threadId}/quarantine/approve") - suspend fun approveQuarantine(@Path("threadId") threadId: String) + @DELETE("accounts/{accountId}/threads/{threadId}/signals/{signalId}") + suspend fun deleteSignal( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Path("signalId") signalId: String, + ): Response - @POST("v1/threads/{threadId}/quarantine/reject") - suspend fun rejectQuarantine(@Path("threadId") threadId: String) + @POST("accounts/{accountId}/threads/{threadId}/signals/{signalId}/send") + suspend fun sendSignal( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Path("signalId") signalId: String, + ): Response - @GET("v1/accounts/{accountId}/labels") - suspend fun getLabels(@Path("accountId") accountId: String): List + @POST("accounts/{accountId}/threads/{threadId}/signals/{signalId}/rsvp") + suspend fun rsvpSignal( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Path("signalId") signalId: String, + ): SignalDto - @POST("v1/accounts/{accountId}/labels") - suspend fun createLabel(@Path("accountId") accountId: String, @Body label: LabelDto): LabelDto + @POST("accounts/{accountId}/threads/{threadId}/signals/{signalId}/reprocess") + suspend fun reprocessSignal( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Path("signalId") signalId: String, + ): SignalDto - @PUT("v1/labels/{labelId}") - suspend fun updateLabel(@Path("labelId") labelId: String, @Body label: LabelDto): LabelDto + @Streaming + @GET("accounts/{accountId}/threads/{threadId}/signals/{signalId}/raw") + suspend fun getRawSignal( + @Path("accountId") accountId: String, + @Path("threadId") threadId: String, + @Path("signalId") signalId: String, + ): ResponseBody - @DELETE("v1/labels/{labelId}") - suspend fun deleteLabel(@Path("labelId") labelId: String) + /** Approve or reject a quarantined signal. */ + @POST("accounts/{accountId}/signals/{signalId}/quarantineResponse") + suspend fun respondToQuarantine( + @Path("accountId") accountId: String, + @Path("signalId") signalId: String, + @Body body: QuarantineResponseRequest, + ): Response - @GET("v1/accounts/{accountId}/drafts") - suspend fun getDrafts(@Path("accountId") accountId: String): List + // ── Labels ────────────────────────────────────────────────────────────── - @PUT("v1/drafts/{draftId}") - suspend fun saveDraft(@Path("draftId") draftId: String, @Body draft: DraftDto): DraftDto + @GET("accounts/{accountId}/labels") + suspend fun getLabels(@Path("accountId") accountId: String): LabelListResponse - @DELETE("v1/drafts/{draftId}") - suspend fun deleteDraft(@Path("draftId") draftId: String) + @POST("accounts/{accountId}/labels") + suspend fun createLabel( + @Path("accountId") accountId: String, + @Body body: CreateLabelRequest, + ): LabelDto - @POST("v1/messages/send") - suspend fun sendMessage(@Body request: SendMessageRequest): MessageDto + @PATCH("accounts/{accountId}/labels/{labelId}") + suspend fun patchLabel( + @Path("accountId") accountId: String, + @Path("labelId") labelId: String, + @Body body: PatchLabelRequest, + ): LabelDto - @POST("v1/messages/{messageId}/cancel-send") - suspend fun cancelSend(@Path("messageId") messageId: String): Response + @DELETE("accounts/{accountId}/labels/{labelId}") + suspend fun deleteLabel( + @Path("accountId") accountId: String, + @Path("labelId") labelId: String, + ): Response - @Streaming - @GET("v1/messages/{messageId}/attachments/{attachmentId}/download") - suspend fun downloadAttachment( - @Path("messageId") messageId: String, - @Path("attachmentId") attachmentId: String, - ): Response + // ── Rules ─────────────────────────────────────────────────────────────── - @GET("v1/accounts/{accountId}/rules") - suspend fun getRules(@Path("accountId") accountId: String): List + @GET("accounts/{accountId}/rules") + suspend fun getRules(@Path("accountId") accountId: String): RuleListResponse - @PATCH("v1/rules/{ruleId}") - suspend fun setRuleEnabled(@Path("ruleId") ruleId: String, @Body body: Map): RuleDto + @POST("accounts/{accountId}/rules") + suspend fun createRule( + @Path("accountId") accountId: String, + @Body body: CreateRuleRequest, + ): RuleDto - @GET("v1/accounts/{accountId}/templates") - suspend fun getTemplates(@Path("accountId") accountId: String): List + @PATCH("accounts/{accountId}/rules/{ruleId}") + suspend fun patchRule( + @Path("accountId") accountId: String, + @Path("ruleId") ruleId: String, + @Body body: PatchRuleRequest, + ): RuleDto - @GET("v1/accounts/{accountId}/dns-records") - suspend fun getDnsRecords(@Path("accountId") accountId: String): List + @DELETE("accounts/{accountId}/rules/{ruleId}") + suspend fun deleteRule( + @Path("accountId") accountId: String, + @Path("ruleId") ruleId: String, + ): Response - @POST("v1/accounts/{accountId}/dns-records/verify") - suspend fun verifyDnsRecords(@Path("accountId") accountId: String): List + // ── Templates ─────────────────────────────────────────────────────────── - @GET("v1/accounts/{accountId}/forwarding-addresses") - suspend fun getForwardingAddresses(@Path("accountId") accountId: String): List + @GET("accounts/{accountId}/templates") + suspend fun getTemplates(@Path("accountId") accountId: String): TemplateListResponse - @POST("v1/accounts/{accountId}/forwarding-addresses") - suspend fun addForwardingAddress(@Path("accountId") accountId: String, @Body body: Map): ForwardingAddressDto + @POST("accounts/{accountId}/templates") + suspend fun createTemplate( + @Path("accountId") accountId: String, + @Body body: UpsertTemplateRequest, + ): EmailTemplateDto - @DELETE("v1/forwarding-addresses/{id}") - suspend fun removeForwardingAddress(@Path("id") id: String) + @PUT("accounts/{accountId}/templates/{templateId}") + suspend fun updateTemplate( + @Path("accountId") accountId: String, + @Path("templateId") templateId: String, + @Body body: UpsertTemplateRequest, + ): EmailTemplateDto - @GET("v1/security/mfa-devices") - suspend fun getMfaDevices(): List + @DELETE("accounts/{accountId}/templates/{templateId}") + suspend fun deleteTemplate( + @Path("accountId") accountId: String, + @Path("templateId") templateId: String, + ): Response - @DELETE("v1/security/mfa-devices/{id}") - suspend fun removeMfaDevice(@Path("id") id: String) + // ── Views ─────────────────────────────────────────────────────────────── - @GET("v1/accounts/{accountId}/team") - suspend fun getTeamMembers(@Path("accountId") accountId: String): List + @GET("accounts/{accountId}/views") + suspend fun getViews(@Path("accountId") accountId: String): ViewListResponse - @GET("v1/accounts/{accountId}/billing") - suspend fun getPlanInfo(@Path("accountId") accountId: String): PlanInfoDto + // ── Domains and forwarding ────────────────────────────────────────────── - @GET("v1/accounts/{accountId}/stats") - suspend fun getStats(@Path("accountId") accountId: String): StatsSummaryDto + @GET("accounts/{accountId}/domains") + suspend fun getDomains(@Path("accountId") accountId: String): DomainListResponse - @POST("v1/support/tickets") - suspend fun submitSupportTicket(@Body request: SupportTicketRequest): Response + @GET("accounts/{accountId}/domains/{domainId}") + suspend fun getDomain( + @Path("accountId") accountId: String, + @Path("domainId") domainId: String, + ): DomainWithRecordsDto - @GET("v1/admin/health") - suspend fun getHealthCheck(): HealthCheckDto + @GET("accounts/{accountId}/forwarding-addresses") + suspend fun getForwardingTargets( + @Path("accountId") accountId: String, + ): ForwardingTargetListResponse - @POST("v1/admin/threads/{threadId}/reprocess") - suspend fun reprocessThread(@Path("threadId") threadId: String): ThreadDto + @POST("accounts/{accountId}/forwarding-addresses") + suspend fun addForwardingTarget( + @Path("accountId") accountId: String, + @Body body: CreateForwardingTargetRequest, + ): ForwardingTargetDto - @Streaming - @GET("v1/admin/threads/{threadId}/raw") - suspend fun getRawEmail(@Path("threadId") threadId: String): Response + @DELETE("accounts/{accountId}/forwarding-addresses/{address}") + suspend fun removeForwardingTarget( + @Path("accountId") accountId: String, + @Path("address") address: String, + ): Response + + @POST("accounts/{accountId}/forwarding-addresses/{address}/verify") + suspend fun verifyForwardingTarget( + @Path("accountId") accountId: String, + @Path("address") address: String, + ): ForwardingTargetDto + + // ── Users and configuration ───────────────────────────────────────────── + + @GET("accounts/{accountId}/users") + suspend fun getAccountUsers( + @Path("accountId") accountId: String, + ): AccountUserListResponse + + @GET("user/{userId}/configuration") + suspend fun getUserConfiguration( + @Path("userId") userId: String, + ): UserConfigurationDto + + @PATCH("user/{userId}/configuration") + suspend fun patchUserConfiguration( + @Path("userId") userId: String, + @Body body: UserConfigurationDto, + ): UserConfigurationDto + + // ── Stats and health ──────────────────────────────────────────────────── + + @GET("accounts/{accountId}/stats") + suspend fun getStats(@Path("accountId") accountId: String): Map + + @GET("healthcheck") + suspend fun getHealthCheck(): HealthCheckDto } diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/AccountDtos.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/AccountDtos.kt index bd406d8..9b68442 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/dto/AccountDtos.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/AccountDtos.kt @@ -4,20 +4,92 @@ import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) data class AccountDto( - val id: String, - val emailAddress: String, - val displayName: String, - val avatarUrl: String?, - val isPrimary: Boolean, - val domain: String, + val accountId: String, + val name: String, + val retentionDuration: String? = null, + val digest: DigestDto? = null, + val filtering: AccountFilteringConfigDto, + val onboarding: AccountOnboardingDto? = null, + // The API exposes the plan name but has no billing endpoints; there is + // nothing to manage from the client. + val billingPlan: String? = null, + val afterSendAction: String? = null, + val defaultCalendarInviteForwardingTargetId: String? = null, + val createdAt: String, + val updatedAt: String, +) + +@JsonClass(generateAdapter = true) +data class DigestDto( + val frequency: String, + val forwardingTargetId: String, +) + +@JsonClass(generateAdapter = true) +data class AccountFilteringConfigDto( + val defaultUnknownSenderPolicy: String, +) + +@JsonClass(generateAdapter = true) +data class AccountOnboardingDto( + val completed: Boolean, + val completedAt: String? = null, + val testEmailReceived: Boolean? = null, + val testEmailReceivedAt: String? = null, +) + +@JsonClass(generateAdapter = true) +data class AccountListResponse( + val accounts: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class PatchAccountRequest( + val name: String? = null, + val retentionDuration: String? = null, + val afterSendAction: String? = null, ) @JsonClass(generateAdapter = true) data class AliasDto( - val id: String, - val accountId: String, - val emailAddress: String, - val displayName: String, - val isDefault: Boolean, - val isVerified: Boolean, + val alias: String, + val unknownSenderPolicy: String, + val createdAt: String, + val updatedAt: String, ) + +@JsonClass(generateAdapter = true) +data class AliasListResponse( + val aliases: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class PatchAliasRequest( + val unknownSenderPolicy: String? = null, +) + +/** Per-sender-domain override on an alias. Replaces the old "block sender" call. */ +@JsonClass(generateAdapter = true) +data class AliasSenderDto( + val domain: String, + val policy: String, +) + +@JsonClass(generateAdapter = true) +data class AliasSenderListResponse( + val senders: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class SetAliasSenderRequest( + val policy: String, +) + +object UnknownSenderPolicy { + const val ALLOW_ALL = "allow_all" + const val QUARANTINE_VISIBLE = "quarantine_visible" + const val QUARANTINE_HIDDEN = "quarantine_hidden" + const val BLOCK_HIDDEN = "block_hidden" + const val BLOCK_REJECT = "block_reject" + const val REPORT_VIOLATION = "report_violation" +} diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/CommonDtos.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/CommonDtos.kt new file mode 100644 index 0000000..57a201c --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/CommonDtos.kt @@ -0,0 +1,45 @@ +package ch.rhosys.email.data.remote.dto + +import com.squareup.moshi.JsonClass + +/** + * Wire types shared across endpoints, transcribed from the OpenAPI document at + * https://email.rhosys.cloud/.well-known/api-catalog (SES Email Adapter 1.0.0). + * + * Timestamps are ISO-8601 strings on the wire, not epoch millis. They are kept + * as String here and parsed at the domain boundary so a malformed value from the + * backend degrades one field instead of failing the whole response. + */ + +@JsonClass(generateAdapter = true) +data class EmailAddressDto( + val address: String, + val name: String? = null, +) + +@JsonClass(generateAdapter = true) +data class PaginationDto( + val cursor: String?, +) + +@JsonClass(generateAdapter = true) +data class AttachmentDto( + val filename: String, + val mimeType: String, + val sizeBytes: Double, + // Present only when the backend exposes a fetchable location. There is no + // attachment download endpoint in the API, so this is the only way to reach one. + val url: String? = null, +) + +@JsonClass(generateAdapter = true) +data class UnsubscribeInfoDto( + val type: String, + val url: String, +) + +@JsonClass(generateAdapter = true) +data class UnsubscribeResultDto( + val status: String, + val url: String? = null, +) diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/OtherDtos.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/OtherDtos.kt index f7675fa..01cb1e7 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/dto/OtherDtos.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/OtherDtos.kt @@ -2,56 +2,231 @@ package ch.rhosys.email.data.remote.dto import com.squareup.moshi.JsonClass +// ── Labels ────────────────────────────────────────────────────────────────── + @JsonClass(generateAdapter = true) -data class LabelDto(val id: String, val accountId: String, val name: String, val color: String, val emoji: String?) +data class LabelDto( + // The stable identifier is `label`; `name` is the display string. + val label: String, + val name: String, + val color: String? = null, + val icon: String? = null, + val createdAt: String, +) @JsonClass(generateAdapter = true) -data class DraftDto( - val id: String, - val accountId: String, - val threadId: String?, - val fromAlias: String, - val toAddresses: List, - val ccAddresses: List, - val bccAddresses: List, +data class LabelListResponse( + val labels: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class CreateLabelRequest( + val name: String, + val color: String? = null, + val icon: String? = null, +) + +@JsonClass(generateAdapter = true) +data class PatchLabelRequest( + val name: String? = null, + val color: String? = null, + val icon: String? = null, +) + +// ── Rules ─────────────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class RuleDto( + val ruleId: String, + val name: String, + val condition: String? = null, + val conditionType: String? = null, + val actions: List = emptyList(), + val status: String, + val priorityOrder: Double, + val type: String? = null, + val createdAt: String, + val updatedAt: String, +) + +@JsonClass(generateAdapter = true) +data class RuleActionDto( + val type: String, + val value: String? = null, +) + +@JsonClass(generateAdapter = true) +data class RuleListResponse( + val rules: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class CreateRuleRequest( + val name: String, + val status: String? = null, + val condition: String? = null, + val conditionType: String? = null, + val actions: List, +) + +@JsonClass(generateAdapter = true) +data class PatchRuleRequest( + val name: String? = null, + val status: String? = null, + val condition: String? = null, + val conditionType: String? = null, + val actions: List? = null, + val priorityOrder: Double? = null, +) + +object RuleStatus { + const val ENABLED = "enabled" + const val DISABLED = "disabled" +} + +// ── Templates ─────────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class EmailTemplateDto( + val templateId: String, + val name: String, val subject: String, - val bodyMarkdown: String, - val updatedAt: Long, + val body: String, + val createdAt: String, + val updatedAt: String, ) @JsonClass(generateAdapter = true) -data class RuleDto(val id: String, val accountId: String, val name: String, val description: String, val isEnabled: Boolean) +data class TemplateListResponse( + val templates: List = emptyList(), +) @JsonClass(generateAdapter = true) -data class TemplateDto(val id: String, val accountId: String, val name: String, val subject: String, val bodyMarkdown: String) +data class UpsertTemplateRequest( + val name: String, + val subject: String, + val body: String, +) + +// ── Domains (formerly modelled as "dns-records") ──────────────────────────── + +@JsonClass(generateAdapter = true) +data class DomainDto( + val domainId: String, + val domain: String, + val receivingSetupComplete: Boolean, + val senderSetupComplete: Boolean, + val createdAt: String, + val updatedAt: String, +) @JsonClass(generateAdapter = true) -data class DnsRecordDto(val type: String, val name: String, val value: String, val isVerified: Boolean) +data class DnsRecordDto( + val name: String, + val type: String, + val value: String, + val currentValue: String? = null, + val status: String, +) @JsonClass(generateAdapter = true) -data class ForwardingAddressDto(val id: String, val emailAddress: String, val isVerified: Boolean) +data class DomainWithRecordsDto( + val domainId: String? = null, + val domain: String? = null, + val receivingSetupComplete: Boolean? = null, + val senderSetupComplete: Boolean? = null, + val records: List = emptyList(), +) @JsonClass(generateAdapter = true) -data class MfaDeviceDto(val id: String, val label: String, val type: String, val addedAt: Long) +data class DomainListResponse( + val domains: List = emptyList(), +) + +// ── Forwarding targets ────────────────────────────────────────────────────── @JsonClass(generateAdapter = true) -data class TeamMemberDto(val id: String, val emailAddress: String, val role: String) +data class ForwardingTargetDto( + val target: String, + val type: String, + val status: String, + val createdAt: String, + val verifiedAt: String? = null, +) @JsonClass(generateAdapter = true) -data class PlanInfoDto(val planName: String, val emailsUsed: Int, val emailsQuota: Int, val renewsAt: Long) +data class ForwardingTargetListResponse( + val forwardingTargets: List = emptyList(), +) @JsonClass(generateAdapter = true) -data class StatsSummaryDto( - val dailyVolume: List, - val monthlyVolume: List, - val workflowBreakdown: Map, +data class CreateForwardingTargetRequest( + val target: String, + val type: String, ) +// ── Users (formerly modelled as "team") ───────────────────────────────────── + @JsonClass(generateAdapter = true) -data class StatsPointDto(val label: String, val count: Int) +data class AccountUserDto( + val userId: String, + val role: String? = null, + val name: String? = null, + val email: String? = null, + val picture: String? = null, +) @JsonClass(generateAdapter = true) -data class SupportTicketRequest(val category: String, val description: String) +data class AccountUserListResponse( + val users: List = emptyList(), + val pagination: PaginationDto? = null, +) + +// ── Views ─────────────────────────────────────────────────────────────────── @JsonClass(generateAdapter = true) -data class HealthCheckDto(val status: String, val checkedAt: Long, val details: Map) +data class ViewDto( + val viewId: String, + val name: String, + val icon: String? = null, + val color: String? = null, + val workflow: String? = null, + val labels: List = emptyList(), + val sortField: String, + val sortDirection: String, + val position: Double, + val createdAt: String, + val updatedAt: String, +) + +@JsonClass(generateAdapter = true) +data class ViewListResponse( + val views: List = emptyList(), +) + +// ── User configuration ────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class UserConfigurationDto( + val notifications: Map? = null, + val preferences: Map? = null, +) + +// ── Health check ──────────────────────────────────────────────────────────── + +@JsonClass(generateAdapter = true) +data class HealthCheckDto( + val status: String, + val checkedAt: String? = null, + val checkedDate: String? = null, + val checks: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class HealthCheckItemDto( + val id: String, + val label: String? = null, + val status: String, + val detail: String? = null, + val section: String? = null, +) diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/SignalDtos.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/SignalDtos.kt new file mode 100644 index 0000000..a618c3a --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/SignalDtos.kt @@ -0,0 +1,228 @@ +package ch.rhosys.email.data.remote.dto + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonClass +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import java.lang.reflect.Type + +/** + * A Signal is what the backend calls an item on a thread. It is a polymorphic + * union of ten variants discriminated by `type` — this replaces the old flat + * "message" model, which the API has no concept of. + * + * `type` separates every variant except inbound and outbound email, which both + * report `type = "email"`. Those two are told apart by their payload: + * outbound carries `sendInitiatedAt`, inbound carries `receivedAt`. + * See [SignalDtoAdapter]. + */ +sealed interface SignalDto { + val signalId: String + val threadId: String? + val source: String + val status: String + val createdAt: String + val type: String +} + +@JsonClass(generateAdapter = true) +data class EmailInboundSignalDto( + override val signalId: String, + override val threadId: String?, + override val source: String, + override val status: String, + override val createdAt: String, + override val type: String = SignalTypes.EMAIL, + val data: InboundEmailSignalDataDto, +) : SignalDto + +@JsonClass(generateAdapter = true) +data class EmailOutboundSignalDto( + override val signalId: String, + override val threadId: String?, + override val source: String, + override val status: String, + override val createdAt: String, + override val type: String = SignalTypes.EMAIL, + val data: OutboundEmailSignalDataDto, +) : SignalDto + +/** + * Every non-email variant (deliverability, calendar_event, calendar_response, + * calendar_invite_invalid, auto_send_blocked, domain_misconfiguration, + * invalid_rule_function, invalid_template_function) plus anything the backend + * adds later. The payload is kept untyped so an unrecognised signal renders as a + * system notice instead of failing the whole thread. + */ +@JsonClass(generateAdapter = true) +data class SystemSignalDto( + override val signalId: String, + override val threadId: String?, + override val source: String, + override val status: String, + override val createdAt: String, + override val type: String, + val data: Map = emptyMap(), +) : SignalDto + +@JsonClass(generateAdapter = true) +data class InboundEmailSignalDataDto( + val receivedAt: String, + val summary: String, + val urgency: String? = null, + val from: EmailAddressDto, + val to: List = emptyList(), + val cc: List = emptyList(), + val replyTo: EmailAddressDto? = null, + val subject: String, + val body: String? = null, + val attachments: List = emptyList(), + val recipientAddress: String, + val workflow: String, + val unsubscribe: UnsubscribeInfoDto? = null, +) + +@JsonClass(generateAdapter = true) +data class OutboundEmailSignalDataDto( + val from: EmailAddressDto, + val to: List = emptyList(), + val cc: List = emptyList(), + val bcc: List = emptyList(), + val replyTo: EmailAddressDto? = null, + val subject: String, + val body: String? = null, + val attachments: List = emptyList(), + val sentAt: String? = null, + val sendInitiatedAt: String, + val sendFailureReason: String? = null, +) + +@JsonClass(generateAdapter = true) +data class SignalListResponse( + val signals: List = emptyList(), + val pagination: PaginationDto? = null, +) + +/** Request body for creating a draft signal on a thread. */ +@JsonClass(generateAdapter = true) +data class CreateDraftSignalRequest( + val from: EmailAddressDto, + val to: List, + val subject: String, + val textBody: String? = null, +) + +/** Request body for updating an existing draft signal (PUT). */ +@JsonClass(generateAdapter = true) +data class UpdateDraftSignalRequest( + val from: EmailAddressDto? = null, + val subject: String? = null, + val textBody: String? = null, +) + +/** Request body for PATCHing a signal's status, e.g. archiving or blocking. */ +@JsonClass(generateAdapter = true) +data class PatchSignalRequest( + val status: String, +) + +/** Request body for responding to a quarantined signal. */ +@JsonClass(generateAdapter = true) +data class QuarantineResponseRequest( + val status: String, +) + +object SignalTypes { + const val EMAIL = "email" +} + +/** + * Signal statuses, from the OpenAPI `status` enum. Drafts, blocking and + * quarantine are statuses on a signal rather than separate endpoints. + */ +object SignalStatus { + const val ACTIVE = "active" + const val BLOCK_HIDDEN = "block_hidden" + const val BLOCK_REJECT = "block_reject" + const val REPORT_VIOLATION = "report_violation" + const val QUARANTINE_VISIBLE = "quarantine_visible" + const val QUARANTINE_HIDDEN = "quarantine_hidden" + const val DRAFT = "draft" + const val PENDING_SEND = "pending_send" + const val SENT = "sent" +} + +/** + * Resolves the [SignalDto] variant. Moshi cannot dispatch on a sibling field, so + * the object is buffered via peekJson and inspected before delegating to the + * concrete adapter — the reader is left untouched for the real read. + */ +class SignalDtoAdapter(moshi: Moshi) : JsonAdapter() { + + private val inbound = moshi.adapter(EmailInboundSignalDto::class.java) + private val outbound = moshi.adapter(EmailOutboundSignalDto::class.java) + private val system = moshi.adapter(SystemSignalDto::class.java) + + override fun fromJson(reader: JsonReader): SignalDto? { + val peeked = reader.peekJson() + peeked.setFailOnUnknown(false) + val envelope = readEnvelope(peeked) + return when { + envelope.type != SignalTypes.EMAIL -> system.fromJson(reader) + envelope.isOutbound -> outbound.fromJson(reader) + else -> inbound.fromJson(reader) + } + } + + override fun toJson(writer: JsonWriter, value: SignalDto?) { + when (value) { + null -> writer.nullValue() + is EmailInboundSignalDto -> inbound.toJson(writer, value) + is EmailOutboundSignalDto -> outbound.toJson(writer, value) + is SystemSignalDto -> system.toJson(writer, value) + } + } + + private data class Envelope(val type: String, val isOutbound: Boolean) + + private fun readEnvelope(reader: JsonReader): Envelope { + var type = "" + var isOutbound = false + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "type" -> type = reader.nextString() + // Only the outbound payload carries sendInitiatedAt. + "data" -> isOutbound = dataHasSendInitiatedAt(reader) + else -> reader.skipValue() + } + } + reader.endObject() + return Envelope(type, isOutbound) + } + + private fun dataHasSendInitiatedAt(reader: JsonReader): Boolean { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return false + } + var found = false + reader.beginObject() + while (reader.hasNext()) { + if (reader.nextName() == "sendInitiatedAt") found = true + reader.skipValue() + } + reader.endObject() + return found + } + + companion object Factory : JsonAdapter.Factory { + override fun create(type: Type, annotations: Set, moshi: Moshi): JsonAdapter<*>? { + if (annotations.isNotEmpty()) return null + if (Types.getRawType(type) != SignalDto::class.java) return null + return SignalDtoAdapter(moshi).nullSafe() + } + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/ThreadDtos.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/ThreadDtos.kt index dd304d3..76d384b 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/dto/ThreadDtos.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/ThreadDtos.kt @@ -2,67 +2,69 @@ package ch.rhosys.email.data.remote.dto import com.squareup.moshi.JsonClass +/** + * A thread as the backend models it. Note what is deliberately absent versus the + * previous hand-written model: there is no `folder`, no `isRead`, no `snippet` + * and no `participants`. Read/unread does not exist in this API at all, and + * foldering is expressed through [ThreadStatus]. + */ @JsonClass(generateAdapter = true) data class ThreadDto( - val id: String, - val accountId: String, + val threadId: String, + val workflow: String, + val labels: List = emptyList(), + val status: String, + val summary: String, + // Null once a thread has no signals left; such threads are hidden from the inbox. + val lastSignalAt: String? = null, + val deletedAt: String? = null, + val createdAt: String, + val updatedAt: String, + val retentionDuration: String? = null, + val urgency: String? = null, + val followupAt: String? = null, + val sender: EmailAddressDto, + val recipientAddress: String, val subject: String, - val snippet: String, - val participants: List, - val lastMessageAt: Long, - val isRead: Boolean, - val folder: String, - val labelIds: List, - val followupAt: Long?, - val workflowType: String, - val workflowFields: Map = emptyMap(), - val isBlockedSender: Boolean = false, - val unsubscribeUrl: String? = null, ) @JsonClass(generateAdapter = true) -data class ThreadPage( - val items: List, - val nextCursor: String?, +data class ThreadListResponse( + val threads: List = emptyList(), + val pagination: PaginationDto? = null, ) +/** + * PATCH body for a thread. Archiving, deleting, relabelling and setting a + * follow-up all go through here — there are no dedicated endpoints for them. + */ @JsonClass(generateAdapter = true) -data class MessageDto( - val id: String, - val threadId: String, - val fromAddress: String, - val toAddresses: List, - val ccAddresses: List, - val bodyMarkdown: String, - val bodyHtml: String?, - val sentAt: Long, - val deliveryStatus: String, - val attachments: List = emptyList(), +data class PatchThreadRequest( + val status: String? = null, + val labels: List? = null, + val followupAt: String? = null, ) -@JsonClass(generateAdapter = true) -data class AttachmentDto( - val id: String, - val messageId: String, - val filename: String, - val mimeType: String, - val sizeBytes: Long, -) +object ThreadStatus { + const val ACTIVE = "active" + const val ARCHIVED = "archived" + const val DELETED = "deleted" + const val REPORT_VIOLATION = "report_violation" +} -@JsonClass(generateAdapter = true) -data class SendMessageRequest( - val fromAlias: String, - val toAddresses: List, - val ccAddresses: List, - val bccAddresses: List, - val subject: String, - val bodyMarkdown: String, - val inReplyToThreadId: String?, - val sendAfter: Long?, -) +/** Workflow classifications the backend assigns to a thread. */ +object Workflow { + val ALL = listOf( + "auth", "conversation", "crm", "package", "travel", "payments", + "alert", "content", "onboarding", "notice", "healthcare", "job", + "support", "test", "events", + ) +} -@JsonClass(generateAdapter = true) -data class MoveThreadRequest( - val folder: String, - val followupAt: Long?, -) +object ThreadUrgency { + const val CRITICAL = "critical" + const val HIGH = "high" + const val NORMAL = "normal" + const val LOW = "low" + const val SILENT = "silent" +} diff --git a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt index 4f16aa3..f09e621 100644 --- a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt +++ b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt @@ -42,7 +42,11 @@ class AppContainer(private val context: Context) { val authManager: AuthressAuthManager by lazy { AuthressAuthManager(context, tokenStore) } private val moshi: Moshi by lazy { - Moshi.Builder().build() + // SignalDto is a polymorphic union discriminated by `type`; Moshi needs the + // factory to pick the concrete variant before deserializing. + Moshi.Builder() + .add(ch.rhosys.email.data.remote.dto.SignalDtoAdapter.Factory) + .build() } private val okHttpClient: OkHttpClient by lazy { From 6cfde775ae15471cd61ad1232fcc2d7713a100b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:21:07 +0000 Subject: [PATCH 04/10] Rewrite domain model, Room schema and DAOs around threads and signals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- .../rhosys/email/data/local/EmailDatabase.kt | 29 ++- .../rhosys/email/data/local/dao/MessageDao.kt | 33 --- .../rhosys/email/data/local/dao/OtherDaos.kt | 52 ++--- .../rhosys/email/data/local/dao/SignalDao.kt | 45 ++++ .../rhosys/email/data/local/dao/ThreadDao.kt | 46 ++-- .../email/data/local/entity/AccountEntity.kt | 70 ++++-- .../email/data/local/entity/MessageEntity.kt | 50 ----- .../email/data/local/entity/OtherEntities.kt | 141 +++++++++--- .../email/data/local/entity/SignalEntity.kt | 173 +++++++++++++++ .../email/data/local/entity/ThreadEntity.kt | 89 ++++---- .../rhosys/email/data/remote/dto/Mappers.kt | 170 +++++++++++++++ .../ch/rhosys/email/domain/model/Account.kt | 84 ++++++-- .../ch/rhosys/email/domain/model/Label.kt | 83 ++++++-- .../rhosys/email/domain/model/MailThread.kt | 201 +++++++++++++++--- .../email/domain/repository/Repositories.kt | 90 +++++--- 15 files changed, 1029 insertions(+), 327 deletions(-) delete mode 100644 app/src/main/java/ch/rhosys/email/data/local/dao/MessageDao.kt create mode 100644 app/src/main/java/ch/rhosys/email/data/local/dao/SignalDao.kt delete mode 100644 app/src/main/java/ch/rhosys/email/data/local/entity/MessageEntity.kt create mode 100644 app/src/main/java/ch/rhosys/email/data/local/entity/SignalEntity.kt create mode 100644 app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt diff --git a/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt b/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt index 861d09a..4ff4572 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/EmailDatabase.kt @@ -4,40 +4,47 @@ import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters import ch.rhosys.email.data.local.dao.AccountDao -import ch.rhosys.email.data.local.dao.DraftDao import ch.rhosys.email.data.local.dao.LabelDao -import ch.rhosys.email.data.local.dao.MessageDao import ch.rhosys.email.data.local.dao.RuleDao +import ch.rhosys.email.data.local.dao.SignalDao import ch.rhosys.email.data.local.dao.TemplateDao import ch.rhosys.email.data.local.dao.ThreadDao +import ch.rhosys.email.data.local.dao.ViewDao import ch.rhosys.email.data.local.entity.AccountEntity import ch.rhosys.email.data.local.entity.AliasEntity -import ch.rhosys.email.data.local.entity.AttachmentEntity -import ch.rhosys.email.data.local.entity.DraftEntity import ch.rhosys.email.data.local.entity.LabelEntity -import ch.rhosys.email.data.local.entity.MessageEntity import ch.rhosys.email.data.local.entity.RuleEntity +import ch.rhosys.email.data.local.entity.SignalEntity import ch.rhosys.email.data.local.entity.TemplateEntity import ch.rhosys.email.data.local.entity.ThreadEntity +import ch.rhosys.email.data.local.entity.ViewEntity +/** + * Version 2 replaces the fabricated schema (messages, attachments, drafts, with + * folder/isRead columns) with one that matches the backend: signals in place of + * messages, drafts as a signal status, and thread status in place of folders. + * + * There is no migration from version 1. The v1 schema described an API that does + * not exist, so nothing cached under it is meaningful — fallbackToDestructiveMigration + * is set in AppContainer and the cache simply refetches. + */ @Database( entities = [ - AccountEntity::class, AliasEntity::class, ThreadEntity::class, MessageEntity::class, - AttachmentEntity::class, LabelEntity::class, DraftEntity::class, RuleEntity::class, - TemplateEntity::class, + AccountEntity::class, AliasEntity::class, ThreadEntity::class, SignalEntity::class, + LabelEntity::class, RuleEntity::class, TemplateEntity::class, ViewEntity::class, ], - version = 1, + version = 2, exportSchema = true, ) @TypeConverters(Converters::class) abstract class EmailDatabase : RoomDatabase() { abstract fun accountDao(): AccountDao abstract fun threadDao(): ThreadDao - abstract fun messageDao(): MessageDao + abstract fun signalDao(): SignalDao abstract fun labelDao(): LabelDao - abstract fun draftDao(): DraftDao abstract fun ruleDao(): RuleDao abstract fun templateDao(): TemplateDao + abstract fun viewDao(): ViewDao companion object { const val NAME = "numaeel.db" diff --git a/app/src/main/java/ch/rhosys/email/data/local/dao/MessageDao.kt b/app/src/main/java/ch/rhosys/email/data/local/dao/MessageDao.kt deleted file mode 100644 index e139bd1..0000000 --- a/app/src/main/java/ch/rhosys/email/data/local/dao/MessageDao.kt +++ /dev/null @@ -1,33 +0,0 @@ -package ch.rhosys.email.data.local.dao - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import ch.rhosys.email.data.local.entity.AttachmentEntity -import ch.rhosys.email.data.local.entity.MessageEntity -import kotlinx.coroutines.flow.Flow - -@Dao -interface MessageDao { - @Query("SELECT * FROM messages WHERE threadId = :threadId ORDER BY sentAt ASC") - fun observeByThread(threadId: String): Flow> - - @Query("SELECT * FROM attachments WHERE messageId = :messageId") - fun observeAttachments(messageId: String): Flow> - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertAll(messages: List) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsert(message: MessageEntity) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsertAttachments(attachments: List) - - @Query("UPDATE attachments SET isDownloaded = 1, localUri = :localUri WHERE id = :id") - suspend fun markDownloaded(id: String, localUri: String) - - @Query("UPDATE messages SET deliveryStatus = :status WHERE id = :id") - suspend fun updateDeliveryStatus(id: String, status: String) -} diff --git a/app/src/main/java/ch/rhosys/email/data/local/dao/OtherDaos.kt b/app/src/main/java/ch/rhosys/email/data/local/dao/OtherDaos.kt index bcdd1b8..ad7612b 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/dao/OtherDaos.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/dao/OtherDaos.kt @@ -6,10 +6,10 @@ import androidx.room.OnConflictStrategy import androidx.room.Query import ch.rhosys.email.data.local.entity.AccountEntity import ch.rhosys.email.data.local.entity.AliasEntity -import ch.rhosys.email.data.local.entity.DraftEntity import ch.rhosys.email.data.local.entity.LabelEntity import ch.rhosys.email.data.local.entity.RuleEntity import ch.rhosys.email.data.local.entity.TemplateEntity +import ch.rhosys.email.data.local.entity.ViewEntity import kotlinx.coroutines.flow.Flow @Dao @@ -17,6 +17,9 @@ interface AccountDao { @Query("SELECT * FROM accounts") fun observeAll(): Flow> + @Query("SELECT * FROM accounts WHERE accountId = :accountId") + suspend fun getById(accountId: String): AccountEntity? + @Query("SELECT * FROM aliases WHERE accountId = :accountId") fun observeAliases(accountId: String): Flow> @@ -26,8 +29,8 @@ interface AccountDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAliases(aliases: List) - @Query("DELETE FROM accounts WHERE id = :id") - suspend fun delete(id: String) + @Query("DELETE FROM accounts WHERE accountId = :accountId") + suspend fun delete(accountId: String) } @Dao @@ -41,38 +44,23 @@ interface LabelDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(label: LabelEntity) - @Query("DELETE FROM labels WHERE id = :id") - suspend fun delete(id: String) -} - -@Dao -interface DraftDao { - @Query("SELECT * FROM drafts WHERE accountId = :accountId ORDER BY updatedAt DESC") - fun observeAll(accountId: String): Flow> - - @Query("SELECT * FROM drafts WHERE id = :id") - suspend fun getById(id: String): DraftEntity? - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun upsert(draft: DraftEntity) - - @Query("SELECT * FROM drafts WHERE isPendingSync = 1") - suspend fun pendingSync(): List - - @Query("DELETE FROM drafts WHERE id = :id") - suspend fun delete(id: String) + @Query("DELETE FROM labels WHERE label = :label") + suspend fun delete(label: String) } @Dao interface RuleDao { - @Query("SELECT * FROM rules WHERE accountId = :accountId ORDER BY name ASC") + @Query("SELECT * FROM rules WHERE accountId = :accountId ORDER BY priorityOrder ASC") fun observeAll(accountId: String): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(rules: List) - @Query("UPDATE rules SET isEnabled = :isEnabled WHERE id = :id") - suspend fun setEnabled(id: String, isEnabled: Boolean) + @Query("UPDATE rules SET isEnabled = :isEnabled WHERE ruleId = :ruleId") + suspend fun setEnabled(ruleId: String, isEnabled: Boolean) + + @Query("DELETE FROM rules WHERE ruleId = :ruleId") + suspend fun delete(ruleId: String) } @Dao @@ -82,4 +70,16 @@ interface TemplateDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertAll(templates: List) + + @Query("DELETE FROM templates WHERE templateId = :templateId") + suspend fun delete(templateId: String) +} + +@Dao +interface ViewDao { + @Query("SELECT * FROM views WHERE accountId = :accountId ORDER BY position ASC") + fun observeAll(accountId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(views: List) } diff --git a/app/src/main/java/ch/rhosys/email/data/local/dao/SignalDao.kt b/app/src/main/java/ch/rhosys/email/data/local/dao/SignalDao.kt new file mode 100644 index 0000000..8abd7a5 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/local/dao/SignalDao.kt @@ -0,0 +1,45 @@ +package ch.rhosys.email.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import ch.rhosys.email.data.local.entity.SignalEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface SignalDao { + @Query("SELECT * FROM signals WHERE threadId = :threadId ORDER BY createdAt ASC") + fun observeByThread(threadId: String): Flow> + + /** Drafts are signals; there is no separate drafts table. */ + @Query("SELECT * FROM signals WHERE accountId = :accountId AND status = 'draft' ORDER BY createdAt DESC") + fun observeDrafts(accountId: String): Flow> + + @Query( + "SELECT * FROM signals WHERE accountId = :accountId " + + "AND status IN ('quarantine_visible', 'quarantine_hidden') ORDER BY createdAt DESC", + ) + fun observeQuarantined(accountId: String): Flow> + + @Query("SELECT * FROM signals WHERE signalId = :signalId") + suspend fun getById(signalId: String): SignalEntity? + + @Query("SELECT * FROM signals WHERE isPendingSync = 1") + suspend fun pendingSync(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertAll(signals: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(signal: SignalEntity) + + @Query("UPDATE signals SET status = :status, isPendingSync = :pending WHERE signalId = :signalId") + suspend fun updateStatus(signalId: String, status: String, pending: Boolean) + + @Query("DELETE FROM signals WHERE signalId = :signalId") + suspend fun delete(signalId: String) + + @Query("DELETE FROM signals WHERE threadId = :threadId") + suspend fun deleteByThread(threadId: String) +} diff --git a/app/src/main/java/ch/rhosys/email/data/local/dao/ThreadDao.kt b/app/src/main/java/ch/rhosys/email/data/local/dao/ThreadDao.kt index 52a5e8a..ffecdf3 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/dao/ThreadDao.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/dao/ThreadDao.kt @@ -9,25 +9,35 @@ import androidx.room.Update import ch.rhosys.email.data.local.entity.ThreadEntity import kotlinx.coroutines.flow.Flow +/** + * Threads are filtered by `status` rather than a folder column, and there is no + * read/unread state to query — the API models neither. + */ @Dao interface ThreadDao { - @Query("SELECT * FROM threads WHERE accountId = :accountId AND folder = :folder ORDER BY lastMessageAt DESC") - fun pagingSource(accountId: String, folder: String): PagingSource + @Query("SELECT * FROM threads WHERE accountId = :accountId AND status = :status ORDER BY lastSignalAt DESC") + fun pagingSource(accountId: String, status: String): PagingSource - @Query("SELECT * FROM threads WHERE accountId = :accountId AND folder = :folder ORDER BY lastMessageAt DESC") - fun observeByFolder(accountId: String, folder: String): Flow> + @Query("SELECT * FROM threads WHERE accountId = :accountId AND status = :status ORDER BY lastSignalAt DESC") + fun observeByStatus(accountId: String, status: String): Flow> - @Query("SELECT * FROM threads WHERE id = :id") - fun observeById(id: String): Flow + @Query( + "SELECT * FROM threads WHERE accountId = :accountId AND status = :status " + + "AND labels LIKE '%' || :label || '%' ORDER BY lastSignalAt DESC", + ) + fun observeByLabel(accountId: String, status: String, label: String): Flow> + + @Query("SELECT * FROM threads WHERE threadId = :threadId") + fun observeById(threadId: String): Flow @Query( "SELECT * FROM threads WHERE accountId = :accountId AND (subject LIKE '%' || :query || '%' " + - "OR snippet LIKE '%' || :query || '%')" + - " ORDER BY lastMessageAt DESC", + "OR summary LIKE '%' || :query || '%' OR senderAddress LIKE '%' || :query || '%') " + + "ORDER BY lastSignalAt DESC", ) fun search(accountId: String, query: String): Flow> - @Query("SELECT * FROM threads WHERE folder = 'ARCHIVED' AND followupAt IS NOT NULL AND followupAt <= :now") + @Query("SELECT * FROM threads WHERE followupAt IS NOT NULL AND followupAt <= :now") suspend fun dueFollowups(now: Long): List @Query("SELECT * FROM threads WHERE isPendingSync = 1") @@ -42,12 +52,18 @@ interface ThreadDao { @Update suspend fun update(thread: ThreadEntity) - @Query("UPDATE threads SET folder = :folder, followupAt = :followupAt, isPendingSync = 1, updatedAt = :now WHERE id = :id") - suspend fun moveToFolder(id: String, folder: String, followupAt: Long?, now: Long) + @Query( + "UPDATE threads SET status = :status, followupAt = :followupAt, isPendingSync = 1, " + + "updatedAt = :now WHERE threadId = :threadId", + ) + suspend fun setStatus(threadId: String, status: String, followupAt: Long?, now: Long) + + @Query("UPDATE threads SET labels = :labels, isPendingSync = 1, updatedAt = :now WHERE threadId = :threadId") + suspend fun setLabels(threadId: String, labels: String, now: Long) - @Query("UPDATE threads SET isRead = 1 WHERE id = :id") - suspend fun markRead(id: String) + @Query("DELETE FROM threads WHERE threadId = :threadId") + suspend fun delete(threadId: String) - @Query("DELETE FROM threads WHERE id = :id") - suspend fun delete(id: String) + @Query("DELETE FROM threads WHERE accountId = :accountId") + suspend fun clearAccount(accountId: String) } diff --git a/app/src/main/java/ch/rhosys/email/data/local/entity/AccountEntity.kt b/app/src/main/java/ch/rhosys/email/data/local/entity/AccountEntity.kt index 7fd0618..05f8504 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/entity/AccountEntity.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/entity/AccountEntity.kt @@ -3,29 +3,69 @@ package ch.rhosys.email.data.local.entity import androidx.room.Entity import androidx.room.PrimaryKey import ch.rhosys.email.domain.model.Account +import ch.rhosys.email.domain.model.AfterSendAction import ch.rhosys.email.domain.model.Alias +import ch.rhosys.email.domain.model.SenderPolicy +import java.time.Instant @Entity(tableName = "accounts") data class AccountEntity( - @PrimaryKey val id: String, - val emailAddress: String, - val displayName: String, - val avatarUrl: String?, - val isPrimary: Boolean, - val domain: String, + @PrimaryKey val accountId: String, + val name: String, + val defaultUnknownSenderPolicy: String, + val retentionDuration: String?, + val afterSendAction: String, + val billingPlan: String?, + val onboardingCompleted: Boolean, + val createdAt: Long?, + val updatedAt: Long?, ) @Entity(tableName = "aliases") data class AliasEntity( - @PrimaryKey val id: String, + @PrimaryKey val alias: String, val accountId: String, - val emailAddress: String, - val displayName: String, - val isDefault: Boolean, - val isVerified: Boolean, + val unknownSenderPolicy: String, + val createdAt: Long?, + val updatedAt: Long?, ) -fun AccountEntity.toDomain() = Account(id, emailAddress, displayName, avatarUrl, isPrimary, domain) -fun Account.toEntity() = AccountEntity(id, emailAddress, displayName, avatarUrl, isPrimary, domain) -fun AliasEntity.toDomain() = Alias(id, accountId, emailAddress, displayName, isDefault, isVerified) -fun Alias.toEntity() = AliasEntity(id, accountId, emailAddress, displayName, isDefault, isVerified) +fun AccountEntity.toDomain() = Account( + accountId = accountId, + name = name, + defaultUnknownSenderPolicy = SenderPolicy.fromWire(defaultUnknownSenderPolicy), + retentionDuration = retentionDuration, + afterSendAction = AfterSendAction.fromWire(afterSendAction), + billingPlan = billingPlan, + onboardingCompleted = onboardingCompleted, + createdAt = createdAt?.let(Instant::ofEpochMilli), + updatedAt = updatedAt?.let(Instant::ofEpochMilli), +) + +fun Account.toEntity() = AccountEntity( + accountId = accountId, + name = name, + defaultUnknownSenderPolicy = defaultUnknownSenderPolicy.wire, + retentionDuration = retentionDuration, + afterSendAction = afterSendAction.wire, + billingPlan = billingPlan, + onboardingCompleted = onboardingCompleted, + createdAt = createdAt?.toEpochMilli(), + updatedAt = updatedAt?.toEpochMilli(), +) + +fun AliasEntity.toDomain() = Alias( + alias = alias, + accountId = accountId, + unknownSenderPolicy = SenderPolicy.fromWire(unknownSenderPolicy), + createdAt = createdAt?.let(Instant::ofEpochMilli), + updatedAt = updatedAt?.let(Instant::ofEpochMilli), +) + +fun Alias.toEntity() = AliasEntity( + alias = alias, + accountId = accountId, + unknownSenderPolicy = unknownSenderPolicy.wire, + createdAt = createdAt?.toEpochMilli(), + updatedAt = updatedAt?.toEpochMilli(), +) diff --git a/app/src/main/java/ch/rhosys/email/data/local/entity/MessageEntity.kt b/app/src/main/java/ch/rhosys/email/data/local/entity/MessageEntity.kt deleted file mode 100644 index b9e2882..0000000 --- a/app/src/main/java/ch/rhosys/email/data/local/entity/MessageEntity.kt +++ /dev/null @@ -1,50 +0,0 @@ -package ch.rhosys.email.data.local.entity - -import androidx.room.Entity -import androidx.room.PrimaryKey -import androidx.room.TypeConverters -import ch.rhosys.email.data.local.Converters -import ch.rhosys.email.domain.model.Attachment -import ch.rhosys.email.domain.model.DeliveryStatus -import ch.rhosys.email.domain.model.Message - -@Entity(tableName = "messages") -@TypeConverters(Converters::class) -data class MessageEntity( - @PrimaryKey val id: String, - val threadId: String, - val fromAddress: String, - val toAddresses: List, - val ccAddresses: List, - val bodyMarkdown: String, - val bodyHtml: String?, - val sentAt: Long, - val deliveryStatus: String, -) - -@Entity(tableName = "attachments") -data class AttachmentEntity( - @PrimaryKey val id: String, - val messageId: String, - val filename: String, - val mimeType: String, - val sizeBytes: Long, - val isDownloaded: Boolean, - val localUri: String?, -) - -fun MessageEntity.toDomain(attachments: List) = Message( - id = id, threadId = threadId, fromAddress = fromAddress, toAddresses = toAddresses, - ccAddresses = ccAddresses, bodyMarkdown = bodyMarkdown, bodyHtml = bodyHtml, sentAt = sentAt, - deliveryStatus = runCatching { DeliveryStatus.valueOf(deliveryStatus) }.getOrDefault(DeliveryStatus.SENT), - attachments = attachments, -) - -fun Message.toEntity() = MessageEntity( - id = id, threadId = threadId, fromAddress = fromAddress, toAddresses = toAddresses, - ccAddresses = ccAddresses, bodyMarkdown = bodyMarkdown, bodyHtml = bodyHtml, sentAt = sentAt, - deliveryStatus = deliveryStatus.name, -) - -fun AttachmentEntity.toDomain() = Attachment(id, messageId, filename, mimeType, sizeBytes, isDownloaded, localUri) -fun Attachment.toEntity() = AttachmentEntity(id, messageId, filename, mimeType, sizeBytes, isDownloaded, localUri) diff --git a/app/src/main/java/ch/rhosys/email/data/local/entity/OtherEntities.kt b/app/src/main/java/ch/rhosys/email/data/local/entity/OtherEntities.kt index c6e18de..8262f9f 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/entity/OtherEntities.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/entity/OtherEntities.kt @@ -4,67 +4,138 @@ import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters import ch.rhosys.email.data.local.Converters -import ch.rhosys.email.domain.model.Draft import ch.rhosys.email.domain.model.Label import ch.rhosys.email.domain.model.Rule +import ch.rhosys.email.domain.model.RuleAction +import ch.rhosys.email.domain.model.RuleActionType import ch.rhosys.email.domain.model.Template +import ch.rhosys.email.domain.model.View +import ch.rhosys.email.domain.model.Workflow +import java.time.Instant + +/** + * There is no drafts table: a draft is a signal with status "draft", cached in + * [SignalEntity] alongside every other signal on its thread. + */ @Entity(tableName = "labels") data class LabelEntity( - @PrimaryKey val id: String, + @PrimaryKey val label: String, val accountId: String, val name: String, - val color: String, - val emoji: String?, -) - -@Entity(tableName = "drafts") -@TypeConverters(Converters::class) -data class DraftEntity( - @PrimaryKey val id: String, - val accountId: String, - val threadId: String?, - val fromAlias: String, - val toAddresses: List, - val ccAddresses: List, - val bccAddresses: List, - val subject: String, - val bodyMarkdown: String, - val updatedAt: Long, - val isPendingSync: Boolean = false, + val color: String?, + val icon: String?, + val createdAt: Long?, ) @Entity(tableName = "rules") +@TypeConverters(Converters::class) data class RuleEntity( - @PrimaryKey val id: String, + @PrimaryKey val ruleId: String, val accountId: String, val name: String, - val description: String, + val condition: String?, + val conditionType: String?, + /** Serialized as "type=value" pairs; see [Converters.fromStringList]. */ + val actions: List, val isEnabled: Boolean, + val priorityOrder: Double, + val isImmutable: Boolean, ) @Entity(tableName = "templates") data class TemplateEntity( - @PrimaryKey val id: String, + @PrimaryKey val templateId: String, val accountId: String, val name: String, val subject: String, - val bodyMarkdown: String, + val body: String, ) -fun LabelEntity.toDomain() = Label(id, accountId, name, color, emoji) -fun Label.toEntity() = LabelEntity(id, accountId, name, color, emoji) +@Entity(tableName = "views") +@TypeConverters(Converters::class) +data class ViewEntity( + @PrimaryKey val viewId: String, + val accountId: String, + val name: String, + val icon: String?, + val color: String?, + val workflow: String?, + val labels: List, + val position: Double, +) -fun DraftEntity.toDomain() = Draft( - id, accountId, threadId, fromAlias, toAddresses, ccAddresses, bccAddresses, subject, bodyMarkdown, updatedAt, +fun LabelEntity.toDomain() = Label( + label = label, + accountId = accountId, + name = name, + color = color, + icon = icon, + createdAt = createdAt?.let(Instant::ofEpochMilli), ) -fun Draft.toEntity(isPendingSync: Boolean = false) = DraftEntity( - id, accountId, threadId, fromAlias, toAddresses, ccAddresses, bccAddresses, subject, bodyMarkdown, updatedAt, - isPendingSync, + +fun Label.toEntity() = LabelEntity( + label = label, + accountId = accountId, + name = name, + color = color, + icon = icon, + createdAt = createdAt?.toEpochMilli(), +) + +private fun encodeAction(a: RuleAction) = "${a.type.wire}=${a.value.orEmpty()}" + +private fun decodeAction(raw: String): RuleAction { + val type = raw.substringBefore('=') + val value = raw.substringAfter('=', "").takeIf { it.isNotEmpty() } + return RuleAction(RuleActionType.fromWire(type), value) +} + +fun RuleEntity.toDomain() = Rule( + ruleId = ruleId, + accountId = accountId, + name = name, + condition = condition, + conditionType = conditionType, + actions = actions.map(::decodeAction), + isEnabled = isEnabled, + priorityOrder = priorityOrder, + isImmutable = isImmutable, ) -fun RuleEntity.toDomain() = Rule(id, accountId, name, description, isEnabled) -fun Rule.toEntity() = RuleEntity(id, accountId, name, description, isEnabled) +fun Rule.toEntity() = RuleEntity( + ruleId = ruleId, + accountId = accountId, + name = name, + condition = condition, + conditionType = conditionType, + actions = actions.map(::encodeAction), + isEnabled = isEnabled, + priorityOrder = priorityOrder, + isImmutable = isImmutable, +) + +fun TemplateEntity.toDomain() = Template(templateId, accountId, name, subject, body) +fun Template.toEntity() = TemplateEntity(templateId, accountId, name, subject, body) -fun TemplateEntity.toDomain() = Template(id, accountId, name, subject, bodyMarkdown) -fun Template.toEntity() = TemplateEntity(id, accountId, name, subject, bodyMarkdown) +fun ViewEntity.toDomain() = View( + viewId = viewId, + accountId = accountId, + name = name, + icon = icon, + color = color, + workflow = workflow?.let(Workflow::fromWire), + labels = labels, + position = position, +) + +fun View.toEntity() = ViewEntity( + viewId = viewId, + accountId = accountId, + name = name, + icon = icon, + color = color, + workflow = workflow?.wire, + labels = labels, + position = position, +) diff --git a/app/src/main/java/ch/rhosys/email/data/local/entity/SignalEntity.kt b/app/src/main/java/ch/rhosys/email/data/local/entity/SignalEntity.kt new file mode 100644 index 0000000..0ea890d --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/local/entity/SignalEntity.kt @@ -0,0 +1,173 @@ +package ch.rhosys.email.data.local.entity + +import androidx.room.Entity +import androidx.room.PrimaryKey +import androidx.room.TypeConverters +import ch.rhosys.email.data.local.Converters +import ch.rhosys.email.domain.model.Attachment +import ch.rhosys.email.domain.model.EmailAddress +import ch.rhosys.email.domain.model.Signal +import ch.rhosys.email.domain.model.SignalStatus +import ch.rhosys.email.domain.model.UnsubscribeInfo +import ch.rhosys.email.domain.model.Urgency +import ch.rhosys.email.domain.model.Workflow +import java.time.Instant + +/** + * Cached signal row. The backend's signal union is flattened into one table with + * a [kind] discriminator, since Room cannot persist a sealed hierarchy directly + * and the app only distinguishes three cases when rendering. + * + * Addresses are stored as delimited strings via [Converters]; attachments are + * stored as a JSON array because they are read only as a block. + */ +@Entity(tableName = "signals") +@TypeConverters(Converters::class) +data class SignalEntity( + @PrimaryKey val signalId: String, + val threadId: String?, + val accountId: String, + val kind: String, + val status: String, + val createdAt: Long?, + val fromAddress: String?, + val fromName: String?, + val toAddresses: List, + val ccAddresses: List, + val bccAddresses: List, + val replyToAddress: String?, + val subject: String, + val body: String?, + val summary: String?, + val urgency: String?, + val workflow: String?, + val recipientAddress: String?, + val receivedAt: Long?, + val sentAt: Long?, + val sendInitiatedAt: Long?, + val sendFailureReason: String?, + val unsubscribeType: String?, + val unsubscribeUrl: String?, + val attachmentsJson: String?, + val noticeType: String?, + val noticeDetail: String?, + /** True while a locally-created or edited draft awaits sync. */ + val isPendingSync: Boolean = false, +) { + object Kind { + const val INBOUND = "inbound" + const val OUTBOUND = "outbound" + const val NOTICE = "notice" + } +} + +private fun addr(address: String?, name: String?): EmailAddress? = + address?.let { EmailAddress(it, name) } + +private fun List.toAddresses(): List = map { EmailAddress(it) } + +fun SignalEntity.toDomain(attachments: List): Signal = when (kind) { + SignalEntity.Kind.OUTBOUND -> Signal.OutboundEmail( + signalId = signalId, + threadId = threadId, + status = SignalStatus.fromWire(status), + createdAt = createdAt?.let(Instant::ofEpochMilli), + from = addr(fromAddress, fromName) ?: EmailAddress(""), + to = toAddresses.toAddresses(), + cc = ccAddresses.toAddresses(), + bcc = bccAddresses.toAddresses(), + replyTo = addr(replyToAddress, null), + subject = subject, + body = body, + attachments = attachments, + sentAt = sentAt?.let(Instant::ofEpochMilli), + sendInitiatedAt = sendInitiatedAt?.let(Instant::ofEpochMilli), + sendFailureReason = sendFailureReason, + ) + + SignalEntity.Kind.INBOUND -> Signal.InboundEmail( + signalId = signalId, + threadId = threadId, + status = SignalStatus.fromWire(status), + createdAt = createdAt?.let(Instant::ofEpochMilli), + from = addr(fromAddress, fromName) ?: EmailAddress(""), + to = toAddresses.toAddresses(), + cc = ccAddresses.toAddresses(), + replyTo = addr(replyToAddress, null), + subject = subject, + body = body, + summary = summary.orEmpty(), + urgency = Urgency.fromWire(urgency), + workflow = Workflow.fromWire(workflow), + recipientAddress = recipientAddress.orEmpty(), + receivedAt = receivedAt?.let(Instant::ofEpochMilli), + attachments = attachments, + unsubscribe = unsubscribeUrl?.let { UnsubscribeInfo(unsubscribeType.orEmpty(), it) }, + ) + + else -> Signal.SystemNotice( + signalId = signalId, + threadId = threadId, + status = SignalStatus.fromWire(status), + createdAt = createdAt?.let(Instant::ofEpochMilli), + type = noticeType.orEmpty(), + detail = noticeDetail, + ) +} + +/** + * Flattens a domain signal for caching. [attachmentsJson] is supplied by the + * repository, which owns the Moshi instance used to encode it. + */ +fun Signal.toEntity( + accountId: String, + attachmentsJson: String? = null, + isPendingSync: Boolean = false, +): SignalEntity { + val base = SignalEntity( + signalId = signalId, + threadId = threadId, + accountId = accountId, + kind = SignalEntity.Kind.NOTICE, + status = status.wire, + createdAt = createdAt?.toEpochMilli(), + fromAddress = null, fromName = null, + toAddresses = emptyList(), ccAddresses = emptyList(), bccAddresses = emptyList(), + replyToAddress = null, + subject = "", body = null, summary = null, urgency = null, workflow = null, + recipientAddress = null, receivedAt = null, sentAt = null, sendInitiatedAt = null, + sendFailureReason = null, unsubscribeType = null, unsubscribeUrl = null, + attachmentsJson = attachmentsJson, noticeType = null, noticeDetail = null, + isPendingSync = isPendingSync, + ) + return when (this) { + is Signal.InboundEmail -> base.copy( + kind = SignalEntity.Kind.INBOUND, + fromAddress = from.address, fromName = from.name, + toAddresses = to.map { it.address }, ccAddresses = cc.map { it.address }, + replyToAddress = replyTo?.address, + subject = subject, body = body, summary = summary, + urgency = urgency.wire, workflow = workflow.wire, + recipientAddress = recipientAddress, + receivedAt = receivedAt?.toEpochMilli(), + unsubscribeType = unsubscribe?.type, unsubscribeUrl = unsubscribe?.url, + ) + + is Signal.OutboundEmail -> base.copy( + kind = SignalEntity.Kind.OUTBOUND, + fromAddress = from.address, fromName = from.name, + toAddresses = to.map { it.address }, ccAddresses = cc.map { it.address }, + bccAddresses = bcc.map { it.address }, + replyToAddress = replyTo?.address, + subject = subject, body = body, + sentAt = sentAt?.toEpochMilli(), + sendInitiatedAt = sendInitiatedAt?.toEpochMilli(), + sendFailureReason = sendFailureReason, + ) + + is Signal.SystemNotice -> base.copy( + kind = SignalEntity.Kind.NOTICE, + noticeType = type, noticeDetail = detail, + ) + } +} diff --git a/app/src/main/java/ch/rhosys/email/data/local/entity/ThreadEntity.kt b/app/src/main/java/ch/rhosys/email/data/local/entity/ThreadEntity.kt index f2c32b7..c7e3fdf 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/entity/ThreadEntity.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/entity/ThreadEntity.kt @@ -4,63 +4,72 @@ import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverters import ch.rhosys.email.data.local.Converters -import ch.rhosys.email.domain.model.Folder +import ch.rhosys.email.domain.model.EmailAddress import ch.rhosys.email.domain.model.MailThread -import ch.rhosys.email.domain.model.WorkflowType +import ch.rhosys.email.domain.model.ThreadStatus +import ch.rhosys.email.domain.model.Urgency +import ch.rhosys.email.domain.model.Workflow +import java.time.Instant +/** + * Cached thread row. Timestamps are stored as epoch millis for cheap sorting; + * they arrive from the backend as ISO-8601 strings and are converted at the + * repository boundary. + */ @Entity(tableName = "threads") @TypeConverters(Converters::class) data class ThreadEntity( - @PrimaryKey val id: String, + @PrimaryKey val threadId: String, val accountId: String, val subject: String, - val snippet: String, - val participants: List, - val lastMessageAt: Long, - val isRead: Boolean, - val folder: String, - val labelIds: List, + val summary: String, + val senderAddress: String, + val senderName: String?, + val recipientAddress: String, + val workflow: String, + val status: String, + val urgency: String, + val labels: List, + val lastSignalAt: Long?, val followupAt: Long?, - val workflowType: String, - val workflowFields: Map = emptyMap(), - val isBlockedSender: Boolean, - val unsubscribeUrl: String?, - /** True while an offline-queued mutation (archive/delete/label) awaits sync. */ + val createdAt: Long?, + val updatedAt: Long?, val isPendingSync: Boolean = false, - val updatedAt: Long = lastMessageAt, ) fun ThreadEntity.toDomain() = MailThread( - id = id, + threadId = threadId, accountId = accountId, subject = subject, - snippet = snippet, - participants = participants, - lastMessageAt = lastMessageAt, - isRead = isRead, - folder = Folder.valueOf(folder), - labelIds = labelIds, - followupAt = followupAt, - workflowType = runCatching { WorkflowType.valueOf(workflowType) }.getOrDefault(WorkflowType.NONE), - workflowFields = workflowFields, - isBlockedSender = isBlockedSender, - unsubscribeUrl = unsubscribeUrl, + summary = summary, + sender = EmailAddress(senderAddress, senderName), + recipientAddress = recipientAddress, + workflow = Workflow.fromWire(workflow), + status = ThreadStatus.fromWire(status), + urgency = Urgency.fromWire(urgency), + labels = labels, + lastSignalAt = lastSignalAt?.let(Instant::ofEpochMilli), + followupAt = followupAt?.let(Instant::ofEpochMilli), + createdAt = createdAt?.let(Instant::ofEpochMilli), + updatedAt = updatedAt?.let(Instant::ofEpochMilli), + isPendingSync = isPendingSync, ) -fun MailThread.toEntity(isPendingSync: Boolean = false) = ThreadEntity( - id = id, +fun MailThread.toEntity(isPendingSync: Boolean = this.isPendingSync) = ThreadEntity( + threadId = threadId, accountId = accountId, subject = subject, - snippet = snippet, - participants = participants, - lastMessageAt = lastMessageAt, - isRead = isRead, - folder = folder.name, - labelIds = labelIds, - followupAt = followupAt, - workflowType = workflowType.name, - workflowFields = workflowFields, - isBlockedSender = isBlockedSender, - unsubscribeUrl = unsubscribeUrl, + summary = summary, + senderAddress = sender.address, + senderName = sender.name, + recipientAddress = recipientAddress, + workflow = workflow.wire, + status = status.wire, + urgency = urgency.wire, + labels = labels, + lastSignalAt = lastSignalAt?.toEpochMilli(), + followupAt = followupAt?.toEpochMilli(), + createdAt = createdAt?.toEpochMilli(), + updatedAt = updatedAt?.toEpochMilli(), isPendingSync = isPendingSync, ) diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt new file mode 100644 index 0000000..8b8172b --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt @@ -0,0 +1,170 @@ +package ch.rhosys.email.data.remote.dto + +import ch.rhosys.email.domain.model.Account +import ch.rhosys.email.domain.model.AfterSendAction +import ch.rhosys.email.domain.model.Alias +import ch.rhosys.email.domain.model.Attachment +import ch.rhosys.email.domain.model.EmailAddress +import ch.rhosys.email.domain.model.Label +import ch.rhosys.email.domain.model.MailThread +import ch.rhosys.email.domain.model.Rule +import ch.rhosys.email.domain.model.RuleAction +import ch.rhosys.email.domain.model.RuleActionType +import ch.rhosys.email.domain.model.SenderPolicy +import ch.rhosys.email.domain.model.Signal +import ch.rhosys.email.domain.model.SignalStatus +import ch.rhosys.email.domain.model.Template +import ch.rhosys.email.domain.model.ThreadStatus +import ch.rhosys.email.domain.model.UnsubscribeInfo +import ch.rhosys.email.domain.model.Urgency +import ch.rhosys.email.domain.model.View +import ch.rhosys.email.domain.model.Workflow +import java.time.Instant +import java.time.format.DateTimeParseException + +/** + * Wire-to-domain mapping. Timestamps arrive as ISO-8601 strings; a malformed one + * degrades that single field to null rather than failing the whole response. + */ +internal fun String?.toInstantOrNull(): Instant? = + this?.takeIf { it.isNotBlank() }?.let { + try { + Instant.parse(it) + } catch (_: DateTimeParseException) { + null + } + } + +internal fun EmailAddressDto.toDomain() = EmailAddress(address, name) + +internal fun AttachmentDto.toDomain() = Attachment( + filename = filename, + mimeType = mimeType, + sizeBytes = sizeBytes.toLong(), + url = url, +) + +internal fun UnsubscribeInfoDto.toDomain() = UnsubscribeInfo(type, url) + +internal fun ThreadDto.toDomain(accountId: String) = MailThread( + threadId = threadId, + accountId = accountId, + subject = subject, + summary = summary, + sender = sender.toDomain(), + recipientAddress = recipientAddress, + workflow = Workflow.fromWire(workflow), + status = ThreadStatus.fromWire(status), + urgency = Urgency.fromWire(urgency), + labels = labels, + lastSignalAt = lastSignalAt.toInstantOrNull(), + followupAt = followupAt.toInstantOrNull(), + createdAt = createdAt.toInstantOrNull(), + updatedAt = updatedAt.toInstantOrNull(), +) + +internal fun SignalDto.toDomain(): Signal = when (this) { + is EmailInboundSignalDto -> Signal.InboundEmail( + signalId = signalId, + threadId = threadId, + status = SignalStatus.fromWire(status), + createdAt = createdAt.toInstantOrNull(), + from = data.from.toDomain(), + to = data.to.map { it.toDomain() }, + cc = data.cc.map { it.toDomain() }, + replyTo = data.replyTo?.toDomain(), + subject = data.subject, + body = data.body, + summary = data.summary, + urgency = Urgency.fromWire(data.urgency), + workflow = Workflow.fromWire(data.workflow), + recipientAddress = data.recipientAddress, + receivedAt = data.receivedAt.toInstantOrNull(), + attachments = data.attachments.map { it.toDomain() }, + unsubscribe = data.unsubscribe?.toDomain(), + ) + + is EmailOutboundSignalDto -> Signal.OutboundEmail( + signalId = signalId, + threadId = threadId, + status = SignalStatus.fromWire(status), + createdAt = createdAt.toInstantOrNull(), + from = data.from.toDomain(), + to = data.to.map { it.toDomain() }, + cc = data.cc.map { it.toDomain() }, + bcc = data.bcc.map { it.toDomain() }, + replyTo = data.replyTo?.toDomain(), + subject = data.subject, + body = data.body, + attachments = data.attachments.map { it.toDomain() }, + sentAt = data.sentAt.toInstantOrNull(), + sendInitiatedAt = data.sendInitiatedAt.toInstantOrNull(), + sendFailureReason = data.sendFailureReason, + ) + + is SystemSignalDto -> Signal.SystemNotice( + signalId = signalId, + threadId = threadId, + status = SignalStatus.fromWire(status), + createdAt = createdAt.toInstantOrNull(), + type = type, + detail = (data["summary"] ?: data["detail"] ?: data["reason"])?.toString(), + ) +} + +internal fun AccountDto.toDomain() = Account( + accountId = accountId, + name = name, + defaultUnknownSenderPolicy = SenderPolicy.fromWire(filtering.defaultUnknownSenderPolicy), + retentionDuration = retentionDuration, + afterSendAction = AfterSendAction.fromWire(afterSendAction), + billingPlan = billingPlan, + onboardingCompleted = onboarding?.completed ?: false, + createdAt = createdAt.toInstantOrNull(), + updatedAt = updatedAt.toInstantOrNull(), +) + +internal fun AliasDto.toDomain(accountId: String) = Alias( + alias = alias, + accountId = accountId, + unknownSenderPolicy = SenderPolicy.fromWire(unknownSenderPolicy), + createdAt = createdAt.toInstantOrNull(), + updatedAt = updatedAt.toInstantOrNull(), +) + +internal fun LabelDto.toDomain(accountId: String) = Label( + label = label, + accountId = accountId, + name = name, + color = color, + icon = icon, + createdAt = createdAt.toInstantOrNull(), +) + +internal fun RuleActionDto.toDomain() = RuleAction(RuleActionType.fromWire(type), value) + +internal fun RuleDto.toDomain(accountId: String) = Rule( + ruleId = ruleId, + accountId = accountId, + name = name, + condition = condition, + conditionType = conditionType, + actions = actions.map { it.toDomain() }, + isEnabled = status == RuleStatus.ENABLED, + priorityOrder = priorityOrder, + isImmutable = type == "IMMUTABLE", +) + +internal fun EmailTemplateDto.toDomain(accountId: String) = + Template(templateId, accountId, name, subject, body) + +internal fun ViewDto.toDomain(accountId: String) = View( + viewId = viewId, + accountId = accountId, + name = name, + icon = icon, + color = color, + workflow = workflow?.let(Workflow::fromWire), + labels = labels, + position = position, +) diff --git a/app/src/main/java/ch/rhosys/email/domain/model/Account.kt b/app/src/main/java/ch/rhosys/email/domain/model/Account.kt index 09b3ab1..bf00dff 100644 --- a/app/src/main/java/ch/rhosys/email/domain/model/Account.kt +++ b/app/src/main/java/ch/rhosys/email/domain/model/Account.kt @@ -1,20 +1,80 @@ package ch.rhosys.email.domain.model -/** A signed-in mailbox identity (decision #3: multi-account support). */ +import java.time.Instant + +/** + * An account as the backend models it. Note there is no email address, avatar or + * "primary" flag on an account — addresses live on [Alias], and the account + * itself is just an id, a name and its filtering configuration. + */ data class Account( - val id: String, - val emailAddress: String, - val displayName: String, - val avatarUrl: String?, - val isPrimary: Boolean, - val domain: String, + val accountId: String, + val name: String, + val defaultUnknownSenderPolicy: SenderPolicy, + val retentionDuration: String?, + val afterSendAction: AfterSendAction, + /** Exposed by the API for display only — there are no billing endpoints. */ + val billingPlan: String?, + val onboardingCompleted: Boolean, + val createdAt: Instant?, + val updatedAt: Instant?, ) +/** A receiving address on an account, with its own unknown-sender policy. */ data class Alias( - val id: String, + val alias: String, val accountId: String, - val emailAddress: String, - val displayName: String, - val isDefault: Boolean, - val isVerified: Boolean, + val unknownSenderPolicy: SenderPolicy, + val createdAt: Instant?, + val updatedAt: Instant?, +) + +/** + * Disposition applied to mail from senders that are not explicitly allowed. + * Setting this per sender-domain is how the app blocks a sender — there is no + * block-sender endpoint. + */ +enum class SenderPolicy { + ALLOW_ALL, + QUARANTINE_VISIBLE, + QUARANTINE_HIDDEN, + BLOCK_HIDDEN, + BLOCK_REJECT, + REPORT_VIOLATION, + ; + + val wire: String get() = name.lowercase() + + companion object { + fun fromWire(value: String?): SenderPolicy = + entries.firstOrNull { it.wire == value } ?: QUARANTINE_VISIBLE + } +} + +enum class AfterSendAction { + ARCHIVE, + KEEP_ACTIVE, + ; + + val wire: String get() = name.lowercase() + + companion object { + fun fromWire(value: String?): AfterSendAction = + entries.firstOrNull { it.wire == value } ?: KEEP_ACTIVE + } +} + +/** Per-sender-domain override on an alias. */ +data class AliasSender( + val domain: String, + val policy: SenderPolicy, +) + +/** A member of an account. Formerly modelled as "team". */ +data class AccountUser( + val userId: String, + val role: String?, + val name: String?, + val email: String?, + val pictureUrl: String?, ) diff --git a/app/src/main/java/ch/rhosys/email/domain/model/Label.kt b/app/src/main/java/ch/rhosys/email/domain/model/Label.kt index a088da9..99c51b8 100644 --- a/app/src/main/java/ch/rhosys/email/domain/model/Label.kt +++ b/app/src/main/java/ch/rhosys/email/domain/model/Label.kt @@ -1,38 +1,81 @@ package ch.rhosys.email.domain.model +import java.time.Instant + +/** + * A label. The stable identifier is [label]; [name] is the display string. + * Threads carry label identifiers in MailThread.labels. + */ data class Label( - val id: String, + val label: String, val accountId: String, val name: String, - val color: String, - val emoji: String?, -) - -data class Draft( - val id: String, - val accountId: String, - val threadId: String?, - val fromAlias: String, - val toAddresses: List, - val ccAddresses: List, - val bccAddresses: List, - val subject: String, - val bodyMarkdown: String, - val updatedAt: Long, + val color: String?, + val icon: String?, + val createdAt: Instant?, ) data class Rule( - val id: String, + val ruleId: String, val accountId: String, val name: String, - val description: String, + val condition: String?, + val conditionType: String?, + val actions: List, val isEnabled: Boolean, + val priorityOrder: Double, + /** IMMUTABLE rules are backend-managed and cannot be edited or deleted. */ + val isImmutable: Boolean, +) + +data class RuleAction( + val type: RuleActionType, + val value: String?, ) +enum class RuleActionType { + ASSIGN_LABEL, + ASSIGN_WORKFLOW, + ARCHIVE, + FORWARD, + BLOCK_HIDDEN, + BLOCK_REJECT, + QUARANTINE_VISIBLE, + QUARANTINE_HIDDEN, + SET_URGENCY, + SUPPRESS_NOTIFICATION, + PONG, + APPROVE_SENDER, + AUTO_DRAFT, + FORWARD_CALENDAR_INVITE, + ; + + /** forwardCalendarInvite is camelCase on the wire; the rest are snake_case. */ + val wire: String + get() = if (this == FORWARD_CALENDAR_INVITE) "forwardCalendarInvite" else name.lowercase() + + companion object { + fun fromWire(value: String?): RuleActionType = + entries.firstOrNull { it.wire == value } ?: ARCHIVE + } +} + data class Template( - val id: String, + val templateId: String, val accountId: String, val name: String, val subject: String, - val bodyMarkdown: String, + val body: String, +) + +/** A saved inbox filter. The API models these as first-class objects. */ +data class View( + val viewId: String, + val accountId: String, + val name: String, + val icon: String?, + val color: String?, + val workflow: Workflow?, + val labels: List, + val position: Double, ) diff --git a/app/src/main/java/ch/rhosys/email/domain/model/MailThread.kt b/app/src/main/java/ch/rhosys/email/domain/model/MailThread.kt index 4f588c5..26666b1 100644 --- a/app/src/main/java/ch/rhosys/email/domain/model/MailThread.kt +++ b/app/src/main/java/ch/rhosys/email/domain/model/MailThread.kt @@ -1,52 +1,183 @@ package ch.rhosys.email.domain.model -enum class Folder { ACTIVE, ARCHIVED, QUARANTINE, SPAM } +import java.time.Instant -/** Matches the 14 structured workflow types the backend classifies signals into. */ -enum class WorkflowType { - AUTH, TRAVEL, PAYMENT, SCHEDULING, CONVERSATION, CRM, PACKAGE, ALERT, - CONTENT, STATUS, HEALTHCARE, JOB, SUPPORT, TEST, NONE, +/** + * Domain model mirroring the backend's thread/signal vocabulary. + * + * Deliberately absent, because the API has no concept of them: read/unread + * state, folders, message snippets and participant lists. Row emphasis is + * driven by [Urgency] instead of unread state. + */ + +/** Replaces the old Folder enum. Matches the API's thread `status`. */ +enum class ThreadStatus { + ACTIVE, + ARCHIVED, + DELETED, + REPORT_VIOLATION, + ; + + val wire: String get() = name.lowercase() + + companion object { + fun fromWire(value: String?): ThreadStatus = + entries.firstOrNull { it.wire == value } ?: ACTIVE + } +} + +/** The 15 workflow classifications the backend assigns. */ +enum class Workflow { + AUTH, CONVERSATION, CRM, PACKAGE, TRAVEL, PAYMENTS, ALERT, CONTENT, + ONBOARDING, NOTICE, HEALTHCARE, JOB, SUPPORT, TEST, EVENTS, + ; + + val wire: String get() = name.lowercase() + + companion object { + fun fromWire(value: String?): Workflow = + entries.firstOrNull { it.wire == value } ?: CONVERSATION + } +} + +/** Drives inbox row emphasis now that unread state is gone. */ +enum class Urgency { + CRITICAL, HIGH, NORMAL, LOW, SILENT, + ; + + val wire: String get() = name.lowercase() + + companion object { + fun fromWire(value: String?): Urgency = + entries.firstOrNull { it.wire == value } ?: NORMAL + } +} + +/** Status of an individual signal. Drafts, blocking and quarantine live here. */ +enum class SignalStatus { + ACTIVE, + BLOCK_HIDDEN, + BLOCK_REJECT, + REPORT_VIOLATION, + QUARANTINE_VISIBLE, + QUARANTINE_HIDDEN, + DRAFT, + PENDING_SEND, + SENT, + ; + + val wire: String get() = name.lowercase() + + val isQuarantined: Boolean get() = this == QUARANTINE_VISIBLE || this == QUARANTINE_HIDDEN + val isBlocked: Boolean get() = this == BLOCK_HIDDEN || this == BLOCK_REJECT + + companion object { + fun fromWire(value: String?): SignalStatus = + entries.firstOrNull { it.wire == value } ?: ACTIVE + } } -enum class DeliveryStatus { QUEUED, SENT, DELIVERED, BOUNCED, FAILED } +data class EmailAddress( + val address: String, + val name: String? = null, +) { + val display: String get() = name?.takeIf { it.isNotBlank() } ?: address +} data class MailThread( - val id: String, + val threadId: String, val accountId: String, val subject: String, - val snippet: String, - val participants: List, - val lastMessageAt: Long, - val isRead: Boolean, - val folder: Folder, - val labelIds: List, - val followupAt: Long?, - val workflowType: WorkflowType, - /** Key/value pairs the backend classifier extracted for the workflow panel (decision #37). */ - val workflowFields: Map = emptyMap(), - val isBlockedSender: Boolean = false, - val unsubscribeUrl: String? = null, + val summary: String, + val sender: EmailAddress, + val recipientAddress: String, + val workflow: Workflow, + val status: ThreadStatus, + val urgency: Urgency, + val labels: List, + /** Null once the thread has no signals left; such threads are hidden. */ + val lastSignalAt: Instant?, + val followupAt: Instant?, + val createdAt: Instant?, + val updatedAt: Instant?, + /** True while an offline-queued mutation awaits sync. */ + val isPendingSync: Boolean = false, ) -data class Message( - val id: String, - val threadId: String, - val fromAddress: String, - val toAddresses: List, - val ccAddresses: List, - val bodyMarkdown: String, - val bodyHtml: String?, - val sentAt: Long, - val deliveryStatus: DeliveryStatus, - val attachments: List, -) +/** + * An item on a thread. The backend models this as a ten-way union; the app cares + * about inbound and outbound email and renders everything else as a system notice. + */ +sealed interface Signal { + val signalId: String + val threadId: String? + val status: SignalStatus + val createdAt: Instant? + data class InboundEmail( + override val signalId: String, + override val threadId: String?, + override val status: SignalStatus, + override val createdAt: Instant?, + val from: EmailAddress, + val to: List, + val cc: List, + val replyTo: EmailAddress?, + val subject: String, + val body: String?, + val summary: String, + val urgency: Urgency, + val workflow: Workflow, + val recipientAddress: String, + val receivedAt: Instant?, + val attachments: List, + val unsubscribe: UnsubscribeInfo?, + ) : Signal + + data class OutboundEmail( + override val signalId: String, + override val threadId: String?, + override val status: SignalStatus, + override val createdAt: Instant?, + val from: EmailAddress, + val to: List, + val cc: List, + val bcc: List, + val replyTo: EmailAddress?, + val subject: String, + val body: String?, + val attachments: List, + val sentAt: Instant?, + val sendInitiatedAt: Instant?, + val sendFailureReason: String?, + ) : Signal { + val isDraft: Boolean get() = status == SignalStatus.DRAFT + val isSending: Boolean get() = status == SignalStatus.PENDING_SEND + } + + /** Deliverability, calendar, rule/template errors, and anything added later. */ + data class SystemNotice( + override val signalId: String, + override val threadId: String?, + override val status: SignalStatus, + override val createdAt: Instant?, + val type: String, + val detail: String?, + ) : Signal +} + +/** + * Attachment metadata. There is no attachment download endpoint in the API, so + * [url] is the only way to reach the content and is often absent. + */ data class Attachment( - val id: String, - val messageId: String, val filename: String, val mimeType: String, val sizeBytes: Long, - val isDownloaded: Boolean, - val localUri: String?, + val url: String?, +) + +data class UnsubscribeInfo( + val type: String, + val url: String, ) diff --git a/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt b/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt index 4d0873e..5b1765a 100644 --- a/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt +++ b/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt @@ -3,15 +3,15 @@ package ch.rhosys.email.domain.repository import androidx.paging.PagingData import ch.rhosys.email.domain.model.Account import ch.rhosys.email.domain.model.Alias -import ch.rhosys.email.domain.model.Attachment -import ch.rhosys.email.domain.model.Draft -import ch.rhosys.email.domain.model.Folder import ch.rhosys.email.domain.model.Label import ch.rhosys.email.domain.model.MailThread -import ch.rhosys.email.domain.model.Message import ch.rhosys.email.domain.model.Rule +import ch.rhosys.email.domain.model.SenderPolicy +import ch.rhosys.email.domain.model.Signal import ch.rhosys.email.domain.model.Template +import ch.rhosys.email.domain.model.ThreadStatus import kotlinx.coroutines.flow.Flow +import java.time.Instant interface AccountRepository { fun observeAccounts(): Flow> @@ -19,63 +19,83 @@ interface AccountRepository { suspend fun refresh() suspend fun setActiveAccount(accountId: String) fun activeAccountId(): Flow + + /** Blocking a sender is a per-domain policy on an alias, not a thread action. */ + suspend fun setSenderPolicy(accountId: String, alias: String, domain: String, policy: SenderPolicy) } interface ThreadRepository { - fun pagedThreads(accountId: String, folder: Folder): Flow> + fun pagedThreads(accountId: String, status: ThreadStatus): Flow> fun observeThread(threadId: String): Flow - fun observeMessages(threadId: String): Flow> + fun observeSignals(threadId: String): Flow> fun search(accountId: String, query: String): Flow> - suspend fun refreshFolder(accountId: String, folder: Folder) - suspend fun refreshMessages(threadId: String) - suspend fun archive(threadId: String) - suspend fun delay(threadId: String, followupAt: Long) - suspend fun delete(threadId: String) - suspend fun moveToActive(threadId: String) - suspend fun markRead(threadId: String) - suspend fun addLabel(threadId: String, labelId: String) - suspend fun removeLabel(threadId: String, labelId: String) - suspend fun unsubscribe(threadId: String) - suspend fun blockSender(threadId: String) - suspend fun approveQuarantine(threadId: String) - suspend fun rejectQuarantine(threadId: String) - suspend fun downloadAttachment(attachment: Attachment): Result + + suspend fun refreshThreads(accountId: String, status: ThreadStatus) + suspend fun refreshSignals(accountId: String, threadId: String) + + suspend fun archive(accountId: String, threadId: String) + suspend fun moveToActive(accountId: String, threadId: String) + suspend fun delete(accountId: String, threadId: String) + suspend fun snooze(accountId: String, threadId: String, followupAt: Instant) + suspend fun setLabels(accountId: String, threadId: String, labels: List) + suspend fun unsubscribe(accountId: String, threadId: String): Result + + /** Quarantine is resolved per signal, not per thread. */ + fun observeQuarantined(accountId: String): Flow> + suspend fun respondToQuarantine(accountId: String, signalId: String, approve: Boolean) + suspend fun syncPending() } +/** + * Composition works on draft signals. A draft belongs to a thread — the API has + * no standalone draft resource — and sending promotes the same signal rather + * than creating a new message. + */ interface ComposeRepository { - fun observeDrafts(accountId: String): Flow> - suspend fun getDraft(draftId: String): Draft? - suspend fun saveDraft(draft: Draft) - suspend fun deleteDraft(draftId: String) - suspend fun send( + fun observeDrafts(accountId: String): Flow> + suspend fun getDraft(signalId: String): Signal.OutboundEmail? + + suspend fun createDraft( + accountId: String, + threadId: String, fromAlias: String, to: List, - cc: List, - bcc: List, subject: String, - bodyMarkdown: String, - inReplyToThreadId: String?, - sendAfterMillis: Long?, + body: String, ): Result - suspend fun cancelSend(messageId: String): Result + + suspend fun updateDraft( + accountId: String, + threadId: String, + signalId: String, + fromAlias: String?, + subject: String?, + body: String?, + ): Result + + suspend fun deleteDraft(accountId: String, threadId: String, signalId: String): Result + suspend fun send(accountId: String, threadId: String, signalId: String): Result } interface LabelRepository { fun observeLabels(accountId: String): Flow> suspend fun refresh(accountId: String) - suspend fun create(accountId: String, name: String, color: String, emoji: String?) - suspend fun update(label: Label) - suspend fun delete(labelId: String) + suspend fun create(accountId: String, name: String, color: String?, icon: String?) + suspend fun update(accountId: String, label: Label) + suspend fun delete(accountId: String, labelId: String) } interface RuleRepository { fun observeRules(accountId: String): Flow> suspend fun refresh(accountId: String) - suspend fun setEnabled(ruleId: String, enabled: Boolean) + suspend fun setEnabled(accountId: String, ruleId: String, enabled: Boolean) + suspend fun delete(accountId: String, ruleId: String) } interface TemplateRepository { fun observeTemplates(accountId: String): Flow> suspend fun refresh(accountId: String) + suspend fun upsert(accountId: String, templateId: String?, name: String, subject: String, body: String) + suspend fun delete(accountId: String, templateId: String) } From 9b809f224373fd0b8c897a73525334db20a19cd9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 16:35:35 +0000 Subject: [PATCH 05/10] Rewrite repositories against the real thread and signal endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- .../data/repository/AccountRepositoryImpl.kt | 34 +++- .../data/repository/AdminStatsRepositories.kt | 30 +++ .../AdminStatsSupportRepositories.kt | 36 ---- .../data/repository/ComposeRepositoryImpl.kt | 112 +++++++---- .../LabelRuleTemplateRepositories.kt | 105 +++++++--- .../data/repository/SettingsRepository.kt | 58 +++--- .../data/repository/ThreadRemoteMediator.kt | 46 ++--- .../data/repository/ThreadRepositoryImpl.kt | 188 +++++++++--------- 8 files changed, 354 insertions(+), 255 deletions(-) create mode 100644 app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt delete mode 100644 app/src/main/java/ch/rhosys/email/data/repository/AdminStatsSupportRepositories.kt diff --git a/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt index 2a5839d..7d28656 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt @@ -5,8 +5,11 @@ import ch.rhosys.email.data.local.dao.AccountDao import ch.rhosys.email.data.local.entity.toDomain import ch.rhosys.email.data.local.entity.toEntity import ch.rhosys.email.data.remote.api.EmailApiService +import ch.rhosys.email.data.remote.dto.SetAliasSenderRequest +import ch.rhosys.email.data.remote.dto.toDomain import ch.rhosys.email.domain.model.Account import ch.rhosys.email.domain.model.Alias +import ch.rhosys.email.domain.model.SenderPolicy import ch.rhosys.email.domain.repository.AccountRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -21,21 +24,26 @@ class AccountRepositoryImpl( private val activeAccount = MutableStateFlow(tokenStore.activeAccountId) - override fun observeAccounts(): Flow> = dao.observeAll().map { it.map { e -> e.toDomain() } } + override fun observeAccounts(): Flow> = + dao.observeAll().map { rows -> rows.map { it.toDomain() } } override fun observeAliases(accountId: String): Flow> = - dao.observeAliases(accountId).map { it.map { e -> e.toDomain() } } + dao.observeAliases(accountId).map { rows -> rows.map { it.toDomain() } } override suspend fun refresh() { - val accounts = api.getAccounts() - dao.upsertAll(accounts.map { Account(it.id, it.emailAddress, it.displayName, it.avatarUrl, it.isPrimary, it.domain).toEntity() }) + val accounts = api.getAccounts().accounts + dao.upsertAll(accounts.map { it.toDomain().toEntity() }) + + // No "primary" flag exists on an account, so the first is the default. if (tokenStore.activeAccountId == null) { - val primary = accounts.firstOrNull { it.isPrimary } ?: accounts.firstOrNull() - primary?.let { setActiveAccount(it.id) } + accounts.firstOrNull()?.let { setActiveAccount(it.accountId) } } + accounts.forEach { account -> - val aliases = api.getAliases(account.id) - dao.upsertAliases(aliases.map { Alias(it.id, it.accountId, it.emailAddress, it.displayName, it.isDefault, it.isVerified).toEntity() }) + runCatching { + val aliases = api.getAliases(account.accountId).aliases + dao.upsertAliases(aliases.map { it.toDomain(account.accountId).toEntity() }) + } } } @@ -45,4 +53,14 @@ class AccountRepositoryImpl( } override fun activeAccountId(): Flow = activeAccount.asStateFlow() + + /** Blocking or approving a sender is a per-domain policy on an alias. */ + override suspend fun setSenderPolicy( + accountId: String, + alias: String, + domain: String, + policy: SenderPolicy, + ) { + api.setAliasSenderPolicy(accountId, alias, domain, SetAliasSenderRequest(policy.wire)) + } } diff --git a/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt b/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt new file mode 100644 index 0000000..cf1f1d3 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt @@ -0,0 +1,30 @@ +package ch.rhosys.email.data.repository + +import ch.rhosys.email.data.remote.api.EmailApiService +import ch.rhosys.email.data.remote.dto.HealthCheckDto + +/** + * Stats are returned as a free-form object by the API — the OpenAPI document + * declares `/accounts/{accountId}/stats` with an untyped response — so the shape + * is surfaced as-is rather than invented into a typed summary. + */ +class StatsRepository(private val api: EmailApiService) { + suspend fun getStats(accountId: String): Map = api.getStats(accountId) +} + +/** + * The previous admin repository called v1/admin/* routes that never existed. + * The API offers a global health check plus per-signal reprocess and raw + * fetch — both of which are addressed by account, thread and signal. + */ +class AdminRepository(private val api: EmailApiService) { + + suspend fun getHealthCheck(): HealthCheckDto = api.getHealthCheck() + + suspend fun reprocessSignal(accountId: String, threadId: String, signalId: String) { + api.reprocessSignal(accountId, threadId, signalId) + } + + suspend fun getRawSignal(accountId: String, threadId: String, signalId: String): String = + api.getRawSignal(accountId, threadId, signalId).string() +} diff --git a/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsSupportRepositories.kt b/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsSupportRepositories.kt deleted file mode 100644 index 036e305..0000000 --- a/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsSupportRepositories.kt +++ /dev/null @@ -1,36 +0,0 @@ -package ch.rhosys.email.data.repository - -import ch.rhosys.email.data.remote.api.EmailApiService -import ch.rhosys.email.data.remote.dto.StatsPointDto -import ch.rhosys.email.data.remote.dto.SupportTicketRequest - -data class StatsPoint(val label: String, val count: Int) -data class StatsSummary(val daily: List, val monthly: List, val workflowBreakdown: Map) -data class HealthCheck(val status: String, val checkedAt: Long, val details: Map) - -/** Decision #41: full stats dashboard with charts. */ -class StatsRepository(private val api: EmailApiService) { - suspend fun getStats(accountId: String): StatsSummary { - val dto = api.getStats(accountId) - fun List.toDomain() = map { StatsPoint(it.label, it.count) } - return StatsSummary(dto.dailyVolume.toDomain(), dto.monthlyVolume.toDomain(), dto.workflowBreakdown) - } -} - -/** Decision #40: full admin panel, hidden behind a settings toggle. */ -class AdminRepository(private val api: EmailApiService) { - suspend fun getHealthCheck(): HealthCheck = - api.getHealthCheck().let { HealthCheck(it.status, it.checkedAt, it.details) } - - suspend fun reprocessThread(threadId: String) = api.reprocessThread(threadId) - - suspend fun getRawEmailUrl(threadId: String) = api.getRawEmail(threadId) -} - -/** Decision #43: in-app support ticket form. */ -class SupportRepository(private val api: EmailApiService) { - suspend fun submitTicket(category: String, description: String): Result = runCatching { - api.submitSupportTicket(SupportTicketRequest(category, description)) - Unit - } -} diff --git a/app/src/main/java/ch/rhosys/email/data/repository/ComposeRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/ComposeRepositoryImpl.kt index b8d1987..1a19595 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/ComposeRepositoryImpl.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/ComposeRepositoryImpl.kt @@ -1,62 +1,98 @@ package ch.rhosys.email.data.repository -import ch.rhosys.email.data.local.dao.DraftDao +import ch.rhosys.email.data.local.dao.SignalDao import ch.rhosys.email.data.local.entity.toDomain import ch.rhosys.email.data.local.entity.toEntity import ch.rhosys.email.data.remote.api.EmailApiService -import ch.rhosys.email.data.remote.dto.DraftDto -import ch.rhosys.email.data.remote.dto.SendMessageRequest -import ch.rhosys.email.domain.model.Draft +import ch.rhosys.email.data.remote.dto.CreateDraftSignalRequest +import ch.rhosys.email.data.remote.dto.EmailAddressDto +import ch.rhosys.email.data.remote.dto.UpdateDraftSignalRequest +import ch.rhosys.email.data.remote.dto.toDomain +import ch.rhosys.email.domain.model.Signal import ch.rhosys.email.domain.repository.ComposeRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +/** + * Composition against draft signals. A draft is a signal on a thread with + * status "draft" — there is no standalone draft resource — and sending promotes + * that same signal rather than creating a new message. + * + * Consequently there is no "send later" or "undo send": the API exposes no + * scheduling parameter and no cancel endpoint. + */ class ComposeRepositoryImpl( private val api: EmailApiService, - private val draftDao: DraftDao, + private val signalDao: SignalDao, ) : ComposeRepository { - override fun observeDrafts(accountId: String): Flow> = - draftDao.observeAll(accountId).map { it.map { e -> e.toDomain() } } - - override suspend fun getDraft(draftId: String): Draft? = draftDao.getById(draftId)?.toDomain() - - override suspend fun saveDraft(draft: Draft) { - draftDao.upsert(draft.toEntity(isPendingSync = true)) - runCatching { - api.saveDraft( - draft.id, - DraftDto( - draft.id, draft.accountId, draft.threadId, draft.fromAlias, draft.toAddresses, - draft.ccAddresses, draft.bccAddresses, draft.subject, draft.bodyMarkdown, draft.updatedAt, - ), - ) - draftDao.upsert(draft.toEntity(isPendingSync = false)) + override fun observeDrafts(accountId: String): Flow> = + signalDao.observeDrafts(accountId).map { rows -> + rows.mapNotNull { it.toDomain(attachments = emptyList()) as? Signal.OutboundEmail } } - } - override suspend fun deleteDraft(draftId: String) { - draftDao.delete(draftId) - runCatching { api.deleteDraft(draftId) } - } + override suspend fun getDraft(signalId: String): Signal.OutboundEmail? = + signalDao.getById(signalId)?.toDomain(attachments = emptyList()) as? Signal.OutboundEmail - override suspend fun send( + override suspend fun createDraft( + accountId: String, + threadId: String, fromAlias: String, to: List, - cc: List, - bcc: List, subject: String, - bodyMarkdown: String, - inReplyToThreadId: String?, - sendAfterMillis: Long?, + body: String, ): Result = runCatching { - api.sendMessage( - SendMessageRequest(fromAlias, to, cc, bcc, subject, bodyMarkdown, inReplyToThreadId, sendAfterMillis), - ).id + val created = api.createDraftSignal( + accountId, + threadId, + CreateDraftSignalRequest( + from = EmailAddressDto(fromAlias), + to = to.map { EmailAddressDto(it) }, + subject = subject, + textBody = body, + ), + ) + val domain = created.toDomain() + signalDao.upsert(domain.toEntity(accountId)) + domain.signalId + } + + override suspend fun updateDraft( + accountId: String, + threadId: String, + signalId: String, + fromAlias: String?, + subject: String?, + body: String?, + ): Result = runCatching { + val updated = api.updateDraftSignal( + accountId, + threadId, + signalId, + UpdateDraftSignalRequest( + from = fromAlias?.let { EmailAddressDto(it) }, + subject = subject, + textBody = body, + ), + ) + signalDao.upsert(updated.toDomain().toEntity(accountId)) + } + + override suspend fun deleteDraft( + accountId: String, + threadId: String, + signalId: String, + ): Result = runCatching { + api.deleteSignal(accountId, threadId, signalId) + signalDao.delete(signalId) } - override suspend fun cancelSend(messageId: String): Result = runCatching { - api.cancelSend(messageId) - Unit + override suspend fun send( + accountId: String, + threadId: String, + signalId: String, + ): Result = runCatching { + api.sendSignal(accountId, threadId, signalId) + signalDao.updateStatus(signalId, ch.rhosys.email.domain.model.SignalStatus.SENT.wire, pending = false) } } diff --git a/app/src/main/java/ch/rhosys/email/data/repository/LabelRuleTemplateRepositories.kt b/app/src/main/java/ch/rhosys/email/data/repository/LabelRuleTemplateRepositories.kt index 5852788..230db59 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/LabelRuleTemplateRepositories.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/LabelRuleTemplateRepositories.kt @@ -3,13 +3,15 @@ package ch.rhosys.email.data.repository import ch.rhosys.email.data.local.dao.LabelDao import ch.rhosys.email.data.local.dao.RuleDao import ch.rhosys.email.data.local.dao.TemplateDao -import ch.rhosys.email.data.local.entity.LabelEntity import ch.rhosys.email.data.local.entity.toDomain import ch.rhosys.email.data.local.entity.toEntity import ch.rhosys.email.data.remote.api.EmailApiService -import ch.rhosys.email.data.remote.dto.LabelDto -import ch.rhosys.email.data.remote.dto.RuleDto -import ch.rhosys.email.data.remote.dto.TemplateDto +import ch.rhosys.email.data.remote.dto.CreateLabelRequest +import ch.rhosys.email.data.remote.dto.PatchLabelRequest +import ch.rhosys.email.data.remote.dto.PatchRuleRequest +import ch.rhosys.email.data.remote.dto.RuleStatus +import ch.rhosys.email.data.remote.dto.UpsertTemplateRequest +import ch.rhosys.email.data.remote.dto.toDomain import ch.rhosys.email.domain.model.Label import ch.rhosys.email.domain.model.Rule import ch.rhosys.email.domain.model.Template @@ -18,58 +20,101 @@ import ch.rhosys.email.domain.repository.RuleRepository import ch.rhosys.email.domain.repository.TemplateRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import java.util.UUID -class LabelRepositoryImpl(private val api: EmailApiService, private val dao: LabelDao) : LabelRepository { +/** + * All three resources are account-scoped and identified by a server-assigned id, + * so creates go to the network first — there is no client-generated id to + * optimistically insert under. + */ +class LabelRepositoryImpl( + private val api: EmailApiService, + private val dao: LabelDao, +) : LabelRepository { + override fun observeLabels(accountId: String): Flow> = - dao.observeAll(accountId).map { it.map { e -> e.toDomain() } } + dao.observeAll(accountId).map { rows -> rows.map { it.toDomain() } } override suspend fun refresh(accountId: String) { - val labels = api.getLabels(accountId) - dao.upsertAll(labels.map { LabelEntity(it.id, it.accountId, it.name, it.color, it.emoji) }) + val labels = api.getLabels(accountId).labels + dao.upsertAll(labels.map { it.toDomain(accountId).toEntity() }) } - override suspend fun create(accountId: String, name: String, color: String, emoji: String?) { - val localId = UUID.randomUUID().toString() - dao.upsert(LabelEntity(localId, accountId, name, color, emoji)) - runCatching { - val created = api.createLabel(accountId, LabelDto(localId, accountId, name, color, emoji)) - dao.upsert(LabelEntity(created.id, created.accountId, created.name, created.color, created.emoji)) - } + override suspend fun create(accountId: String, name: String, color: String?, icon: String?) { + val created = api.createLabel(accountId, CreateLabelRequest(name, color, icon)) + dao.upsert(created.toDomain(accountId).toEntity()) } - override suspend fun update(label: Label) { + override suspend fun update(accountId: String, label: Label) { dao.upsert(label.toEntity()) - runCatching { api.updateLabel(label.id, LabelDto(label.id, label.accountId, label.name, label.color, label.emoji)) } + runCatching { + api.patchLabel(accountId, label.label, PatchLabelRequest(label.name, label.color, label.icon)) + }.onSuccess { dao.upsert(it.toDomain(accountId).toEntity()) } } - override suspend fun delete(labelId: String) { + override suspend fun delete(accountId: String, labelId: String) { dao.delete(labelId) - runCatching { api.deleteLabel(labelId) } + runCatching { api.deleteLabel(accountId, labelId) } } } -class RuleRepositoryImpl(private val api: EmailApiService, private val dao: RuleDao) : RuleRepository { +class RuleRepositoryImpl( + private val api: EmailApiService, + private val dao: RuleDao, +) : RuleRepository { + override fun observeRules(accountId: String): Flow> = - dao.observeAll(accountId).map { it.map { e -> e.toDomain() } } + dao.observeAll(accountId).map { rows -> rows.map { it.toDomain() } } override suspend fun refresh(accountId: String) { - val rules = api.getRules(accountId) - dao.upsertAll(rules.map { Rule(it.id, it.accountId, it.name, it.description, it.isEnabled).toEntity() }) + val rules = api.getRules(accountId).rules + dao.upsertAll(rules.map { it.toDomain(accountId).toEntity() }) } - override suspend fun setEnabled(ruleId: String, enabled: Boolean) { + /** Enablement is a `status` field on the rule, not a boolean. */ + override suspend fun setEnabled(accountId: String, ruleId: String, enabled: Boolean) { dao.setEnabled(ruleId, enabled) - runCatching { api.setRuleEnabled(ruleId, mapOf("isEnabled" to enabled)) } + val status = if (enabled) RuleStatus.ENABLED else RuleStatus.DISABLED + runCatching { api.patchRule(accountId, ruleId, PatchRuleRequest(status = status)) } + .onSuccess { dao.upsertAll(listOf(it.toDomain(accountId).toEntity())) } + } + + override suspend fun delete(accountId: String, ruleId: String) { + dao.delete(ruleId) + runCatching { api.deleteRule(accountId, ruleId) } } } -class TemplateRepositoryImpl(private val api: EmailApiService, private val dao: TemplateDao) : TemplateRepository { +class TemplateRepositoryImpl( + private val api: EmailApiService, + private val dao: TemplateDao, +) : TemplateRepository { + override fun observeTemplates(accountId: String): Flow> = - dao.observeAll(accountId).map { it.map { e -> e.toDomain() } } + dao.observeAll(accountId).map { rows -> rows.map { it.toDomain() } } override suspend fun refresh(accountId: String) { - val templates = api.getTemplates(accountId) - dao.upsertAll(templates.map { Template(it.id, it.accountId, it.name, it.subject, it.bodyMarkdown).toEntity() }) + val templates = api.getTemplates(accountId).templates + dao.upsertAll(templates.map { it.toDomain(accountId).toEntity() }) + } + + override suspend fun upsert( + accountId: String, + templateId: String?, + name: String, + subject: String, + body: String, + ) { + val request = UpsertTemplateRequest(name, subject, body) + val saved = if (templateId == null) { + api.createTemplate(accountId, request) + } else { + api.updateTemplate(accountId, templateId, request) + } + dao.upsertAll(listOf(saved.toDomain(accountId).toEntity())) + } + + override suspend fun delete(accountId: String, templateId: String) { + dao.delete(templateId) + runCatching { api.deleteTemplate(accountId, templateId) } } } diff --git a/app/src/main/java/ch/rhosys/email/data/repository/SettingsRepository.kt b/app/src/main/java/ch/rhosys/email/data/repository/SettingsRepository.kt index 20c2361..5fa4096 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/SettingsRepository.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/SettingsRepository.kt @@ -1,37 +1,45 @@ package ch.rhosys.email.data.repository import ch.rhosys.email.data.remote.api.EmailApiService -import ch.rhosys.email.domain.model.DnsRecord -import ch.rhosys.email.domain.model.ForwardingAddress -import ch.rhosys.email.domain.model.MfaDevice -import ch.rhosys.email.domain.model.PlanInfo -import ch.rhosys.email.domain.model.TeamMember - -/** Backs Settings' 4 tabs (decision #45), DNS/forwarding/MFA management (#46-48), and billing view (#42). */ +import ch.rhosys.email.data.remote.dto.AccountUserDto +import ch.rhosys.email.data.remote.dto.CreateForwardingTargetRequest +import ch.rhosys.email.data.remote.dto.DnsRecordDto +import ch.rhosys.email.data.remote.dto.DomainDto +import ch.rhosys.email.data.remote.dto.ForwardingTargetDto + +/** + * Backs the settings screens against what the API actually exposes. + * + * Removed, because the API provides no endpoints for them: MFA and passkey + * device management, and the billing/plan view. `billingPlan` is readable on the + * account for display, but there is nothing to manage. + * + * What the app called "DNS records" is the domains resource; records come back + * on a single domain, not on the account. + */ class SettingsRepository(private val api: EmailApiService) { - suspend fun getDnsRecords(accountId: String): List = - api.getDnsRecords(accountId).map { DnsRecord(it.type, it.name, it.value, it.isVerified) } - - suspend fun verifyDnsRecords(accountId: String): List = - api.verifyDnsRecords(accountId).map { DnsRecord(it.type, it.name, it.value, it.isVerified) } - suspend fun getForwardingAddresses(accountId: String): List = - api.getForwardingAddresses(accountId).map { ForwardingAddress(it.id, it.emailAddress, it.isVerified) } + suspend fun getDomains(accountId: String): List = + api.getDomains(accountId).domains - suspend fun addForwardingAddress(accountId: String, emailAddress: String): ForwardingAddress = - api.addForwardingAddress(accountId, mapOf("emailAddress" to emailAddress)) - .let { ForwardingAddress(it.id, it.emailAddress, it.isVerified) } + /** DNS records hang off an individual domain. */ + suspend fun getDomainRecords(accountId: String, domainId: String): List = + api.getDomain(accountId, domainId).records - suspend fun removeForwardingAddress(id: String) = api.removeForwardingAddress(id) + suspend fun getForwardingTargets(accountId: String): List = + api.getForwardingTargets(accountId).forwardingTargets - suspend fun getMfaDevices(): List = - api.getMfaDevices().map { MfaDevice(it.id, it.label, it.type, it.addedAt) } + suspend fun addForwardingTarget(accountId: String, target: String): ForwardingTargetDto = + api.addForwardingTarget(accountId, CreateForwardingTargetRequest(target, type = "email")) - suspend fun removeMfaDevice(id: String) = api.removeMfaDevice(id) + suspend fun removeForwardingTarget(accountId: String, address: String) { + api.removeForwardingTarget(accountId, address) + } - suspend fun getTeamMembers(accountId: String): List = - api.getTeamMembers(accountId).map { TeamMember(it.id, it.emailAddress, it.role) } + suspend fun verifyForwardingTarget(accountId: String, address: String): ForwardingTargetDto = + api.verifyForwardingTarget(accountId, address) - suspend fun getPlanInfo(accountId: String): PlanInfo = - api.getPlanInfo(accountId).let { PlanInfo(it.planName, it.emailsUsed, it.emailsQuota, it.renewsAt) } + /** Formerly "team members". */ + suspend fun getAccountUsers(accountId: String): List = + api.getAccountUsers(accountId).users } diff --git a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRemoteMediator.kt b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRemoteMediator.kt index f865f44..0ae4748 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRemoteMediator.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRemoteMediator.kt @@ -8,49 +8,49 @@ import ch.rhosys.email.data.local.EmailDatabase import ch.rhosys.email.data.local.entity.ThreadEntity import ch.rhosys.email.data.local.entity.toEntity import ch.rhosys.email.data.remote.api.EmailApiService -import ch.rhosys.email.domain.model.Folder +import ch.rhosys.email.data.remote.dto.toDomain +import ch.rhosys.email.domain.model.ThreadStatus /** - * Bridges the paged Room source with the backend page cursor (decision #81). - * Local rows remain the source of truth for the UI; this only refills them. + * Bridges the paged Room source with the backend cursor. Local rows remain the + * source of truth for the UI; this only refills them. */ @OptIn(ExperimentalPagingApi::class) class ThreadRemoteMediator( private val accountId: String, - private val folder: Folder, + private val status: ThreadStatus, private val api: EmailApiService, private val db: EmailDatabase, ) : RemoteMediator() { private var nextCursor: String? = null - override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { - if (loadType == LoadType.PREPEND) return MediatorResult.Success(endOfPaginationReached = true) - + override suspend fun load( + loadType: LoadType, + state: PagingState, + ): MediatorResult { return try { val cursor = when (loadType) { LoadType.REFRESH -> null - LoadType.APPEND -> nextCursor ?: return MediatorResult.Success(endOfPaginationReached = true) LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true) + LoadType.APPEND -> nextCursor + ?: return MediatorResult.Success(endOfPaginationReached = true) } - val page = api.getThreads(accountId, folder.name, cursor) - nextCursor = page.nextCursor + val page = api.getThreads( + accountId = accountId, + status = status.wire, + cursor = cursor, + limit = state.config.pageSize, + ) + nextCursor = page.pagination?.cursor - db.threadDao().upsertAll(page.items.map { dto -> - ch.rhosys.email.domain.model.MailThread( - id = dto.id, accountId = dto.accountId, subject = dto.subject, snippet = dto.snippet, - participants = dto.participants, lastMessageAt = dto.lastMessageAt, isRead = dto.isRead, - folder = runCatching { Folder.valueOf(dto.folder) }.getOrDefault(folder), - labelIds = dto.labelIds, followupAt = dto.followupAt, - workflowType = runCatching { ch.rhosys.email.domain.model.WorkflowType.valueOf(dto.workflowType) } - .getOrDefault(ch.rhosys.email.domain.model.WorkflowType.NONE), - workflowFields = dto.workflowFields, - isBlockedSender = dto.isBlockedSender, unsubscribeUrl = dto.unsubscribeUrl, - ).toEntity() - }) + if (loadType == LoadType.REFRESH) { + db.threadDao().clearAccount(accountId) + } + db.threadDao().upsertAll(page.threads.map { it.toDomain(accountId).toEntity() }) - MediatorResult.Success(endOfPaginationReached = page.nextCursor == null) + MediatorResult.Success(endOfPaginationReached = nextCursor == null) } catch (e: Exception) { MediatorResult.Error(e) } diff --git a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt index 990ec40..8155b14 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt @@ -1,6 +1,5 @@ package ch.rhosys.email.data.repository -import android.content.Context import androidx.paging.ExperimentalPagingApi import androidx.paging.Pager import androidx.paging.PagingConfig @@ -10,140 +9,139 @@ import ch.rhosys.email.data.local.EmailDatabase import ch.rhosys.email.data.local.entity.toDomain import ch.rhosys.email.data.local.entity.toEntity import ch.rhosys.email.data.remote.api.EmailApiService -import ch.rhosys.email.data.remote.dto.MoveThreadRequest -import ch.rhosys.email.domain.model.Attachment -import ch.rhosys.email.domain.model.Folder +import ch.rhosys.email.data.remote.dto.PatchThreadRequest +import ch.rhosys.email.data.remote.dto.QuarantineResponseRequest +import ch.rhosys.email.data.remote.dto.SignalStatus as WireSignalStatus +import ch.rhosys.email.data.remote.dto.toDomain import ch.rhosys.email.domain.model.MailThread -import ch.rhosys.email.domain.model.Message +import ch.rhosys.email.domain.model.Signal +import ch.rhosys.email.domain.model.ThreadStatus import ch.rhosys.email.domain.repository.ThreadRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import java.io.File - +import java.time.Instant + +/** + * Thread and signal caching. Local rows stay the source of truth for the UI; + * remote calls only refill them. + * + * Mutations write locally first with isPendingSync, then attempt the network — + * archive, delete, snooze and relabel are all PATCHes on the thread, since the + * API has no dedicated endpoints for them. + */ @OptIn(ExperimentalPagingApi::class) class ThreadRepositoryImpl( - private val context: Context, private val api: EmailApiService, private val db: EmailDatabase, ) : ThreadRepository { private val threadDao = db.threadDao() - private val messageDao = db.messageDao() + private val signalDao = db.signalDao() - override fun pagedThreads(accountId: String, folder: Folder): Flow> = + override fun pagedThreads(accountId: String, status: ThreadStatus): Flow> = Pager( config = PagingConfig(pageSize = 30, enablePlaceholders = false), - remoteMediator = ThreadRemoteMediator(accountId, folder, api, db), - pagingSourceFactory = { threadDao.pagingSource(accountId, folder.name) }, + remoteMediator = ThreadRemoteMediator(accountId, status, api, db), + pagingSourceFactory = { threadDao.pagingSource(accountId, status.wire) }, ).flow.map { paging -> paging.map { it.toDomain() } } override fun observeThread(threadId: String): Flow = threadDao.observeById(threadId).map { it?.toDomain() } - override fun observeMessages(threadId: String): Flow> = - messageDao.observeByThread(threadId).map { messages -> - messages.map { m -> m.toDomain(attachments = emptyList()) } + override fun observeSignals(threadId: String): Flow> = + signalDao.observeByThread(threadId).map { rows -> + rows.map { it.toDomain(attachments = emptyList()) } } - override fun search(accountId: String, query: String): Flow> = - threadDao.search(accountId, query).map { it.map { e -> e.toDomain() } } - - override suspend fun refreshFolder(accountId: String, folder: Folder) { - val page = api.getThreads(accountId, folder.name) - threadDao.upsertAll(page.items.map { dto -> - MailThread( - id = dto.id, accountId = dto.accountId, subject = dto.subject, snippet = dto.snippet, - participants = dto.participants, lastMessageAt = dto.lastMessageAt, isRead = dto.isRead, - folder = runCatching { Folder.valueOf(dto.folder) }.getOrDefault(folder), - labelIds = dto.labelIds, followupAt = dto.followupAt, - workflowType = runCatching { ch.rhosys.email.domain.model.WorkflowType.valueOf(dto.workflowType) } - .getOrDefault(ch.rhosys.email.domain.model.WorkflowType.NONE), - workflowFields = dto.workflowFields, - isBlockedSender = dto.isBlockedSender, unsubscribeUrl = dto.unsubscribeUrl, - ).toEntity() - }) - } - - override suspend fun refreshMessages(threadId: String) { - val messages = api.getMessages(threadId) - messageDao.upsertAll(messages.map { dto -> - Message( - id = dto.id, threadId = dto.threadId, fromAddress = dto.fromAddress, toAddresses = dto.toAddresses, - ccAddresses = dto.ccAddresses, bodyMarkdown = dto.bodyMarkdown, bodyHtml = dto.bodyHtml, - sentAt = dto.sentAt, - deliveryStatus = runCatching { ch.rhosys.email.domain.model.DeliveryStatus.valueOf(dto.deliveryStatus) } - .getOrDefault(ch.rhosys.email.domain.model.DeliveryStatus.SENT), - attachments = emptyList(), - ).toEntity() - }) - messages.forEach { dto -> - messageDao.upsertAttachments(dto.attachments.map { a -> - ch.rhosys.email.data.local.entity.AttachmentEntity(a.id, a.messageId, a.filename, a.mimeType, a.sizeBytes, false, null) - }) + override fun observeQuarantined(accountId: String): Flow> = + signalDao.observeQuarantined(accountId).map { rows -> + rows.map { it.toDomain(attachments = emptyList()) } } - } - - override suspend fun archive(threadId: String) = moveLocalThenSync(threadId, Folder.ARCHIVED, null) - - override suspend fun delay(threadId: String, followupAt: Long) = moveLocalThenSync(threadId, Folder.ARCHIVED, followupAt) - - override suspend fun moveToActive(threadId: String) = moveLocalThenSync(threadId, Folder.ACTIVE, null) - - private suspend fun moveLocalThenSync(threadId: String, folder: Folder, followupAt: Long?) { - threadDao.moveToFolder(threadId, folder.name, followupAt, System.currentTimeMillis()) - runCatching { api.moveThread(threadId, MoveThreadRequest(folder.name, followupAt)) } - } - override suspend fun delete(threadId: String) { - threadDao.delete(threadId) - runCatching { api.deleteThread(threadId) } - } - - override suspend fun markRead(threadId: String) { - threadDao.markRead(threadId) - runCatching { api.markRead(threadId) } - } + override fun search(accountId: String, query: String): Flow> = + threadDao.search(accountId, query).map { rows -> rows.map { it.toDomain() } } - override suspend fun addLabel(threadId: String, labelId: String) { - runCatching { api.addLabel(threadId, labelId) } + override suspend fun refreshThreads(accountId: String, status: ThreadStatus) { + val page = api.getThreads(accountId, status = status.wire) + threadDao.upsertAll(page.threads.map { it.toDomain(accountId).toEntity() }) } - override suspend fun removeLabel(threadId: String, labelId: String) { - runCatching { api.removeLabel(threadId, labelId) } + override suspend fun refreshSignals(accountId: String, threadId: String) { + val page = api.getThreadSignals(accountId, threadId) + signalDao.upsertAll(page.signals.map { it.toDomain().toEntity(accountId) }) } - override suspend fun unsubscribe(threadId: String) { - runCatching { api.unsubscribe(threadId) } + override suspend fun archive(accountId: String, threadId: String) = + patchStatus(accountId, threadId, ThreadStatus.ARCHIVED, null) + + override suspend fun moveToActive(accountId: String, threadId: String) = + patchStatus(accountId, threadId, ThreadStatus.ACTIVE, null) + + override suspend fun snooze(accountId: String, threadId: String, followupAt: Instant) = + patchStatus(accountId, threadId, ThreadStatus.ARCHIVED, followupAt) + + override suspend fun delete(accountId: String, threadId: String) = + patchStatus(accountId, threadId, ThreadStatus.DELETED, null) + + private suspend fun patchStatus( + accountId: String, + threadId: String, + status: ThreadStatus, + followupAt: Instant?, + ) { + threadDao.setStatus(threadId, status.wire, followupAt?.toEpochMilli(), System.currentTimeMillis()) + runCatching { + api.patchThread( + accountId, + threadId, + PatchThreadRequest(status = status.wire, followupAt = followupAt?.toString()), + ) + }.onSuccess { dto -> + threadDao.upsert(dto.toDomain(accountId).toEntity(isPendingSync = false)) + } } - override suspend fun blockSender(threadId: String) { - runCatching { api.blockSender(threadId) } + override suspend fun setLabels(accountId: String, threadId: String, labels: List) { + threadDao.setLabels(threadId, labels.joinToString("|"), System.currentTimeMillis()) + runCatching { api.patchThread(accountId, threadId, PatchThreadRequest(labels = labels)) } + .onSuccess { dto -> threadDao.upsert(dto.toDomain(accountId).toEntity(isPendingSync = false)) } } - override suspend fun approveQuarantine(threadId: String) = moveLocalThenSync(threadId, Folder.ACTIVE, null) - .also { runCatching { api.approveQuarantine(threadId) } } + override suspend fun unsubscribe(accountId: String, threadId: String): Result = + runCatching { api.unsubscribeThread(accountId, threadId).url } - override suspend fun rejectQuarantine(threadId: String) { - runCatching { api.rejectQuarantine(threadId) } - threadDao.delete(threadId) - } - - override suspend fun downloadAttachment(attachment: Attachment): Result = runCatching { - val response = api.downloadAttachment(attachment.messageId, attachment.id) - val body = response.body() ?: error("Empty attachment body") - val dir = File(context.filesDir, "attachments").apply { mkdirs() } - val file = File(dir, "${attachment.id}_${attachment.filename}") - file.outputStream().use { out -> body.byteStream().copyTo(out) } - messageDao.markDownloaded(attachment.id, file.absolutePath) - file.absolutePath + override suspend fun respondToQuarantine(accountId: String, signalId: String, approve: Boolean) { + val status = if (approve) WireSignalStatus.ACTIVE else WireSignalStatus.BLOCK_REJECT + signalDao.updateStatus(signalId, status, pending = true) + runCatching { api.respondToQuarantine(accountId, signalId, QuarantineResponseRequest(status)) } + .onSuccess { signalDao.updateStatus(signalId, status, pending = false) } } override suspend fun syncPending() { threadDao.pendingSync().forEach { entity -> runCatching { - api.moveThread(entity.id, MoveThreadRequest(entity.folder, entity.followupAt)) + api.patchThread( + entity.accountId, + entity.threadId, + PatchThreadRequest( + status = entity.status, + labels = entity.labels, + followupAt = entity.followupAt?.let { Instant.ofEpochMilli(it).toString() }, + ), + ) threadDao.update(entity.copy(isPendingSync = false)) } } + signalDao.pendingSync().forEach { entity -> + runCatching { + api.patchSignal( + entity.accountId, + entity.threadId ?: return@runCatching, + entity.signalId, + ch.rhosys.email.data.remote.dto.PatchSignalRequest(entity.status), + ) + signalDao.updateStatus(entity.signalId, entity.status, pending = false) + } + } } } From 8d61e545b2b6854fa70a53c92266fe5580913567 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 17:41:56 +0000 Subject: [PATCH 06/10] Delete the spam, admin, billing and support screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- .../ch/rhosys/email/domain/model/Settings.kt | 34 --------- .../email/presentation/admin/AdminScreen.kt | 48 ------------ .../presentation/billing/BillingScreen.kt | 62 ---------------- .../presentation/inbox/FolderListViewModel.kt | 50 ------------- .../email/presentation/inbox/InboxScreen.kt | 48 +++++++----- .../presentation/inbox/InboxViewModel.kt | 64 +++++++++------- .../presentation/navigation/Destinations.kt | 12 +-- .../quarantine/QuarantineScreen.kt | 48 ++++++++---- .../quarantine/QuarantineViewModel.kt | 42 +++++++++++ .../email/presentation/spam/SpamScreen.kt | 54 -------------- .../presentation/support/SupportScreen.kt | 74 ------------------- 11 files changed, 149 insertions(+), 387 deletions(-) delete mode 100644 app/src/main/java/ch/rhosys/email/domain/model/Settings.kt delete mode 100644 app/src/main/java/ch/rhosys/email/presentation/admin/AdminScreen.kt delete mode 100644 app/src/main/java/ch/rhosys/email/presentation/billing/BillingScreen.kt delete mode 100644 app/src/main/java/ch/rhosys/email/presentation/inbox/FolderListViewModel.kt create mode 100644 app/src/main/java/ch/rhosys/email/presentation/quarantine/QuarantineViewModel.kt delete mode 100644 app/src/main/java/ch/rhosys/email/presentation/spam/SpamScreen.kt delete mode 100644 app/src/main/java/ch/rhosys/email/presentation/support/SupportScreen.kt diff --git a/app/src/main/java/ch/rhosys/email/domain/model/Settings.kt b/app/src/main/java/ch/rhosys/email/domain/model/Settings.kt deleted file mode 100644 index eee5c6a..0000000 --- a/app/src/main/java/ch/rhosys/email/domain/model/Settings.kt +++ /dev/null @@ -1,34 +0,0 @@ -package ch.rhosys.email.domain.model - -data class DnsRecord( - val type: String, - val name: String, - val value: String, - val isVerified: Boolean, -) - -data class ForwardingAddress( - val id: String, - val emailAddress: String, - val isVerified: Boolean, -) - -data class MfaDevice( - val id: String, - val label: String, - val type: String, - val addedAt: Long, -) - -data class TeamMember( - val id: String, - val emailAddress: String, - val role: String, -) - -data class PlanInfo( - val planName: String, - val emailsUsed: Int, - val emailsQuota: Int, - val renewsAt: Long, -) diff --git a/app/src/main/java/ch/rhosys/email/presentation/admin/AdminScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/admin/AdminScreen.kt deleted file mode 100644 index f00d46c..0000000 --- a/app/src/main/java/ch/rhosys/email/presentation/admin/AdminScreen.kt +++ /dev/null @@ -1,48 +0,0 @@ -package ch.rhosys.email.presentation.admin - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import ch.rhosys.email.data.repository.AdminRepository -import ch.rhosys.email.data.repository.HealthCheck -import ch.rhosys.email.di.LocalAppContainer -import kotlinx.coroutines.launch - -/** Decision #40: full admin panel — Signal Inspector, health check, reprocess. Gated by a Settings toggle. */ -@Composable -fun AdminScreen() { - val container = LocalAppContainer.current - val repository: AdminRepository = container.adminRepository - val scope = rememberCoroutineScope() - var health by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - health = runCatching { repository.getHealthCheck() }.getOrNull() - } - - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Text("Health check", style = MaterialTheme.typography.titleLarge) - Text(health?.status ?: "Loading…", style = MaterialTheme.typography.bodyLarge) - health?.details?.forEach { (key, value) -> - Text("$key: $value", style = MaterialTheme.typography.bodyMedium) - } - Button( - onClick = { scope.launch { health = runCatching { repository.getHealthCheck() }.getOrNull() } }, - modifier = Modifier.padding(top = 16.dp), - ) { - Text("Refresh") - } - } -} diff --git a/app/src/main/java/ch/rhosys/email/presentation/billing/BillingScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/billing/BillingScreen.kt deleted file mode 100644 index 0231854..0000000 --- a/app/src/main/java/ch/rhosys/email/presentation/billing/BillingScreen.kt +++ /dev/null @@ -1,62 +0,0 @@ -package ch.rhosys.email.presentation.billing - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import ch.rhosys.email.di.LocalAppContainer -import ch.rhosys.email.domain.model.PlanInfo -import kotlinx.coroutines.flow.first -import java.text.DateFormat -import java.util.Date - -/** Decision #42: billing/plan info is view-only on mobile; upgrades happen on web. */ -@Composable -fun BillingScreen() { - val container = LocalAppContainer.current - var plan by remember { mutableStateOf(null) } - - LaunchedEffect(Unit) { - val accountId = container.accountRepository.activeAccountId().first { it != null } ?: return@LaunchedEffect - plan = runCatching { container.settingsRepository.getPlanInfo(accountId) }.getOrNull() - } - - val info = plan ?: run { - Text("Loading…", modifier = Modifier.padding(16.dp)) - return - } - - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - Text(info.planName, style = MaterialTheme.typography.titleLarge) - Text( - "${info.emailsUsed} / ${info.emailsQuota} emails used", - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(top = 8.dp), - ) - LinearProgressIndicator( - progress = { (info.emailsUsed.toFloat() / info.emailsQuota.coerceAtLeast(1)).coerceIn(0f, 1f) }, - modifier = Modifier.padding(top = 8.dp), - ) - Text( - "Renews ${DateFormat.getDateInstance().format(Date(info.renewsAt))}", - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp), - ) - Text( - "Manage your plan and payment method on numaeel.com", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 16.dp), - ) - } -} diff --git a/app/src/main/java/ch/rhosys/email/presentation/inbox/FolderListViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/inbox/FolderListViewModel.kt deleted file mode 100644 index 179e609..0000000 --- a/app/src/main/java/ch/rhosys/email/presentation/inbox/FolderListViewModel.kt +++ /dev/null @@ -1,50 +0,0 @@ -package ch.rhosys.email.presentation.inbox - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.cachedIn -import ch.rhosys.email.domain.model.Folder -import ch.rhosys.email.domain.model.MailThread -import ch.rhosys.email.domain.repository.AccountRepository -import ch.rhosys.email.domain.repository.ThreadRepository -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.launch - -/** Shared by Quarantine (#38) and Spam (#39) — both are dedicated folder lists. */ -class FolderListViewModel( - private val folder: Folder, - private val threadRepository: ThreadRepository, - accountRepository: AccountRepository, -) : ViewModel() { - - private val isRefreshing = MutableStateFlow(false) - val refreshing: StateFlow = isRefreshing - - private val activeAccountId = accountRepository.activeAccountId() - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - - val threads: Flow> = activeAccountId.filterNotNull().flatMapLatest { accountId -> - threadRepository.pagedThreads(accountId, folder) - }.cachedIn(viewModelScope) - - fun refresh() { - val accountId = activeAccountId.value ?: return - viewModelScope.launch { - isRefreshing.value = true - runCatching { threadRepository.refreshFolder(accountId, folder) } - isRefreshing.value = false - } - } - - fun approve(threadId: String) = viewModelScope.launch { threadRepository.approveQuarantine(threadId) } - fun reject(threadId: String) = viewModelScope.launch { threadRepository.rejectQuarantine(threadId) } - fun delete(threadId: String) = viewModelScope.launch { threadRepository.delete(threadId) } - fun restore(threadId: String) = viewModelScope.launch { threadRepository.moveToActive(threadId) } -} diff --git a/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxScreen.kt index 22ee28d..4fd1678 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxScreen.kt @@ -33,10 +33,12 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import ch.rhosys.email.domain.model.MailThread +import ch.rhosys.email.domain.model.Urgency import ch.rhosys.email.presentation.components.DelayPickerSheet import ch.rhosys.email.presentation.components.EmptyState import ch.rhosys.email.presentation.components.rememberViewModel @@ -70,25 +72,25 @@ fun InboxScreen(onThreadClick: (String) -> Unit) { val thread = threads[index] ?: return@items InboxRow( thread = thread, - isSelected = thread.id in uiState.selectedIds, + isSelected = thread.threadId in uiState.selectedIds, isSelectionMode = uiState.isSelectionMode, onClick = { - if (uiState.isSelectionMode) viewModel.toggleSelection(thread.id) else onThreadClick(thread.id) + if (uiState.isSelectionMode) viewModel.toggleSelection(thread.threadId) else onThreadClick(thread.threadId) }, - onLongClick = { viewModel.enterSelectionMode(thread.id) }, - onArchive = { viewModel.archive(thread.id) }, - onDelay = { viewModel.openDelayPicker(thread.id) }, - onDelete = { viewModel.delete(thread.id) }, + onLongClick = { viewModel.enterSelectionMode(thread.threadId) }, + onArchive = { viewModel.archive(thread.threadId) }, + onDelay = { viewModel.openSnoozePicker(thread.threadId) }, + onDelete = { viewModel.delete(thread.threadId) }, ) } } } } - if (uiState.delayTargetThreadId != null) { + if (uiState.snoozeTargetThreadId != null) { DelayPickerSheet( - onDismiss = { viewModel.dismissDelayPicker() }, - onConfirm = { millis -> viewModel.confirmDelay(millis) }, + onDismiss = { viewModel.dismissSnoozePicker() }, + onConfirm = { millis -> viewModel.confirmSnooze(millis) }, ) } } @@ -145,19 +147,31 @@ private fun InboxRow( Column(modifier = Modifier.fillMaxWidth()) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { Text( - text = thread.participants.firstOrNull() ?: "Unknown", + text = thread.sender.display, style = MaterialTheme.typography.titleMedium, - fontWeight = if (thread.isRead) null else androidx.compose.ui.text.font.FontWeight.Bold, - ) - Text( - text = DateFormat.getDateInstance(DateFormat.SHORT).format(Date(thread.lastMessageAt)), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + // Emphasis comes from urgency, not unread state — the API + // has no read/unread concept. + fontWeight = when (thread.urgency) { + Urgency.CRITICAL, Urgency.HIGH -> FontWeight.Bold + else -> null + }, + color = when (thread.urgency) { + Urgency.CRITICAL -> MaterialTheme.colorScheme.error + Urgency.SILENT -> MaterialTheme.colorScheme.onSurfaceVariant + else -> MaterialTheme.colorScheme.onSurface + }, ) + thread.lastSignalAt?.let { at -> + Text( + text = DateFormat.getDateInstance(DateFormat.SHORT).format(Date(at.toEpochMilli())), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } Text(text = thread.subject, style = MaterialTheme.typography.bodyLarge, maxLines = 1) Text( - text = thread.snippet, + text = thread.summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, diff --git a/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxViewModel.kt index 4b85493..ae55828 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/inbox/InboxViewModel.kt @@ -4,8 +4,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn -import ch.rhosys.email.domain.model.Folder import ch.rhosys.email.domain.model.MailThread +import ch.rhosys.email.domain.model.ThreadStatus import ch.rhosys.email.domain.repository.AccountRepository import ch.rhosys.email.domain.repository.ThreadRepository import kotlinx.coroutines.flow.Flow @@ -13,63 +13,70 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import java.time.Instant data class InboxUiState( val isRefreshing: Boolean = false, val selectedIds: Set = emptySet(), val isSelectionMode: Boolean = false, - val delayTargetThreadId: String? = null, + val snoozeTargetThreadId: String? = null, val error: String? = null, ) -/** Backs the Inbox screen: simplified single Active list (decision #10). */ +/** + * Backs the Inbox: threads with status ACTIVE. There is no unread count or + * mark-as-read here — the API has no such concept — so rows are emphasised by + * urgency instead. + */ class InboxViewModel( private val threadRepository: ThreadRepository, - private val accountRepository: AccountRepository, + accountRepository: AccountRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(InboxUiState()) val uiState: StateFlow = _uiState.asStateFlow() - val activeAccountId: StateFlow = - accountRepository.activeAccountId().stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + val activeAccountId: StateFlow = accountRepository.activeAccountId() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - val threads: Flow> = activeAccountId.filterNotNull().flatMapLatest { accountId -> - threadRepository.pagedThreads(accountId, Folder.ACTIVE) - }.cachedIn(viewModelScope) + val threads: Flow> = activeAccountId.filterNotNull() + .flatMapLatest { accountId -> threadRepository.pagedThreads(accountId, ThreadStatus.ACTIVE) } + .cachedIn(viewModelScope) fun refresh() { val accountId = activeAccountId.value ?: return viewModelScope.launch { _uiState.value = _uiState.value.copy(isRefreshing = true) - runCatching { threadRepository.refreshFolder(accountId, Folder.ACTIVE) } + runCatching { threadRepository.refreshThreads(accountId, ThreadStatus.ACTIVE) } .onFailure { _uiState.value = _uiState.value.copy(error = it.message) } _uiState.value = _uiState.value.copy(isRefreshing = false) } } - fun archive(threadId: String) = viewModelScope.launch { threadRepository.archive(threadId) } - fun delete(threadId: String) = viewModelScope.launch { threadRepository.delete(threadId) } + fun archive(threadId: String) = withAccount { threadRepository.archive(it, threadId) } + + fun delete(threadId: String) = withAccount { threadRepository.delete(it, threadId) } - fun openDelayPicker(threadId: String) { - _uiState.value = _uiState.value.copy(delayTargetThreadId = threadId) + fun openSnoozePicker(threadId: String) { + _uiState.value = _uiState.value.copy(snoozeTargetThreadId = threadId) } - fun dismissDelayPicker() { - _uiState.value = _uiState.value.copy(delayTargetThreadId = null) + fun dismissSnoozePicker() { + _uiState.value = _uiState.value.copy(snoozeTargetThreadId = null) } - fun confirmDelay(followupAt: Long) { - val threadId = _uiState.value.delayTargetThreadId ?: return - viewModelScope.launch { threadRepository.delay(threadId, followupAt) } - dismissDelayPicker() + fun confirmSnooze(followupAtMillis: Long) { + val threadId = _uiState.value.snoozeTargetThreadId ?: return + withAccount { threadRepository.snooze(it, threadId, Instant.ofEpochMilli(followupAtMillis)) } + dismissSnoozePicker() } - fun addLabel(threadId: String, labelId: String) = viewModelScope.launch { threadRepository.addLabel(threadId, labelId) } + fun setLabels(threadId: String, labels: List) = + withAccount { threadRepository.setLabels(it, threadId, labels) } fun toggleSelection(threadId: String) { val current = _uiState.value.selectedIds @@ -85,13 +92,18 @@ class InboxViewModel( _uiState.value = _uiState.value.copy(isSelectionMode = false, selectedIds = emptySet()) } - fun bulkArchive() = viewModelScope.launch { - _uiState.value.selectedIds.forEach { threadRepository.archive(it) } + fun bulkArchive() = withAccount { accountId -> + _uiState.value.selectedIds.forEach { threadRepository.archive(accountId, it) } clearSelection() } - fun bulkDelete() = viewModelScope.launch { - _uiState.value.selectedIds.forEach { threadRepository.delete(it) } + fun bulkDelete() = withAccount { accountId -> + _uiState.value.selectedIds.forEach { threadRepository.delete(accountId, it) } clearSelection() } + + private fun withAccount(block: suspend (String) -> Unit) { + val accountId = activeAccountId.value ?: return + viewModelScope.launch { block(accountId) } + } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/Destinations.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/Destinations.kt index b9a4c14..d6c560a 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/navigation/Destinations.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/Destinations.kt @@ -6,16 +6,12 @@ sealed class Destination(val route: String) { data object Inbox : Destination("inbox") data object Archived : Destination("archived") data object Quarantine : Destination("quarantine") - data object Spam : Destination("spam") data object Drafts : Destination("drafts") data object Labels : Destination("labels") data object Rules : Destination("rules") data object Templates : Destination("templates") data object Settings : Destination("settings") - data object Admin : Destination("admin") data object Stats : Destination("stats") - data object Billing : Destination("billing") - data object Support : Destination("support") data object Thread : Destination("thread/{threadId}") { fun route(threadId: String) = "thread/$threadId" @@ -27,7 +23,11 @@ sealed class Destination(val route: String) { } companion object { - /** Drawer items mirroring the web sidebar (decision #11). */ - val drawerItems = listOf(Inbox, Quarantine, Spam, Drafts, Rules, Templates, Labels, Settings) + /** + * Spam, Admin, Billing and Support are absent: the API backs none of + * them. Filtered mail surfaces under Quarantine, which maps to signal + * status plus quarantineResponse. + */ + val drawerItems = listOf(Inbox, Quarantine, Drafts, Rules, Templates, Labels, Settings) } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/quarantine/QuarantineScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/quarantine/QuarantineScreen.kt index ecc1277..f58f282 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/quarantine/QuarantineScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/quarantine/QuarantineScreen.kt @@ -17,39 +17,55 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import androidx.paging.compose.collectAsLazyPagingItems import ch.rhosys.email.di.LocalAppContainer -import ch.rhosys.email.domain.model.Folder +import ch.rhosys.email.domain.model.Signal import ch.rhosys.email.presentation.components.EmptyState import ch.rhosys.email.presentation.components.rememberViewModel -import ch.rhosys.email.presentation.inbox.FolderListViewModel -/** Decision #38: dedicated Quarantine screen with approve/reject buttons per row. */ +/** Quarantined signals awaiting an approve/reject decision. */ @Composable fun QuarantineScreen(onThreadClick: (String) -> Unit) { val container = LocalAppContainer.current val viewModel = rememberViewModel { - FolderListViewModel(Folder.QUARANTINE, container.threadRepository, container.accountRepository) + QuarantineViewModel(container.threadRepository, container.accountRepository) } - val threads = viewModel.threads.collectAsLazyPagingItems() + val signals by viewModel.signals.collectAsState() - if (threads.itemCount == 0) { - EmptyState(title = "Nothing in quarantine", message = "Suspicious senders awaiting approval show up here.", celebration = false) + if (signals.isEmpty()) { + EmptyState( + title = "Nothing in quarantine", + message = "Mail from unrecognised senders waits here for approval.", + celebration = false, + ) return } LazyColumn(modifier = Modifier.fillMaxSize()) { - items(threads.itemCount) { index -> - val thread = threads[index] ?: return@items + items(signals, key = { it.signalId }) { signal -> Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) { - TextButton(onClick = { onThreadClick(thread.id) }) { - Text(thread.subject, maxLines = 1) + val subject = (signal as? Signal.InboundEmail)?.subject ?: "Filtered signal" + val detail = when (signal) { + is Signal.InboundEmail -> "${signal.from.display} — ${signal.summary}" + is Signal.SystemNotice -> signal.detail.orEmpty() + is Signal.OutboundEmail -> signal.subject } - Text(thread.snippet, style = MaterialTheme.typography.bodyMedium, maxLines = 2) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp)) { - TextButton(onClick = { viewModel.approve(thread.id) }) { Text("Approve") } - TextButton(onClick = { viewModel.reject(thread.id) }) { Text("Reject") } + + val threadId = signal.threadId + if (threadId != null) { + TextButton(onClick = { onThreadClick(threadId) }) { Text(subject, maxLines = 1) } + } else { + Text(subject, style = MaterialTheme.typography.titleMedium, maxLines = 1) + } + + Text(detail, style = MaterialTheme.typography.bodyMedium, maxLines = 2) + + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(top = 4.dp), + ) { + TextButton(onClick = { viewModel.approve(signal.signalId) }) { Text("Approve") } + TextButton(onClick = { viewModel.reject(signal.signalId) }) { Text("Reject") } } } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/quarantine/QuarantineViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/quarantine/QuarantineViewModel.kt new file mode 100644 index 0000000..97d6c65 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/presentation/quarantine/QuarantineViewModel.kt @@ -0,0 +1,42 @@ +package ch.rhosys.email.presentation.quarantine + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import ch.rhosys.email.domain.model.Signal +import ch.rhosys.email.domain.repository.AccountRepository +import ch.rhosys.email.domain.repository.ThreadRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** + * Quarantine is resolved per signal, not per thread: a quarantined item is a + * signal with status quarantine_visible or quarantine_hidden, and approving or + * rejecting posts to that signal's quarantineResponse. + */ +class QuarantineViewModel( + private val threadRepository: ThreadRepository, + accountRepository: AccountRepository, +) : ViewModel() { + + private val activeAccountId: StateFlow = accountRepository.activeAccountId() + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + + val signals: StateFlow> = activeAccountId.filterNotNull() + .flatMapLatest { threadRepository.observeQuarantined(it) } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + + fun approve(signalId: String) = respond(signalId, approve = true) + + fun reject(signalId: String) = respond(signalId, approve = false) + + private fun respond(signalId: String, approve: Boolean) { + val accountId = activeAccountId.value ?: return + viewModelScope.launch { + threadRepository.respondToQuarantine(accountId, signalId, approve) + } + } +} diff --git a/app/src/main/java/ch/rhosys/email/presentation/spam/SpamScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/spam/SpamScreen.kt deleted file mode 100644 index 26364b0..0000000 --- a/app/src/main/java/ch/rhosys/email/presentation/spam/SpamScreen.kt +++ /dev/null @@ -1,54 +0,0 @@ -package ch.rhosys.email.presentation.spam - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.material3.Card -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import androidx.paging.compose.collectAsLazyPagingItems -import ch.rhosys.email.di.LocalAppContainer -import ch.rhosys.email.domain.model.Folder -import ch.rhosys.email.presentation.components.EmptyState -import ch.rhosys.email.presentation.components.rememberViewModel -import ch.rhosys.email.presentation.inbox.FolderListViewModel - -/** Decision #39: Spam is its own dedicated screen, separate from quarantine. */ -@Composable -fun SpamScreen(onThreadClick: (String) -> Unit) { - val container = LocalAppContainer.current - val viewModel = rememberViewModel { - FolderListViewModel(Folder.SPAM, container.threadRepository, container.accountRepository) - } - val threads = viewModel.threads.collectAsLazyPagingItems() - - if (threads.itemCount == 0) { - EmptyState(title = "No spam", message = "Flagged junk mail lands here.", celebration = false) - return - } - - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(threads.itemCount) { index -> - val thread = threads[index] ?: return@items - Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { - Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) { - TextButton(onClick = { onThreadClick(thread.id) }) { Text(thread.subject, maxLines = 1) } - Text(thread.snippet, style = MaterialTheme.typography.bodyMedium, maxLines = 2) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(top = 4.dp)) { - TextButton(onClick = { viewModel.restore(thread.id) }) { Text("Not spam") } - TextButton(onClick = { viewModel.delete(thread.id) }) { Text("Delete") } - } - } - } - } - } -} diff --git a/app/src/main/java/ch/rhosys/email/presentation/support/SupportScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/support/SupportScreen.kt deleted file mode 100644 index dd396e4..0000000 --- a/app/src/main/java/ch/rhosys/email/presentation/support/SupportScreen.kt +++ /dev/null @@ -1,74 +0,0 @@ -package ch.rhosys.email.presentation.support - -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import ch.rhosys.email.di.LocalAppContainer -import kotlinx.coroutines.launch - -private val categories = listOf("Bug report", "Billing question", "Feature request", "Deliverability issue", "Other") - -/** Decision #43: submit support tickets in-app instead of only mailto. */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SupportScreen() { - val container = LocalAppContainer.current - val scope = rememberCoroutineScope() - var category by remember { mutableStateOf(categories.first()) } - var expanded by remember { mutableStateOf(false) } - var description by remember { mutableStateOf("") } - var submitted by remember { mutableStateOf(false) } - - Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { - if (submitted) { - Text("Thanks — we'll get back to you by email.", style = MaterialTheme.typography.titleMedium) - return@Column - } - Text("Contact support", style = MaterialTheme.typography.titleLarge) - ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }, modifier = Modifier.padding(top = 12.dp)) { - TextField( - value = category, onValueChange = {}, readOnly = true, label = { Text("Category") }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier.fillMaxWidth().menuAnchor(MenuAnchorType.PrimaryNotEditable), - ) - ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - categories.forEach { option -> - DropdownMenuItem(text = { Text(option) }, onClick = { category = option; expanded = false }) - } - } - } - TextField( - value = description, onValueChange = { description = it }, - label = { Text("Describe the issue") }, - modifier = Modifier.fillMaxWidth().height(160.dp).padding(top = 12.dp), - ) - Button( - onClick = { - scope.launch { - container.supportRepository.submitTicket(category, description) - submitted = true - } - }, - modifier = Modifier.padding(top = 16.dp), - ) { Text("Submit") } - } -} From a8b496fb33fa90909feecfce82c630d5b4437a3b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:03:24 +0000 Subject: [PATCH 07/10] Fix unclosed KDoc comment and drop the undo-send machinery 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- app/src/main/AndroidManifest.xml | 2 - .../data/repository/AdminStatsRepositories.kt | 2 +- .../email/data/repository/StatsRepository.kt | 16 +++ .../java/ch/rhosys/email/di/AppContainer.kt | 18 ++-- .../rhosys/email/sync/PendingSendManager.kt | 97 ------------------- 5 files changed, 24 insertions(+), 111 deletions(-) create mode 100644 app/src/main/java/ch/rhosys/email/data/repository/StatsRepository.kt delete mode 100644 app/src/main/java/ch/rhosys/email/sync/PendingSendManager.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d9d36b1..a162c16 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -81,8 +81,6 @@ android:exported="false" android:foregroundServiceType="dataSync" /> - - diff --git a/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt b/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt index cf1f1d3..2c03eba 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt @@ -13,7 +13,7 @@ class StatsRepository(private val api: EmailApiService) { } /** - * The previous admin repository called v1/admin/* routes that never existed. + * The previous admin repository called `v1/admin` routes that never existed. * The API offers a global health check plus per-signal reprocess and raw * fetch — both of which are addressed by account, thread and signal. */ diff --git a/app/src/main/java/ch/rhosys/email/data/repository/StatsRepository.kt b/app/src/main/java/ch/rhosys/email/data/repository/StatsRepository.kt new file mode 100644 index 0000000..54ddac0 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/repository/StatsRepository.kt @@ -0,0 +1,16 @@ +package ch.rhosys.email.data.repository + +import ch.rhosys.email.data.remote.api.EmailApiService + +/** + * Stats come back as a free-form object: the OpenAPI document declares + * `/accounts/{accountId}/stats` with an untyped response, so the shape is + * surfaced as-is rather than invented into a typed summary. + * + * The admin repository that used to live alongside this is gone with the admin + * screen. Its healthcheck and per-signal reprocess endpoints are real and can + * come back whenever that screen does. + */ +class StatsRepository(private val api: EmailApiService) { + suspend fun getStats(accountId: String): Map = api.getStats(accountId) +} diff --git a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt index f09e621..d232233 100644 --- a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt +++ b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt @@ -9,13 +9,11 @@ import ch.rhosys.email.data.local.EmailDatabase import ch.rhosys.email.data.remote.api.AuthInterceptor import ch.rhosys.email.data.remote.api.EmailApiService import ch.rhosys.email.data.repository.AccountRepositoryImpl -import ch.rhosys.email.data.repository.AdminRepository import ch.rhosys.email.data.repository.ComposeRepositoryImpl import ch.rhosys.email.data.repository.LabelRepositoryImpl import ch.rhosys.email.data.repository.RuleRepositoryImpl import ch.rhosys.email.data.repository.SettingsRepository import ch.rhosys.email.data.repository.StatsRepository -import ch.rhosys.email.data.repository.SupportRepository import ch.rhosys.email.data.repository.TemplateRepositoryImpl import ch.rhosys.email.data.repository.ThreadRepositoryImpl import ch.rhosys.email.domain.repository.AccountRepository @@ -72,7 +70,11 @@ class AppContainer(private val context: Context) { } val database: EmailDatabase by lazy { - Room.databaseBuilder(context, EmailDatabase::class.java, EmailDatabase.NAME).build() + // The v1 schema described an API that does not exist, so there is nothing + // worth migrating — the cache simply refetches against the real one. + Room.databaseBuilder(context, EmailDatabase::class.java, EmailDatabase.NAME) + .fallbackToDestructiveMigration(dropAllTables = true) + .build() } val accountRepository: AccountRepository by lazy { @@ -80,11 +82,11 @@ class AppContainer(private val context: Context) { } val threadRepository: ThreadRepository by lazy { - ThreadRepositoryImpl(context, apiService, database) + ThreadRepositoryImpl(apiService, database) } val composeRepository: ComposeRepository by lazy { - ComposeRepositoryImpl(apiService, database.draftDao()) + ComposeRepositoryImpl(apiService, database.signalDao()) } val labelRepository: LabelRepository by lazy { @@ -101,14 +103,8 @@ class AppContainer(private val context: Context) { val settingsRepository: SettingsRepository by lazy { SettingsRepository(apiService) } val statsRepository: StatsRepository by lazy { StatsRepository(apiService) } - val adminRepository: AdminRepository by lazy { AdminRepository(apiService) } - val supportRepository: SupportRepository by lazy { SupportRepository(apiService) } val preferencesStore: ch.rhosys.email.data.local.PreferencesStore by lazy { ch.rhosys.email.data.local.PreferencesStore(context) } - - val pendingSendManager: ch.rhosys.email.sync.PendingSendManager by lazy { - ch.rhosys.email.sync.PendingSendManager(context, composeRepository) - } } diff --git a/app/src/main/java/ch/rhosys/email/sync/PendingSendManager.kt b/app/src/main/java/ch/rhosys/email/sync/PendingSendManager.kt deleted file mode 100644 index 4878425..0000000 --- a/app/src/main/java/ch/rhosys/email/sync/PendingSendManager.kt +++ /dev/null @@ -1,97 +0,0 @@ -package ch.rhosys.email.sync - -import android.Manifest -import android.app.PendingIntent -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import android.content.pm.PackageManager -import androidx.core.app.NotificationCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat -import ch.rhosys.email.EmailApp -import ch.rhosys.email.R -import ch.rhosys.email.domain.repository.ComposeRepository -import ch.rhosys.email.notification.NotificationChannels -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap - -data class PendingSend( - val fromAlias: String, - val to: List, - val cc: List, - val bcc: List, - val subject: String, - val bodyMarkdown: String, - val inReplyToThreadId: String?, -) - -/** - * Decision #31: tapping Send returns to the inbox immediately; a system - * notification offers an 8-second Undo window before the message actually - * goes out. Lives at application scope (not a ViewModel) so it survives - * Compose navigating away from the compose screen. - */ -class PendingSendManager(private val context: Context, private val composeRepository: ComposeRepository) { - - private val scope = CoroutineScope(SupervisorJob()) - private val jobs = ConcurrentHashMap() - private val notificationManager = NotificationManagerCompat.from(context) - - fun scheduleSend(pending: PendingSend, windowMillis: Long = 8_000L) { - val id = UUID.randomUUID().toString() - showUndoNotification(id) - jobs[id] = scope.launch { - delay(windowMillis) - if (isActive) { - composeRepository.send( - pending.fromAlias, pending.to, pending.cc, pending.bcc, - pending.subject, pending.bodyMarkdown, pending.inReplyToThreadId, null, - ) - notificationManager.cancel(id.hashCode()) - } - jobs.remove(id) - } - } - - fun undo(id: String) { - jobs.remove(id)?.cancel() - notificationManager.cancel(id.hashCode()) - } - - private fun showUndoNotification(id: String) { - val undoIntent = Intent(context, UndoSendReceiver::class.java).apply { putExtra(EXTRA_PENDING_ID, id) } - val pendingIntent = PendingIntent.getBroadcast( - context, id.hashCode(), undoIntent, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, - ) - val notification = NotificationCompat.Builder(context, NotificationChannels.UNDO_SEND) - .setSmallIcon(R.drawable.ic_notification) - .setContentTitle("Sending…") - .addAction(0, "Undo", pendingIntent) - .setTimeoutAfter(8_000L) - .setAutoCancel(true) - .build() - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED) { - notificationManager.notify(id.hashCode(), notification) - } - } - - companion object { - const val EXTRA_PENDING_ID = "pending_id" - } -} - -class UndoSendReceiver : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - val id = intent.getStringExtra(PendingSendManager.EXTRA_PENDING_ID) ?: return - val app = context.applicationContext as? EmailApp ?: return - app.appContainer.pendingSendManager.undo(id) - } -} From 9f563285305d1aaca3b71401ac42bcf920a813c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:57:30 +0000 Subject: [PATCH 08/10] Migrate thread, workflow panel and compose to the signal model 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- .../presentation/compose/ComposeViewModel.kt | 154 +++++++++++------- .../presentation/thread/ThreadViewModel.kt | 80 ++++++--- .../presentation/thread/WorkflowPanelView.kt | 50 +++--- 3 files changed, 171 insertions(+), 113 deletions(-) diff --git a/app/src/main/java/ch/rhosys/email/presentation/compose/ComposeViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/compose/ComposeViewModel.kt index 14db3e5..549b7a0 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/compose/ComposeViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/compose/ComposeViewModel.kt @@ -3,11 +3,9 @@ package ch.rhosys.email.presentation.compose import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import ch.rhosys.email.domain.model.Alias -import ch.rhosys.email.domain.model.Draft +import ch.rhosys.email.domain.model.Signal import ch.rhosys.email.domain.repository.AccountRepository import ch.rhosys.email.domain.repository.ComposeRepository -import ch.rhosys.email.sync.PendingSend -import ch.rhosys.email.sync.PendingSendManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow @@ -16,112 +14,144 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.util.UUID data class ComposeUiState( - val draftId: String = UUID.randomUUID().toString(), + /** Server-assigned once the draft signal exists; null before the first save. */ + val signalId: String? = null, val accountId: String = "", + val threadId: String? = null, val fromAlias: String = "", val toAddresses: String = "", - val ccAddresses: String = "", - val bccAddresses: String = "", val subject: String = "", - val bodyMarkdown: String = "", + val body: String = "", val isPreview: Boolean = false, val showAliasPicker: Boolean = false, - val inReplyToThreadId: String? = null, + val isSending: Boolean = false, val isSent: Boolean = false, -) + val error: String? = null, +) { + /** + * A draft can only exist on a thread: draft creation posts to that thread's + * signals collection, and the API has no route for a standalone draft. So + * composing is available from a reply or forward, not from a blank slate. + */ + val canCompose: Boolean get() = threadId != null +} +/** + * Compose backed by draft signals. + * + * Sending is immediate — POST .../signals/{id}/send — because the API exposes + * neither a scheduling parameter nor a cancel route. The previous send-later + * plus undo-send flow had no backend at all. + */ class ComposeViewModel( private val composeRepository: ComposeRepository, private val accountRepository: AccountRepository, - private val pendingSendManager: PendingSendManager, initialThreadId: String?, - initialDraftId: String?, + initialSignalId: String?, ) : ViewModel() { - private val _uiState = MutableStateFlow(ComposeUiState(inReplyToThreadId = initialThreadId)) + private val _uiState = MutableStateFlow( + ComposeUiState(threadId = initialThreadId, signalId = initialSignalId), + ) val uiState: StateFlow = _uiState.asStateFlow() val aliases: StateFlow> = accountRepository.activeAccountId() .filterNotNull() .flatMapLatest { accountId -> - _uiState.update { it.copy(accountId = accountId) } + update { it.copy(accountId = accountId) } accountRepository.observeAliases(accountId) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) init { - if (initialDraftId != null) { + if (initialSignalId != null) { viewModelScope.launch { - composeRepository.getDraft(initialDraftId)?.let { draft -> loadDraft(draft) } + composeRepository.getDraft(initialSignalId)?.let(::loadDraft) } } viewModelScope.launch { aliases.collect { list -> if (_uiState.value.fromAlias.isEmpty()) { - (list.firstOrNull { it.isDefault } ?: list.firstOrNull())?.let { setFromAlias(it.emailAddress) } + list.firstOrNull()?.let { setFromAlias(it.alias) } } } } } - private fun loadDraft(draft: Draft) { - _uiState.update { - it.copy( - draftId = draft.id, - accountId = draft.accountId, - fromAlias = draft.fromAlias, - toAddresses = draft.toAddresses.joinToString(", "), - ccAddresses = draft.ccAddresses.joinToString(", "), - bccAddresses = draft.bccAddresses.joinToString(", "), - subject = draft.subject, - bodyMarkdown = draft.bodyMarkdown, - inReplyToThreadId = draft.threadId, - ) - } + private fun loadDraft(draft: Signal.OutboundEmail) = update { + it.copy( + signalId = draft.signalId, + threadId = draft.threadId ?: it.threadId, + fromAlias = draft.from.address, + toAddresses = draft.to.joinToString(", ") { addr -> addr.address }, + subject = draft.subject, + body = draft.body.orEmpty(), + ) } - fun setFromAlias(alias: String) = _uiState.update { it.copy(fromAlias = alias, showAliasPicker = false) } - fun setTo(value: String) = _uiState.update { it.copy(toAddresses = value) } - fun setCc(value: String) = _uiState.update { it.copy(ccAddresses = value) } - fun setBcc(value: String) = _uiState.update { it.copy(bccAddresses = value) } - fun setSubject(value: String) = _uiState.update { it.copy(subject = value) } - fun setBody(value: String) = _uiState.update { it.copy(bodyMarkdown = value) } - fun togglePreview() = _uiState.update { it.copy(isPreview = !it.isPreview) } - fun openAliasPicker() = _uiState.update { it.copy(showAliasPicker = true) } - fun dismissAliasPicker() = _uiState.update { it.copy(showAliasPicker = false) } - - private inline fun MutableStateFlow.update(transform: (ComposeUiState) -> ComposeUiState) { - value = transform(value) - } + fun setFromAlias(alias: String) = update { it.copy(fromAlias = alias, showAliasPicker = false) } + fun setTo(value: String) = update { it.copy(toAddresses = value) } + fun setSubject(value: String) = update { it.copy(subject = value) } + fun setBody(value: String) = update { it.copy(body = value) } + fun togglePreview() = update { it.copy(isPreview = !it.isPreview) } + fun openAliasPicker() = update { it.copy(showAliasPicker = true) } + fun dismissAliasPicker() = update { it.copy(showAliasPicker = false) } fun saveDraft() { val s = _uiState.value + val threadId = s.threadId ?: return viewModelScope.launch { - composeRepository.saveDraft( - Draft( - id = s.draftId, accountId = s.accountId, threadId = s.inReplyToThreadId, fromAlias = s.fromAlias, - toAddresses = splitAddresses(s.toAddresses), ccAddresses = splitAddresses(s.ccAddresses), - bccAddresses = splitAddresses(s.bccAddresses), subject = s.subject, bodyMarkdown = s.bodyMarkdown, - updatedAt = System.currentTimeMillis(), - ), - ) + if (s.signalId == null) { + composeRepository.createDraft( + accountId = s.accountId, + threadId = threadId, + fromAlias = s.fromAlias, + to = splitAddresses(s.toAddresses), + subject = s.subject, + body = s.body, + ).onSuccess { id -> update { it.copy(signalId = id) } } + .onFailure { e -> update { it.copy(error = e.message) } } + } else { + composeRepository.updateDraft( + accountId = s.accountId, + threadId = threadId, + signalId = s.signalId, + fromAlias = s.fromAlias, + subject = s.subject, + body = s.body, + ).onFailure { e -> update { it.copy(error = e.message) } } + } } } + /** Saves the draft if needed, then sends it. No undo window exists. */ fun send() { - val s = _uiState.value - pendingSendManager.scheduleSend( - PendingSend( - fromAlias = s.fromAlias, to = splitAddresses(s.toAddresses), cc = splitAddresses(s.ccAddresses), - bcc = splitAddresses(s.bccAddresses), subject = s.subject, bodyMarkdown = s.bodyMarkdown, - inReplyToThreadId = s.inReplyToThreadId, - ), - ) - viewModelScope.launch { composeRepository.deleteDraft(s.draftId) } - _uiState.update { it.copy(isSent = true) } + val threadId = _uiState.value.threadId ?: return + viewModelScope.launch { + update { it.copy(isSending = true, error = null) } + val s = _uiState.value + val signalId = s.signalId ?: composeRepository.createDraft( + accountId = s.accountId, + threadId = threadId, + fromAlias = s.fromAlias, + to = splitAddresses(s.toAddresses), + subject = s.subject, + body = s.body, + ).getOrElse { e -> + update { it.copy(isSending = false, error = e.message) } + return@launch + } + + composeRepository.send(s.accountId, threadId, signalId) + .onSuccess { update { it.copy(isSending = false, isSent = true) } } + .onFailure { e -> update { it.copy(isSending = false, error = e.message) } } + } + } + + private inline fun update(transform: (ComposeUiState) -> ComposeUiState) { + _uiState.value = transform(_uiState.value) } private fun splitAddresses(value: String): List = diff --git a/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadViewModel.kt index cabc67c..9199ffb 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadViewModel.kt @@ -2,69 +2,81 @@ package ch.rhosys.email.presentation.thread import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import ch.rhosys.email.domain.model.Attachment import ch.rhosys.email.domain.model.MailThread -import ch.rhosys.email.domain.model.Message +import ch.rhosys.email.domain.model.SenderPolicy +import ch.rhosys.email.domain.model.Signal +import ch.rhosys.email.domain.repository.AccountRepository import ch.rhosys.email.domain.repository.ThreadRepository import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch data class ThreadDetailUiState( - val expandedMessageIds: Set = emptySet(), + val expandedSignalIds: Set = emptySet(), val showBlockSenderConfirm: Boolean = false, val isLoading: Boolean = true, + val unsubscribeUrl: String? = null, ) +/** + * Thread detail. There is no mark-as-read on open — the API has no read state — + * and no attachment download, since the API exposes no download endpoint; + * attachments are shown with whatever `url` the backend supplies, if any. + */ class ThreadViewModel( + private val accountId: String, private val threadId: String, private val threadRepository: ThreadRepository, + private val accountRepository: AccountRepository, ) : ViewModel() { private val _uiState = MutableStateFlow(ThreadDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() - val thread: StateFlow = - threadRepository.observeThread(threadId).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) + val thread: StateFlow = threadRepository.observeThread(threadId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - val messages: StateFlow> = - threadRepository.observeMessages(threadId).stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) + val signals: StateFlow> = threadRepository.observeSignals(threadId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) init { viewModelScope.launch { - threadRepository.markRead(threadId) - runCatching { threadRepository.refreshMessages(threadId) } + runCatching { threadRepository.refreshSignals(accountId, threadId) } _uiState.value = _uiState.value.copy(isLoading = false) } - // Expand only the latest message by default (decision #16). + // Expand only the most recent signal by default. viewModelScope.launch { - messages.combine(thread) { msgs, _ -> msgs }.collect { msgs -> - val latest = msgs.maxByOrNull { it.sentAt } - if (latest != null && _uiState.value.expandedMessageIds.isEmpty()) { - _uiState.value = _uiState.value.copy(expandedMessageIds = setOf(latest.id)) + signals.collect { items -> + val latest = items.maxByOrNull { it.createdAt?.toEpochMilli() ?: 0L } + if (latest != null && _uiState.value.expandedSignalIds.isEmpty()) { + _uiState.value = _uiState.value.copy(expandedSignalIds = setOf(latest.signalId)) } } } } - fun toggleExpanded(messageId: String) { - val current = _uiState.value.expandedMessageIds + fun toggleExpanded(signalId: String) { + val current = _uiState.value.expandedSignalIds _uiState.value = _uiState.value.copy( - expandedMessageIds = if (messageId in current) current - messageId else current + messageId, + expandedSignalIds = if (signalId in current) current - signalId else current + signalId, ) } - fun downloadAttachment(attachment: Attachment) = viewModelScope.launch { - threadRepository.downloadAttachment(attachment) + fun archive() = viewModelScope.launch { threadRepository.archive(accountId, threadId) } + + fun delete() = viewModelScope.launch { threadRepository.delete(accountId, threadId) } + + fun unsubscribe() = viewModelScope.launch { + threadRepository.unsubscribe(accountId, threadId) + .onSuccess { url -> _uiState.value = _uiState.value.copy(unsubscribeUrl = url) } } - fun archive() = viewModelScope.launch { threadRepository.archive(threadId) } - fun delete() = viewModelScope.launch { threadRepository.delete(threadId) } - fun unsubscribe() = viewModelScope.launch { threadRepository.unsubscribe(threadId) } + fun consumeUnsubscribeUrl() { + _uiState.value = _uiState.value.copy(unsubscribeUrl = null) + } fun requestBlockSender() { _uiState.value = _uiState.value.copy(showBlockSenderConfirm = true) @@ -74,11 +86,25 @@ class ThreadViewModel( _uiState.value = _uiState.value.copy(showBlockSenderConfirm = false) } + /** + * Blocking applies a reject policy to the sender's domain on the alias that + * received the mail — the API has no per-thread block. + */ fun confirmBlockSender() { - viewModelScope.launch { threadRepository.blockSender(threadId) } + val current = thread.value ?: return dismissBlockSenderConfirm() + val domain = current.sender.address.substringAfter('@', "") + if (domain.isNotBlank()) { + viewModelScope.launch { + runCatching { + accountRepository.setSenderPolicy( + accountId = accountId, + alias = current.recipientAddress, + domain = domain, + policy = SenderPolicy.BLOCK_REJECT, + ) + } + } + } dismissBlockSenderConfirm() } - - fun approveQuarantine() = viewModelScope.launch { threadRepository.approveQuarantine(threadId) } - fun rejectQuarantine() = viewModelScope.launch { threadRepository.rejectQuarantine(threadId) } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/thread/WorkflowPanelView.kt b/app/src/main/java/ch/rhosys/email/presentation/thread/WorkflowPanelView.kt index 3cfffe2..e8ea860 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/thread/WorkflowPanelView.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/thread/WorkflowPanelView.kt @@ -31,19 +31,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import ch.rhosys.email.domain.model.WorkflowType +import ch.rhosys.email.domain.model.Workflow import androidx.core.content.getSystemService /** - * Renders one card per workflow classification (decision #37: all 14 types). - * Since the backend contract for structured fields isn't fixed yet, this uses - * a generic label/value layout keyed by [WorkflowType] for icon + title, with - * copy buttons on each value (decision #72). + * One card per workflow classification, keyed by the backend's [Workflow] enum. + * + * Structured fields come from the signal's typed workflowData payload, which + * varies per workflow; callers flatten whichever payload they have into + * label/value pairs. The free-form workflowFields map the old thread model + * carried does not exist in the API. */ @Composable -fun WorkflowPanelView(type: WorkflowType, fields: Map, modifier: Modifier = Modifier) { - if (type == WorkflowType.NONE || fields.isEmpty()) return - val (icon, title) = workflowMeta(type) +fun WorkflowPanelView(workflow: Workflow, fields: Map, modifier: Modifier = Modifier) { + if (fields.isEmpty()) return + val (icon, title) = workflowMeta(workflow) val context = LocalContext.current val clipboard = context.getSystemService() @@ -74,20 +76,20 @@ fun WorkflowPanelView(type: WorkflowType, fields: Map, modifier: } } -private fun workflowMeta(type: WorkflowType): Pair = when (type) { - WorkflowType.AUTH -> Icons.Filled.Security to "Verification code" - WorkflowType.TRAVEL -> Icons.Filled.Flight to "Travel itinerary" - WorkflowType.PAYMENT -> Icons.Filled.Payments to "Payment" - WorkflowType.SCHEDULING -> Icons.Filled.Schedule to "Scheduled event" - WorkflowType.CONVERSATION -> Icons.Filled.Info to "Conversation" - WorkflowType.CRM -> Icons.Filled.Info to "Contact" - WorkflowType.PACKAGE -> Icons.Filled.Inventory2 to "Package tracking" - WorkflowType.ALERT -> Icons.Filled.Warning to "Alert" - WorkflowType.CONTENT -> Icons.Filled.Info to "Content summary" - WorkflowType.STATUS -> Icons.Filled.Info to "Status" - WorkflowType.HEALTHCARE -> Icons.Filled.HealthAndSafety to "Healthcare" - WorkflowType.JOB -> Icons.Filled.Work to "Job update" - WorkflowType.SUPPORT -> Icons.Filled.SupportAgent to "Support ticket" - WorkflowType.TEST -> Icons.Filled.Info to "Test signal" - WorkflowType.NONE -> Icons.Filled.Info to "" +private fun workflowMeta(workflow: Workflow): Pair = when (workflow) { + Workflow.AUTH -> Icons.Filled.Security to "Verification code" + Workflow.TRAVEL -> Icons.Filled.Flight to "Travel itinerary" + Workflow.PAYMENTS -> Icons.Filled.Payments to "Payment" + Workflow.EVENTS -> Icons.Filled.Schedule to "Event" + Workflow.CONVERSATION -> Icons.Filled.Info to "Conversation" + Workflow.CRM -> Icons.Filled.Info to "Contact" + Workflow.PACKAGE -> Icons.Filled.Inventory2 to "Package tracking" + Workflow.ALERT -> Icons.Filled.Warning to "Alert" + Workflow.CONTENT -> Icons.Filled.Info to "Content summary" + Workflow.NOTICE -> Icons.Filled.Info to "Notice" + Workflow.ONBOARDING -> Icons.Filled.Info to "Getting started" + Workflow.HEALTHCARE -> Icons.Filled.HealthAndSafety to "Healthcare" + Workflow.JOB -> Icons.Filled.Work to "Job update" + Workflow.SUPPORT -> Icons.Filled.SupportAgent to "Support" + Workflow.TEST -> Icons.Filled.Info to "Test signal" } From 1c5c1456fb3471c998771b7bf174adc1fa9807f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 20:32:43 +0000 Subject: [PATCH 09/10] Finish the UI migration; correct sender policy and attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- .../2.json | 642 ++++++++++++++++++ .../email/data/local/entity/AccountEntity.kt | 6 +- .../email/data/local/entity/SignalEntity.kt | 53 +- .../email/data/remote/dto/AccountDtos.kt | 19 +- .../rhosys/email/data/remote/dto/Mappers.kt | 12 +- .../data/repository/AccountRepositoryImpl.kt | 15 + .../data/repository/AdminStatsRepositories.kt | 30 - .../data/repository/ComposeRepositoryImpl.kt | 4 +- .../data/repository/ThreadRepositoryImpl.kt | 4 +- .../java/ch/rhosys/email/di/AppContainer.kt | 2 +- .../ch/rhosys/email/domain/model/Account.kt | 51 +- .../email/domain/repository/Repositories.kt | 12 +- .../presentation/compose/ComposeScreen.kt | 29 +- .../email/presentation/drafts/DraftsScreen.kt | 25 +- .../email/presentation/labels/LabelsScreen.kt | 6 +- .../presentation/labels/LabelsViewModel.kt | 15 +- .../presentation/navigation/AppScaffold.kt | 10 +- .../email/presentation/navigation/NavGraph.kt | 31 +- .../email/presentation/rules/RulesScreen.kt | 23 +- .../presentation/settings/SettingsScreen.kt | 83 +-- .../settings/SettingsViewModel.kt | 83 +-- .../email/presentation/stats/StatsScreen.kt | 87 ++- .../presentation/templates/TemplatesScreen.kt | 2 +- .../email/presentation/thread/ThreadScreen.kt | 182 +++-- .../presentation/thread/ThreadViewModel.kt | 76 ++- .../ch/rhosys/email/widget/InboxWidget.kt | 19 +- 26 files changed, 1217 insertions(+), 304 deletions(-) create mode 100644 app/schemas/ch.rhosys.email.data.local.EmailDatabase/2.json delete mode 100644 app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt diff --git a/app/schemas/ch.rhosys.email.data.local.EmailDatabase/2.json b/app/schemas/ch.rhosys.email.data.local.EmailDatabase/2.json new file mode 100644 index 0000000..71d5755 --- /dev/null +++ b/app/schemas/ch.rhosys.email.data.local.EmailDatabase/2.json @@ -0,0 +1,642 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "f67cc62a336f16687114e5475a7ba7f4", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `defaultUnknownSenderPolicy` TEXT NOT NULL, `retentionDuration` TEXT, `afterSendAction` TEXT NOT NULL, `billingPlan` TEXT, `onboardingCompleted` INTEGER NOT NULL, `createdAt` INTEGER, `updatedAt` INTEGER, PRIMARY KEY(`accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultUnknownSenderPolicy", + "columnName": "defaultUnknownSenderPolicy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "retentionDuration", + "columnName": "retentionDuration", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "afterSendAction", + "columnName": "afterSendAction", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "billingPlan", + "columnName": "billingPlan", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "onboardingCompleted", + "columnName": "onboardingCompleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "aliases", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`alias` TEXT NOT NULL, `accountId` TEXT NOT NULL, `unknownSenderPolicy` TEXT NOT NULL, `createdAt` INTEGER, `updatedAt` INTEGER, PRIMARY KEY(`alias`))", + "fields": [ + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unknownSenderPolicy", + "columnName": "unknownSenderPolicy", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "alias" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "threads", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`threadId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `subject` TEXT NOT NULL, `summary` TEXT NOT NULL, `senderAddress` TEXT NOT NULL, `senderName` TEXT, `recipientAddress` TEXT NOT NULL, `workflow` TEXT NOT NULL, `status` TEXT NOT NULL, `urgency` TEXT NOT NULL, `labels` TEXT NOT NULL, `lastSignalAt` INTEGER, `followupAt` INTEGER, `createdAt` INTEGER, `updatedAt` INTEGER, `isPendingSync` INTEGER NOT NULL, PRIMARY KEY(`threadId`))", + "fields": [ + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderAddress", + "columnName": "senderAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderName", + "columnName": "senderName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientAddress", + "columnName": "recipientAddress", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workflow", + "columnName": "workflow", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "urgency", + "columnName": "urgency", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "labels", + "columnName": "labels", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastSignalAt", + "columnName": "lastSignalAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "followupAt", + "columnName": "followupAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isPendingSync", + "columnName": "isPendingSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "threadId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "signals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`signalId` TEXT NOT NULL, `threadId` TEXT, `accountId` TEXT NOT NULL, `kind` TEXT NOT NULL, `status` TEXT NOT NULL, `createdAt` INTEGER, `fromAddress` TEXT, `fromName` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL, `replyToAddress` TEXT, `subject` TEXT NOT NULL, `body` TEXT, `summary` TEXT, `urgency` TEXT, `workflow` TEXT, `recipientAddress` TEXT, `receivedAt` INTEGER, `sentAt` INTEGER, `sendInitiatedAt` INTEGER, `sendFailureReason` TEXT, `unsubscribeType` TEXT, `unsubscribeUrl` TEXT, `attachmentsJson` TEXT, `noticeType` TEXT, `noticeDetail` TEXT, `isPendingSync` INTEGER NOT NULL, PRIMARY KEY(`signalId`))", + "fields": [ + { + "fieldPath": "signalId", + "columnName": "signalId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "threadId", + "columnName": "threadId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "kind", + "columnName": "kind", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fromAddress", + "columnName": "fromAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "fromName", + "columnName": "fromName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "toAddresses", + "columnName": "toAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccAddresses", + "columnName": "ccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bccAddresses", + "columnName": "bccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyToAddress", + "columnName": "replyToAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "summary", + "columnName": "summary", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urgency", + "columnName": "urgency", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "workflow", + "columnName": "workflow", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientAddress", + "columnName": "recipientAddress", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "receivedAt", + "columnName": "receivedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sentAt", + "columnName": "sentAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sendInitiatedAt", + "columnName": "sendInitiatedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "sendFailureReason", + "columnName": "sendFailureReason", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unsubscribeType", + "columnName": "unsubscribeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "unsubscribeUrl", + "columnName": "unsubscribeUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "attachmentsJson", + "columnName": "attachmentsJson", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "noticeType", + "columnName": "noticeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "noticeDetail", + "columnName": "noticeDetail", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isPendingSync", + "columnName": "isPendingSync", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "signalId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "labels", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`label` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `color` TEXT, `icon` TEXT, `createdAt` INTEGER, PRIMARY KEY(`label`))", + "fields": [ + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "label" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "rules", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`ruleId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `condition` TEXT, `conditionType` TEXT, `actions` TEXT NOT NULL, `isEnabled` INTEGER NOT NULL, `priorityOrder` REAL NOT NULL, `isImmutable` INTEGER NOT NULL, PRIMARY KEY(`ruleId`))", + "fields": [ + { + "fieldPath": "ruleId", + "columnName": "ruleId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "condition", + "columnName": "condition", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "conditionType", + "columnName": "conditionType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "actions", + "columnName": "actions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isEnabled", + "columnName": "isEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priorityOrder", + "columnName": "priorityOrder", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "isImmutable", + "columnName": "isImmutable", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "ruleId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "templates", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`templateId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, PRIMARY KEY(`templateId`))", + "fields": [ + { + "fieldPath": "templateId", + "columnName": "templateId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "templateId" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "views", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`viewId` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `icon` TEXT, `color` TEXT, `workflow` TEXT, `labels` TEXT NOT NULL, `position` REAL NOT NULL, PRIMARY KEY(`viewId`))", + "fields": [ + { + "fieldPath": "viewId", + "columnName": "viewId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "workflow", + "columnName": "workflow", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "labels", + "columnName": "labels", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "viewId" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f67cc62a336f16687114e5475a7ba7f4')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/ch/rhosys/email/data/local/entity/AccountEntity.kt b/app/src/main/java/ch/rhosys/email/data/local/entity/AccountEntity.kt index 05f8504..a695372 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/entity/AccountEntity.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/entity/AccountEntity.kt @@ -5,7 +5,7 @@ import androidx.room.PrimaryKey import ch.rhosys.email.domain.model.Account import ch.rhosys.email.domain.model.AfterSendAction import ch.rhosys.email.domain.model.Alias -import ch.rhosys.email.domain.model.SenderPolicy +import ch.rhosys.email.domain.model.UnknownSenderPolicy import java.time.Instant @Entity(tableName = "accounts") @@ -33,7 +33,7 @@ data class AliasEntity( fun AccountEntity.toDomain() = Account( accountId = accountId, name = name, - defaultUnknownSenderPolicy = SenderPolicy.fromWire(defaultUnknownSenderPolicy), + defaultUnknownSenderPolicy = UnknownSenderPolicy.fromWire(defaultUnknownSenderPolicy), retentionDuration = retentionDuration, afterSendAction = AfterSendAction.fromWire(afterSendAction), billingPlan = billingPlan, @@ -57,7 +57,7 @@ fun Account.toEntity() = AccountEntity( fun AliasEntity.toDomain() = Alias( alias = alias, accountId = accountId, - unknownSenderPolicy = SenderPolicy.fromWire(unknownSenderPolicy), + unknownSenderPolicy = UnknownSenderPolicy.fromWire(unknownSenderPolicy), createdAt = createdAt?.let(Instant::ofEpochMilli), updatedAt = updatedAt?.let(Instant::ofEpochMilli), ) diff --git a/app/src/main/java/ch/rhosys/email/data/local/entity/SignalEntity.kt b/app/src/main/java/ch/rhosys/email/data/local/entity/SignalEntity.kt index 0ea890d..67e2c1e 100644 --- a/app/src/main/java/ch/rhosys/email/data/local/entity/SignalEntity.kt +++ b/app/src/main/java/ch/rhosys/email/data/local/entity/SignalEntity.kt @@ -11,6 +11,8 @@ import ch.rhosys.email.domain.model.SignalStatus import ch.rhosys.email.domain.model.UnsubscribeInfo import ch.rhosys.email.domain.model.Urgency import ch.rhosys.email.domain.model.Workflow +import org.json.JSONArray +import org.json.JSONObject import java.time.Instant /** @@ -64,9 +66,44 @@ data class SignalEntity( private fun addr(address: String?, name: String?): EmailAddress? = address?.let { EmailAddress(it, name) } +/** + * Attachments live at a fixed URL carried on the signal itself — there is no + * download endpoint — so the array is cached verbatim alongside the signal. + */ +internal fun encodeAttachments(attachments: List): String = + JSONArray().apply { + attachments.forEach { a -> + put( + JSONObject() + .put("filename", a.filename) + .put("mimeType", a.mimeType) + .put("sizeBytes", a.sizeBytes) + .put("url", a.url ?: JSONObject.NULL), + ) + } + }.toString() + +internal fun decodeAttachments(json: String?): List { + if (json.isNullOrBlank()) return emptyList() + return runCatching { + val array = JSONArray(json) + (0 until array.length()).map { i -> + val o = array.getJSONObject(i) + Attachment( + filename = o.optString("filename"), + mimeType = o.optString("mimeType"), + sizeBytes = o.optLong("sizeBytes"), + url = o.optString("url").takeIf { it.isNotBlank() && it != "null" }, + ) + } + }.getOrDefault(emptyList()) +} + private fun List.toAddresses(): List = map { EmailAddress(it) } -fun SignalEntity.toDomain(attachments: List): Signal = when (kind) { +fun SignalEntity.toDomain(): Signal { + val attachments = decodeAttachments(attachmentsJson) + return when (kind) { SignalEntity.Kind.OUTBOUND -> Signal.OutboundEmail( signalId = signalId, threadId = threadId, @@ -113,17 +150,19 @@ fun SignalEntity.toDomain(attachments: List): Signal = when (kind) { type = noticeType.orEmpty(), detail = noticeDetail, ) + } } -/** - * Flattens a domain signal for caching. [attachmentsJson] is supplied by the - * repository, which owns the Moshi instance used to encode it. - */ +/** Flattens a domain signal for caching, attachments included. */ fun Signal.toEntity( accountId: String, - attachmentsJson: String? = null, isPendingSync: Boolean = false, ): SignalEntity { + val encodedAttachments = when (this) { + is Signal.InboundEmail -> encodeAttachments(attachments) + is Signal.OutboundEmail -> encodeAttachments(attachments) + is Signal.SystemNotice -> null + } val base = SignalEntity( signalId = signalId, threadId = threadId, @@ -137,7 +176,7 @@ fun Signal.toEntity( subject = "", body = null, summary = null, urgency = null, workflow = null, recipientAddress = null, receivedAt = null, sentAt = null, sendInitiatedAt = null, sendFailureReason = null, unsubscribeType = null, unsubscribeUrl = null, - attachmentsJson = attachmentsJson, noticeType = null, noticeDetail = null, + attachmentsJson = encodedAttachments, noticeType = null, noticeDetail = null, isPendingSync = isPendingSync, ) return when (this) { diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/AccountDtos.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/AccountDtos.kt index 9b68442..32f8538 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/dto/AccountDtos.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/AccountDtos.kt @@ -71,8 +71,12 @@ data class PatchAliasRequest( /** Per-sender-domain override on an alias. Replaces the old "block sender" call. */ @JsonClass(generateAdapter = true) data class AliasSenderDto( - val domain: String, + val alias: String, + // The sender *domain*, despite the field name. + val sender: String, val policy: String, + val createdAt: String? = null, + val updatedAt: String? = null, ) @JsonClass(generateAdapter = true) @@ -85,7 +89,18 @@ data class SetAliasSenderRequest( val policy: String, ) -object UnknownSenderPolicy { +/** + * Per-domain sender policy. Narrower than the unknown-sender policy: no + * quarantine options, and "allow" rather than "allow_all". + */ +object SenderPolicyWire { + const val ALLOW = "allow" + const val BLOCK_HIDDEN = "block_hidden" + const val BLOCK_REJECT = "block_reject" + const val REPORT_VIOLATION = "report_violation" +} + +object UnknownSenderPolicyWire { const val ALLOW_ALL = "allow_all" const val QUARANTINE_VISIBLE = "quarantine_visible" const val QUARANTINE_HIDDEN = "quarantine_hidden" diff --git a/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt index 8b8172b..978f561 100644 --- a/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt @@ -10,7 +10,9 @@ import ch.rhosys.email.domain.model.MailThread import ch.rhosys.email.domain.model.Rule import ch.rhosys.email.domain.model.RuleAction import ch.rhosys.email.domain.model.RuleActionType +import ch.rhosys.email.domain.model.AliasSender import ch.rhosys.email.domain.model.SenderPolicy +import ch.rhosys.email.domain.model.UnknownSenderPolicy import ch.rhosys.email.domain.model.Signal import ch.rhosys.email.domain.model.SignalStatus import ch.rhosys.email.domain.model.Template @@ -115,7 +117,7 @@ internal fun SignalDto.toDomain(): Signal = when (this) { internal fun AccountDto.toDomain() = Account( accountId = accountId, name = name, - defaultUnknownSenderPolicy = SenderPolicy.fromWire(filtering.defaultUnknownSenderPolicy), + defaultUnknownSenderPolicy = UnknownSenderPolicy.fromWire(filtering.defaultUnknownSenderPolicy), retentionDuration = retentionDuration, afterSendAction = AfterSendAction.fromWire(afterSendAction), billingPlan = billingPlan, @@ -127,7 +129,7 @@ internal fun AccountDto.toDomain() = Account( internal fun AliasDto.toDomain(accountId: String) = Alias( alias = alias, accountId = accountId, - unknownSenderPolicy = SenderPolicy.fromWire(unknownSenderPolicy), + unknownSenderPolicy = UnknownSenderPolicy.fromWire(unknownSenderPolicy), createdAt = createdAt.toInstantOrNull(), updatedAt = updatedAt.toInstantOrNull(), ) @@ -168,3 +170,9 @@ internal fun ViewDto.toDomain(accountId: String) = View( labels = labels, position = position, ) + +internal fun AliasSenderDto.toDomain() = AliasSender( + alias = alias, + sender = sender, + policy = SenderPolicy.fromWire(policy), +) diff --git a/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt index 7d28656..4841543 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt @@ -5,11 +5,14 @@ import ch.rhosys.email.data.local.dao.AccountDao import ch.rhosys.email.data.local.entity.toDomain import ch.rhosys.email.data.local.entity.toEntity import ch.rhosys.email.data.remote.api.EmailApiService +import ch.rhosys.email.data.remote.dto.PatchAliasRequest import ch.rhosys.email.data.remote.dto.SetAliasSenderRequest import ch.rhosys.email.data.remote.dto.toDomain import ch.rhosys.email.domain.model.Account import ch.rhosys.email.domain.model.Alias +import ch.rhosys.email.domain.model.AliasSender import ch.rhosys.email.domain.model.SenderPolicy +import ch.rhosys.email.domain.model.UnknownSenderPolicy import ch.rhosys.email.domain.repository.AccountRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow @@ -54,6 +57,9 @@ class AccountRepositoryImpl( override fun activeAccountId(): Flow = activeAccount.asStateFlow() + override suspend fun getAliasSenders(accountId: String, alias: String): List = + api.getAliasSenders(accountId, alias).senders.map { it.toDomain() } + /** Blocking or approving a sender is a per-domain policy on an alias. */ override suspend fun setSenderPolicy( accountId: String, @@ -63,4 +69,13 @@ class AccountRepositoryImpl( ) { api.setAliasSenderPolicy(accountId, alias, domain, SetAliasSenderRequest(policy.wire)) } + + override suspend fun setAliasUnknownSenderPolicy( + accountId: String, + alias: String, + policy: UnknownSenderPolicy, + ) { + val updated = api.patchAlias(accountId, alias, PatchAliasRequest(policy.wire)) + dao.upsertAliases(listOf(updated.toDomain(accountId).toEntity())) + } } diff --git a/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt b/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt deleted file mode 100644 index 2c03eba..0000000 --- a/app/src/main/java/ch/rhosys/email/data/repository/AdminStatsRepositories.kt +++ /dev/null @@ -1,30 +0,0 @@ -package ch.rhosys.email.data.repository - -import ch.rhosys.email.data.remote.api.EmailApiService -import ch.rhosys.email.data.remote.dto.HealthCheckDto - -/** - * Stats are returned as a free-form object by the API — the OpenAPI document - * declares `/accounts/{accountId}/stats` with an untyped response — so the shape - * is surfaced as-is rather than invented into a typed summary. - */ -class StatsRepository(private val api: EmailApiService) { - suspend fun getStats(accountId: String): Map = api.getStats(accountId) -} - -/** - * The previous admin repository called `v1/admin` routes that never existed. - * The API offers a global health check plus per-signal reprocess and raw - * fetch — both of which are addressed by account, thread and signal. - */ -class AdminRepository(private val api: EmailApiService) { - - suspend fun getHealthCheck(): HealthCheckDto = api.getHealthCheck() - - suspend fun reprocessSignal(accountId: String, threadId: String, signalId: String) { - api.reprocessSignal(accountId, threadId, signalId) - } - - suspend fun getRawSignal(accountId: String, threadId: String, signalId: String): String = - api.getRawSignal(accountId, threadId, signalId).string() -} diff --git a/app/src/main/java/ch/rhosys/email/data/repository/ComposeRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/ComposeRepositoryImpl.kt index 1a19595..f6da663 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/ComposeRepositoryImpl.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/ComposeRepositoryImpl.kt @@ -28,11 +28,11 @@ class ComposeRepositoryImpl( override fun observeDrafts(accountId: String): Flow> = signalDao.observeDrafts(accountId).map { rows -> - rows.mapNotNull { it.toDomain(attachments = emptyList()) as? Signal.OutboundEmail } + rows.mapNotNull { it.toDomain() as? Signal.OutboundEmail } } override suspend fun getDraft(signalId: String): Signal.OutboundEmail? = - signalDao.getById(signalId)?.toDomain(attachments = emptyList()) as? Signal.OutboundEmail + signalDao.getById(signalId)?.toDomain() as? Signal.OutboundEmail override suspend fun createDraft( accountId: String, diff --git a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt index 8155b14..617355a 100644 --- a/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt +++ b/app/src/main/java/ch/rhosys/email/data/repository/ThreadRepositoryImpl.kt @@ -50,12 +50,12 @@ class ThreadRepositoryImpl( override fun observeSignals(threadId: String): Flow> = signalDao.observeByThread(threadId).map { rows -> - rows.map { it.toDomain(attachments = emptyList()) } + rows.map { it.toDomain() } } override fun observeQuarantined(accountId: String): Flow> = signalDao.observeQuarantined(accountId).map { rows -> - rows.map { it.toDomain(attachments = emptyList()) } + rows.map { it.toDomain() } } override fun search(accountId: String, query: String): Flow> = diff --git a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt index d232233..5b32fc7 100644 --- a/app/src/main/java/ch/rhosys/email/di/AppContainer.kt +++ b/app/src/main/java/ch/rhosys/email/di/AppContainer.kt @@ -73,7 +73,7 @@ class AppContainer(private val context: Context) { // The v1 schema described an API that does not exist, so there is nothing // worth migrating — the cache simply refetches against the real one. Room.databaseBuilder(context, EmailDatabase::class.java, EmailDatabase.NAME) - .fallbackToDestructiveMigration(dropAllTables = true) + .fallbackToDestructiveMigration() .build() } diff --git a/app/src/main/java/ch/rhosys/email/domain/model/Account.kt b/app/src/main/java/ch/rhosys/email/domain/model/Account.kt index bf00dff..5ec3933 100644 --- a/app/src/main/java/ch/rhosys/email/domain/model/Account.kt +++ b/app/src/main/java/ch/rhosys/email/domain/model/Account.kt @@ -10,7 +10,7 @@ import java.time.Instant data class Account( val accountId: String, val name: String, - val defaultUnknownSenderPolicy: SenderPolicy, + val defaultUnknownSenderPolicy: UnknownSenderPolicy, val retentionDuration: String?, val afterSendAction: AfterSendAction, /** Exposed by the API for display only — there are no billing endpoints. */ @@ -24,29 +24,48 @@ data class Account( data class Alias( val alias: String, val accountId: String, - val unknownSenderPolicy: SenderPolicy, + val unknownSenderPolicy: UnknownSenderPolicy, val createdAt: Instant?, val updatedAt: Instant?, ) /** - * Disposition applied to mail from senders that are not explicitly allowed. - * Setting this per sender-domain is how the app blocks a sender — there is no - * block-sender endpoint. + * Policy for one specific sender domain on an alias. This is a different, + * narrower enum than [UnknownSenderPolicy] — it has no quarantine options and + * spells "allow" without the _all suffix. */ -enum class SenderPolicy { - ALLOW_ALL, - QUARANTINE_VISIBLE, - QUARANTINE_HIDDEN, - BLOCK_HIDDEN, - BLOCK_REJECT, - REPORT_VIOLATION, +enum class SenderPolicy(val label: String) { + ALLOW("Allow"), + BLOCK_HIDDEN("Drop"), + BLOCK_REJECT("Block (reject)"), + REPORT_VIOLATION("Report violation"), ; val wire: String get() = name.lowercase() companion object { fun fromWire(value: String?): SenderPolicy = + entries.firstOrNull { it.wire == value } ?: ALLOW + } +} + +/** + * Default disposition for senders with no explicit per-domain policy. Set on an + * account, and overridable per alias. + */ +enum class UnknownSenderPolicy(val label: String) { + ALLOW_ALL("Allow all"), + QUARANTINE_VISIBLE("Quarantine (visible)"), + QUARANTINE_HIDDEN("Quarantine (hidden)"), + BLOCK_HIDDEN("Drop"), + BLOCK_REJECT("Block (reject)"), + REPORT_VIOLATION("Report violation"), + ; + + val wire: String get() = name.lowercase() + + companion object { + fun fromWire(value: String?): UnknownSenderPolicy = entries.firstOrNull { it.wire == value } ?: QUARANTINE_VISIBLE } } @@ -64,9 +83,13 @@ enum class AfterSendAction { } } -/** Per-sender-domain override on an alias. */ +/** + * Per-sender-domain override on an alias. The wire field for the domain is + * `sender`, not `domain`. + */ data class AliasSender( - val domain: String, + val alias: String, + val sender: String, val policy: SenderPolicy, ) diff --git a/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt b/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt index 5b1765a..3bb1d3e 100644 --- a/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt +++ b/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt @@ -6,7 +6,9 @@ import ch.rhosys.email.domain.model.Alias import ch.rhosys.email.domain.model.Label import ch.rhosys.email.domain.model.MailThread import ch.rhosys.email.domain.model.Rule +import ch.rhosys.email.domain.model.AliasSender import ch.rhosys.email.domain.model.SenderPolicy +import ch.rhosys.email.domain.model.UnknownSenderPolicy import ch.rhosys.email.domain.model.Signal import ch.rhosys.email.domain.model.Template import ch.rhosys.email.domain.model.ThreadStatus @@ -20,8 +22,16 @@ interface AccountRepository { suspend fun setActiveAccount(accountId: String) fun activeAccountId(): Flow - /** Blocking a sender is a per-domain policy on an alias, not a thread action. */ + /** + * Sender controls, mirroring the web app's sender popup: a policy for one + * sender domain on an alias, plus the alias-level default for senders with + * no explicit entry. There is no per-thread block endpoint. + */ + suspend fun getAliasSenders(accountId: String, alias: String): List + suspend fun setSenderPolicy(accountId: String, alias: String, domain: String, policy: SenderPolicy) + + suspend fun setAliasUnknownSenderPolicy(accountId: String, alias: String, policy: UnknownSenderPolicy) } interface ThreadRepository { diff --git a/app/src/main/java/ch/rhosys/email/presentation/compose/ComposeScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/compose/ComposeScreen.kt index 90a5aa3..21da084 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/compose/ComposeScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/compose/ComposeScreen.kt @@ -31,13 +31,16 @@ import ch.rhosys.email.di.LocalAppContainer import ch.rhosys.email.presentation.components.MarkdownText import ch.rhosys.email.presentation.components.rememberViewModel -/** Decision #12: full-screen compose with Markdown input and Edit/Preview toggle. */ +/** + * Full-screen compose. Cc and Bcc are absent because the draft-creation body the + * API accepts carries only from, to, subject and textBody. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ComposeScreen(threadId: String?, draftId: String?, onDone: () -> Unit) { val container = LocalAppContainer.current val viewModel = rememberViewModel { - ComposeViewModel(container.composeRepository, container.accountRepository, container.pendingSendManager, threadId, draftId) + ComposeViewModel(container.composeRepository, container.accountRepository, threadId, draftId) } val uiState by viewModel.uiState.collectAsState() val aliases by viewModel.aliases.collectAsState() @@ -56,7 +59,11 @@ fun ComposeScreen(threadId: String?, draftId: String?, onDone: () -> Unit) { } }, actions = { - IconButton(onClick = { viewModel.send() }) { + // Sending is immediate — the API has no undo window. + IconButton( + onClick = { viewModel.send() }, + enabled = uiState.canCompose && !uiState.isSending, + ) { Icon(Icons.Filled.Send, contentDescription = "Send") } }, @@ -71,14 +78,6 @@ fun ComposeScreen(threadId: String?, draftId: String?, onDone: () -> Unit) { value = uiState.toAddresses, onValueChange = viewModel::setTo, label = { Text("To") }, modifier = Modifier.fillMaxWidth(), ) - TextField( - value = uiState.ccAddresses, onValueChange = viewModel::setCc, - label = { Text("Cc") }, modifier = Modifier.fillMaxWidth(), - ) - TextField( - value = uiState.bccAddresses, onValueChange = viewModel::setBcc, - label = { Text("Bcc") }, modifier = Modifier.fillMaxWidth(), - ) TextField( value = uiState.subject, onValueChange = viewModel::setSubject, label = { Text("Subject") }, modifier = Modifier.fillMaxWidth(), @@ -98,10 +97,10 @@ fun ComposeScreen(threadId: String?, draftId: String?, onDone: () -> Unit) { } if (uiState.isPreview) { - MarkdownText(uiState.bodyMarkdown, modifier = Modifier.fillMaxSize()) + MarkdownText(uiState.body, modifier = Modifier.fillMaxSize()) } else { TextField( - value = uiState.bodyMarkdown, onValueChange = viewModel::setBody, + value = uiState.body, onValueChange = viewModel::setBody, label = { Text("Message (Markdown)") }, modifier = Modifier.fillMaxSize().weight(1f), ) @@ -113,8 +112,8 @@ fun ComposeScreen(threadId: String?, draftId: String?, onDone: () -> Unit) { ModalBottomSheet(onDismissRequest = { viewModel.dismissAliasPicker() }) { LazyColumn { items(aliases) { alias -> - TextButton(onClick = { viewModel.setFromAlias(alias.emailAddress) }) { - Text(alias.emailAddress) + TextButton(onClick = { viewModel.setFromAlias(alias.alias) }) { + Text(alias.alias) } } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/drafts/DraftsScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/drafts/DraftsScreen.kt index bbd483e..50bfc73 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/drafts/DraftsScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/drafts/DraftsScreen.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import ch.rhosys.email.di.LocalAppContainer -import ch.rhosys.email.domain.model.Draft +import ch.rhosys.email.domain.model.Signal import ch.rhosys.email.domain.repository.AccountRepository import ch.rhosys.email.domain.repository.ComposeRepository import ch.rhosys.email.presentation.components.EmptyState @@ -28,18 +28,21 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn -/** Decision #28: dedicated Drafts screen (drafts also appear inline in their thread). */ +/** + * Drafts are outbound signals with status DRAFT, so each one already belongs to + * a thread — opening one needs both ids. + */ class DraftsViewModel(private val composeRepository: ComposeRepository, accountRepository: AccountRepository) : ViewModel() { private val activeAccountId = accountRepository.activeAccountId() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) - val drafts: StateFlow> = activeAccountId.filterNotNull().flatMapLatest { accountId -> + val drafts: StateFlow> = activeAccountId.filterNotNull().flatMapLatest { accountId -> composeRepository.observeDrafts(accountId) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) } @Composable -fun DraftsScreen(onDraftClick: (String) -> Unit) { +fun DraftsScreen(onDraftClick: (threadId: String, signalId: String) -> Unit) { val container = LocalAppContainer.current val viewModel = rememberViewModel { DraftsViewModel(container.composeRepository, container.accountRepository) } val drafts by viewModel.drafts.collectAsState() @@ -50,15 +53,23 @@ fun DraftsScreen(onDraftClick: (String) -> Unit) { } LazyColumn(modifier = Modifier.fillMaxSize()) { - items(drafts, key = { it.id }) { draft -> + items(drafts, key = { it.signalId }) { draft -> Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) { Column( modifier = Modifier.fillMaxWidth().padding(12.dp), ) { - androidx.compose.material3.TextButton(onClick = { onDraftClick(draft.id) }) { + val threadId = draft.threadId + androidx.compose.material3.TextButton( + enabled = threadId != null, + onClick = { threadId?.let { onDraftClick(it, draft.signalId) } }, + ) { Text(draft.subject.ifBlank { "(no subject)" }, maxLines = 1) } - Text(draft.bodyMarkdown.take(80), style = MaterialTheme.typography.bodyMedium, maxLines = 1) + Text( + draft.body.orEmpty().take(80), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) } } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsScreen.kt index 651f869..3454d12 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsScreen.kt @@ -52,15 +52,15 @@ fun LabelsScreen() { EmptyState(title = "No labels yet", message = "Create a label to organize mail.", celebration = false, modifier = Modifier.padding(padding)) } else { LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { - items(labels, key = { it.id }) { label -> + items(labels, key = { it.label }) { label -> Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) { Row( modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text("${label.emoji ?: "🏷️"} ${label.name}", style = MaterialTheme.typography.bodyLarge) - IconButton(onClick = { viewModel.delete(label.id) }) { + Text("${label.icon ?: "🏷️"} ${label.name}", style = MaterialTheme.typography.bodyLarge) + IconButton(onClick = { viewModel.delete(label.label) }) { Icon(Icons.Filled.Delete, contentDescription = "Delete ${label.name}") } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsViewModel.kt index abb8ec9..53d6b04 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/labels/LabelsViewModel.kt @@ -27,11 +27,18 @@ class LabelsViewModel( labelRepository.observeLabels(accountId) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - fun create(name: String, color: String, emoji: String?) { + fun create(name: String, color: String?, icon: String?) { val accountId = activeAccountId.value ?: return - viewModelScope.launch { labelRepository.create(accountId, name, color, emoji) } + viewModelScope.launch { labelRepository.create(accountId, name, color, icon) } } - fun update(label: Label) = viewModelScope.launch { labelRepository.update(label) } - fun delete(labelId: String) = viewModelScope.launch { labelRepository.delete(labelId) } + fun update(label: Label) { + val accountId = activeAccountId.value ?: return + viewModelScope.launch { labelRepository.update(accountId, label) } + } + + fun delete(labelId: String) { + val accountId = activeAccountId.value ?: return + viewModelScope.launch { labelRepository.delete(accountId, labelId) } + } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/AppScaffold.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/AppScaffold.kt index 1192648..2b739dd 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/navigation/AppScaffold.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/AppScaffold.kt @@ -43,7 +43,6 @@ import kotlinx.coroutines.launch private fun iconFor(destination: Destination): ImageVector = when (destination) { Destination.Inbox -> Icons.Filled.Inbox Destination.Quarantine -> Icons.Filled.Shield - Destination.Spam -> Icons.Filled.Report Destination.Drafts -> Icons.Filled.Description Destination.Rules -> Icons.Filled.Rule Destination.Templates -> Icons.Filled.AutoAwesome @@ -130,11 +129,12 @@ private fun AccountSwitcher() { Column(modifier = Modifier.fillMaxWidth().padding(16.dp)) { Text("Accounts", style = MaterialTheme.typography.titleMedium) LazyColumn { - items(accounts, key = { it.id }) { account -> + items(accounts, key = { it.accountId }) { account -> NavigationDrawerItem( - label = { Text(account.emailAddress) }, - selected = account.id == activeId, - onClick = { viewModel.select(account.id) }, + // An account has a name, not an address — addresses are aliases. + label = { Text(account.name) }, + selected = account.accountId == activeId, + onClick = { viewModel.select(account.accountId) }, ) } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt index f2453e6..af8f15a 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/navigation/NavGraph.kt @@ -14,9 +14,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import ch.rhosys.email.di.LocalAppContainer -import ch.rhosys.email.presentation.admin.AdminScreen import ch.rhosys.email.presentation.auth.LoginScreen -import ch.rhosys.email.presentation.billing.BillingScreen import ch.rhosys.email.presentation.changelog.ChangelogDialog import ch.rhosys.email.presentation.compose.ComposeScreen import ch.rhosys.email.presentation.drafts.DraftsScreen @@ -27,9 +25,7 @@ import ch.rhosys.email.presentation.onboarding.OnboardingScreen import ch.rhosys.email.presentation.quarantine.QuarantineScreen import ch.rhosys.email.presentation.rules.RulesScreen import ch.rhosys.email.presentation.settings.SettingsScreen -import ch.rhosys.email.presentation.spam.SpamScreen import ch.rhosys.email.presentation.stats.StatsScreen -import ch.rhosys.email.presentation.support.SupportScreen import ch.rhosys.email.presentation.templates.TemplatesScreen import ch.rhosys.email.presentation.thread.ThreadScreen import kotlinx.coroutines.flow.first @@ -67,6 +63,7 @@ fun RootNavGraph() { @Composable private fun AppNavHost() { val navController = rememberNavController() + val container = LocalAppContainer.current AppScaffold(navController) { modifier -> NavHost(navController = navController, startDestination = Destination.Inbox.route, modifier = modifier) { @@ -76,25 +73,23 @@ private fun AppNavHost() { composable(Destination.Quarantine.route) { QuarantineScreen(onThreadClick = { navController.navigate(Destination.Thread.route(it)) }) } - composable(Destination.Spam.route) { - SpamScreen(onThreadClick = { navController.navigate(Destination.Thread.route(it)) }) - } composable(Destination.Drafts.route) { - DraftsScreen(onDraftClick = { navController.navigate(Destination.Compose.route(draftId = it)) }) + DraftsScreen( + onDraftClick = { threadId, signalId -> + navController.navigate(Destination.Compose.route(threadId = threadId, draftId = signalId)) + }, + ) } composable(Destination.Labels.route) { LabelsScreen() } composable(Destination.Rules.route) { RulesScreen() } - composable(Destination.Templates.route) { - TemplatesScreen(onUseTemplate = { navController.navigate(Destination.Compose.route()) }) - } + // Templates are applied from within a reply; there is no standalone + // compose target to send them to. + composable(Destination.Templates.route) { TemplatesScreen(onUseTemplate = {}) } composable( Destination.Settings.route, ) { SettingsScreen( onNavigateStats = { navController.navigate(Destination.Stats.route) }, - onNavigateBilling = { navController.navigate(Destination.Billing.route) }, - onNavigateSupport = { navController.navigate(Destination.Support.route) }, - onNavigateAdmin = { navController.navigate(Destination.Admin.route) }, onSignedOut = { navController.navigate(Destination.Inbox.route) { popUpTo(0) @@ -102,16 +97,18 @@ private fun AppNavHost() { }, ) } - composable(Destination.Admin.route) { AdminScreen() } composable(Destination.Stats.route) { StatsScreen() } - composable(Destination.Billing.route) { BillingScreen() } - composable(Destination.Support.route) { SupportScreen() } composable( Destination.Thread.route, arguments = listOf(navArgument("threadId") { type = NavType.StringType }), ) { backStackEntry -> val threadId = backStackEntry.arguments?.getString("threadId") ?: return@composable + // Thread and signal routes are account-scoped. + val accountId by container.accountRepository.activeAccountId() + .collectAsState(initial = null) + val currentAccountId = accountId ?: return@composable ThreadScreen( + accountId = currentAccountId, threadId = threadId, onBack = { navController.popBackStack() }, onReply = { navController.navigate(Destination.Compose.route(threadId = threadId)) }, diff --git a/app/src/main/java/ch/rhosys/email/presentation/rules/RulesScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/rules/RulesScreen.kt index d28390b..b6b74a9 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/rules/RulesScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/rules/RulesScreen.kt @@ -42,7 +42,10 @@ class RulesViewModel(private val ruleRepository: RuleRepository, accountReposito ruleRepository.observeRules(accountId) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) - fun setEnabled(ruleId: String, enabled: Boolean) = viewModelScope.launch { ruleRepository.setEnabled(ruleId, enabled) } + fun setEnabled(ruleId: String, enabled: Boolean) { + val accountId = activeAccountId.value ?: return + viewModelScope.launch { ruleRepository.setEnabled(accountId, ruleId, enabled) } + } } @Composable @@ -57,7 +60,7 @@ fun RulesScreen() { } LazyColumn(modifier = Modifier.fillMaxSize()) { - items(rules, key = { it.id }) { rule -> + items(rules, key = { it.ruleId }) { rule -> Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp)) { Row( modifier = Modifier.fillMaxWidth().padding(12.dp), @@ -65,9 +68,21 @@ fun RulesScreen() { ) { Column(modifier = Modifier.weight(1f)) { Text(rule.name, style = MaterialTheme.typography.titleMedium) - Text(rule.description, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + // A rule has no description; summarise its actions instead. + Text( + rule.actions.joinToString(", ") { a -> + a.type.name.lowercase().replace('_', ' ') + }.ifBlank { "No actions" }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - Switch(checked = rule.isEnabled, onCheckedChange = { viewModel.setEnabled(rule.id, it) }) + Switch( + checked = rule.isEnabled, + // Backend-managed rules cannot be toggled from the client. + enabled = !rule.isImmutable, + onCheckedChange = { viewModel.setEnabled(rule.ruleId, it) }, + ) } } } diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt index 7b36ba1..7ec87ee 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsScreen.kt @@ -37,9 +37,6 @@ import ch.rhosys.email.ui.theme.CatppuccinFlavor @Composable fun SettingsScreen( onNavigateStats: () -> Unit, - onNavigateBilling: () -> Unit, - onNavigateSupport: () -> Unit, - onNavigateAdmin: () -> Unit, onSignedOut: () -> Unit, ) { val container = LocalAppContainer.current @@ -49,12 +46,13 @@ fun SettingsScreen( val uiState by viewModel.uiState.collectAsState() var tabIndex by remember { mutableStateOf(0) } var showSignOutConfirm by remember { mutableStateOf(false) } - val tabs = listOf("Aliases", "Email & Forwarding", "Profile & Security", "Team") + // No Security tab: the API has no MFA endpoints. No billing either. + val tabs = listOf("Aliases", "Email & Forwarding", "Users") LaunchedEffect(tabIndex) { when (tabIndex) { - 1 -> viewModel.loadForwardingAndDns() - 2, 3 -> viewModel.loadSecurityAndTeam() + 1 -> viewModel.loadForwardingAndDomains() + 2 -> viewModel.loadAccountUsers() } } @@ -63,11 +61,7 @@ fun SettingsScreen( uiState = uiState, onThemeSelected = viewModel::setThemeFlavor, onBiometricToggle = viewModel::setBiometricLockEnabled, - onAdminToggle = viewModel::setAdminPanelEnabled, onNavigateStats = onNavigateStats, - onNavigateBilling = onNavigateBilling, - onNavigateSupport = onNavigateSupport, - onNavigateAdmin = onNavigateAdmin, onSignOutClick = { showSignOutConfirm = true }, ) HorizontalDivider() @@ -78,9 +72,13 @@ fun SettingsScreen( } when (tabIndex) { 0 -> AliasesTab(uiState) - 1 -> ForwardingTab(uiState, onVerifyDns = viewModel::verifyDns, onAddForwarding = viewModel::addForwardingAddress, onRemoveForwarding = viewModel::removeForwardingAddress) - 2 -> SecurityTab(uiState, onRemoveMfa = viewModel::removeMfaDevice) - 3 -> TeamTab(uiState) + 1 -> ForwardingTab( + uiState = uiState, + onAddForwarding = viewModel::addForwardingTarget, + onRemoveForwarding = viewModel::removeForwardingTarget, + onVerifyForwarding = viewModel::verifyForwardingTarget, + ) + 2 -> UsersTab(uiState) } } @@ -101,11 +99,7 @@ private fun AppPreferencesSection( uiState: SettingsUiState, onThemeSelected: (CatppuccinFlavor?) -> Unit, onBiometricToggle: (Boolean) -> Unit, - onAdminToggle: (Boolean) -> Unit, onNavigateStats: () -> Unit, - onNavigateBilling: () -> Unit, - onNavigateSupport: () -> Unit, - onNavigateAdmin: () -> Unit, onSignOutClick: () -> Unit, ) { Column(modifier = Modifier.padding(12.dp)) { @@ -124,17 +118,7 @@ private fun AppPreferencesSection( supportingContent = { Text("Require Face/Fingerprint unlock to open the app") }, trailingContent = { Switch(checked = uiState.biometricLockEnabled, onCheckedChange = onBiometricToggle) }, ) - ListItem( - headlineContent = { Text("Admin panel") }, - supportingContent = { Text("Show Signal Inspector, health check, and reprocess tools") }, - trailingContent = { Switch(checked = uiState.adminPanelEnabled, onCheckedChange = onAdminToggle) }, - ) - if (uiState.adminPanelEnabled) { - ListItem(headlineContent = { Text("Open admin panel") }, modifier = Modifier.clickableSettings(onNavigateAdmin)) - } ListItem(headlineContent = { Text("Stats") }, modifier = Modifier.clickableSettings(onNavigateStats)) - ListItem(headlineContent = { Text("Billing") }, modifier = Modifier.clickableSettings(onNavigateBilling)) - ListItem(headlineContent = { Text("Support") }, modifier = Modifier.clickableSettings(onNavigateSupport)) ListItem( headlineContent = { Text("Sign out", color = MaterialTheme.colorScheme.error) }, modifier = Modifier.clickableSettings(onSignOutClick), @@ -148,10 +132,10 @@ private fun Modifier.clickableSettings(onClick: () -> Unit): Modifier = @Composable private fun AliasesTab(uiState: SettingsUiState) { LazyColumn(modifier = Modifier.fillMaxSize()) { - items(uiState.aliases, key = { it.id }) { alias -> + items(uiState.aliases, key = { it.alias }) { alias -> ListItem( - headlineContent = { Text(alias.emailAddress) }, - supportingContent = { Text(if (alias.isDefault) "Default alias" else "Alias") }, + headlineContent = { Text(alias.alias) }, + supportingContent = { Text("Unknown senders: ${alias.unknownSenderPolicy.label}") }, ) } } @@ -160,9 +144,9 @@ private fun AliasesTab(uiState: SettingsUiState) { @Composable private fun ForwardingTab( uiState: SettingsUiState, - onVerifyDns: () -> Unit, onAddForwarding: (String) -> Unit, onRemoveForwarding: (String) -> Unit, + onVerifyForwarding: (String) -> Unit, ) { var newAddress by remember { mutableStateOf("") } LazyColumn(modifier = Modifier.fillMaxSize().padding(12.dp)) { @@ -171,16 +155,22 @@ private fun ForwardingTab( ListItem( headlineContent = { Text("${record.type} — ${record.name}") }, supportingContent = { Text(record.value, maxLines = 1) }, - trailingContent = { Text(if (record.isVerified) "Verified" else "Pending") }, + trailingContent = { Text(record.status.replaceFirstChar { it.uppercase() }) }, ) } - item { TextButton(onClick = onVerifyDns) { Text("Re-check verification") } } item { Text("Forwarding addresses", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 16.dp)) } - items(uiState.forwardingAddresses, key = { it.id }) { address -> + items(uiState.forwardingTargets, key = { it.target }) { target -> ListItem( - headlineContent = { Text(address.emailAddress) }, - supportingContent = { Text(if (address.isVerified) "Verified" else "Pending verification") }, - trailingContent = { TextButton(onClick = { onRemoveForwarding(address.id) }) { Text("Remove") } }, + headlineContent = { Text(target.target) }, + supportingContent = { Text(target.status.replaceFirstChar { it.uppercase() }) }, + trailingContent = { + Row { + if (target.status != "verified") { + TextButton(onClick = { onVerifyForwarding(target.target) }) { Text("Verify") } + } + TextButton(onClick = { onRemoveForwarding(target.target) }) { Text("Remove") } + } + }, ) } item { @@ -193,24 +183,13 @@ private fun ForwardingTab( } @Composable -private fun SecurityTab(uiState: SettingsUiState, onRemoveMfa: (String) -> Unit) { +private fun UsersTab(uiState: SettingsUiState) { LazyColumn(modifier = Modifier.fillMaxSize()) { - item { Text("MFA devices", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(12.dp)) } - items(uiState.mfaDevices, key = { it.id }) { device -> + items(uiState.accountUsers, key = { it.userId }) { user -> ListItem( - headlineContent = { Text(device.label) }, - supportingContent = { Text(device.type) }, - trailingContent = { TextButton(onClick = { onRemoveMfa(device.id) }) { Text("Remove") } }, + headlineContent = { Text(user.email ?: user.name ?: user.userId) }, + supportingContent = { Text(user.role.orEmpty()) }, ) } } } - -@Composable -private fun TeamTab(uiState: SettingsUiState) { - LazyColumn(modifier = Modifier.fillMaxSize()) { - items(uiState.teamMembers, key = { it.id }) { member -> - ListItem(headlineContent = { Text(member.emailAddress) }, supportingContent = { Text(member.role) }) - } - } -} diff --git a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt index 0c95d6c..d33a1a5 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/settings/SettingsViewModel.kt @@ -5,12 +5,11 @@ import androidx.lifecycle.viewModelScope import ch.rhosys.email.data.auth.AuthressAuthManager import ch.rhosys.email.data.local.PreferencesStore import ch.rhosys.email.data.repository.SettingsRepository +import ch.rhosys.email.data.remote.dto.AccountUserDto +import ch.rhosys.email.data.remote.dto.DnsRecordDto +import ch.rhosys.email.data.remote.dto.DomainDto +import ch.rhosys.email.data.remote.dto.ForwardingTargetDto import ch.rhosys.email.domain.model.Alias -import ch.rhosys.email.domain.model.DnsRecord -import ch.rhosys.email.domain.model.ForwardingAddress -import ch.rhosys.email.domain.model.MfaDevice -import ch.rhosys.email.domain.model.PlanInfo -import ch.rhosys.email.domain.model.TeamMember import ch.rhosys.email.domain.repository.AccountRepository import ch.rhosys.email.ui.theme.CatppuccinFlavor import kotlinx.coroutines.flow.MutableStateFlow @@ -21,16 +20,18 @@ import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +/** + * MFA devices and plan/billing are absent: the API exposes no endpoints for + * either. DNS records hang off an individual domain rather than the account. + */ data class SettingsUiState( val aliases: List = emptyList(), - val dnsRecords: List = emptyList(), - val forwardingAddresses: List = emptyList(), - val mfaDevices: List = emptyList(), - val teamMembers: List = emptyList(), - val planInfo: PlanInfo? = null, + val domains: List = emptyList(), + val dnsRecords: List = emptyList(), + val forwardingTargets: List = emptyList(), + val accountUsers: List = emptyList(), val themeFlavor: CatppuccinFlavor? = null, val biometricLockEnabled: Boolean = false, - val adminPanelEnabled: Boolean = false, ) class SettingsViewModel( @@ -60,63 +61,67 @@ class SettingsViewModel( viewModelScope.launch { preferencesStore.biometricLockEnabled.collect { enabled -> _uiState.value = _uiState.value.copy(biometricLockEnabled = enabled) } } - viewModelScope.launch { - preferencesStore.adminPanelEnabled.collect { enabled -> _uiState.value = _uiState.value.copy(adminPanelEnabled = enabled) } - } } - fun loadForwardingAndDns() { + fun loadForwardingAndDomains() { val accountId = activeAccountId.value ?: return viewModelScope.launch { - runCatching { settingsRepository.getDnsRecords(accountId) }.onSuccess { - _uiState.value = _uiState.value.copy(dnsRecords = it) + runCatching { settingsRepository.getDomains(accountId) }.onSuccess { domains -> + _uiState.value = _uiState.value.copy(domains = domains) + // Records live on a domain, so pull them for the first one. + domains.firstOrNull()?.let { domain -> + runCatching { settingsRepository.getDomainRecords(accountId, domain.domainId) } + .onSuccess { _uiState.value = _uiState.value.copy(dnsRecords = it) } + } } - runCatching { settingsRepository.getForwardingAddresses(accountId) }.onSuccess { - _uiState.value = _uiState.value.copy(forwardingAddresses = it) + runCatching { settingsRepository.getForwardingTargets(accountId) }.onSuccess { + _uiState.value = _uiState.value.copy(forwardingTargets = it) } } } - fun verifyDns() { + fun addForwardingTarget(email: String) { val accountId = activeAccountId.value ?: return viewModelScope.launch { - runCatching { settingsRepository.verifyDnsRecords(accountId) }.onSuccess { - _uiState.value = _uiState.value.copy(dnsRecords = it) + runCatching { settingsRepository.addForwardingTarget(accountId, email) }.onSuccess { + _uiState.value = _uiState.value.copy(forwardingTargets = _uiState.value.forwardingTargets + it) } } } - fun addForwardingAddress(email: String) { + fun removeForwardingTarget(address: String) { val accountId = activeAccountId.value ?: return viewModelScope.launch { - runCatching { settingsRepository.addForwardingAddress(accountId, email) }.onSuccess { - _uiState.value = _uiState.value.copy(forwardingAddresses = _uiState.value.forwardingAddresses + it) - } + runCatching { settingsRepository.removeForwardingTarget(accountId, address) } + _uiState.value = _uiState.value.copy( + forwardingTargets = _uiState.value.forwardingTargets.filterNot { it.target == address }, + ) } } - fun removeForwardingAddress(id: String) = viewModelScope.launch { - runCatching { settingsRepository.removeForwardingAddress(id) } - _uiState.value = _uiState.value.copy(forwardingAddresses = _uiState.value.forwardingAddresses.filterNot { it.id == id }) - } - - fun loadSecurityAndTeam() { + fun verifyForwardingTarget(address: String) { val accountId = activeAccountId.value ?: return viewModelScope.launch { - runCatching { settingsRepository.getMfaDevices() }.onSuccess { _uiState.value = _uiState.value.copy(mfaDevices = it) } - runCatching { settingsRepository.getTeamMembers(accountId) }.onSuccess { _uiState.value = _uiState.value.copy(teamMembers = it) } - runCatching { settingsRepository.getPlanInfo(accountId) }.onSuccess { _uiState.value = _uiState.value.copy(planInfo = it) } + runCatching { settingsRepository.verifyForwardingTarget(accountId, address) }.onSuccess { updated -> + _uiState.value = _uiState.value.copy( + forwardingTargets = _uiState.value.forwardingTargets.map { + if (it.target == updated.target) updated else it + }, + ) + } } } - fun removeMfaDevice(id: String) = viewModelScope.launch { - runCatching { settingsRepository.removeMfaDevice(id) } - _uiState.value = _uiState.value.copy(mfaDevices = _uiState.value.mfaDevices.filterNot { it.id == id }) + fun loadAccountUsers() { + val accountId = activeAccountId.value ?: return + viewModelScope.launch { + runCatching { settingsRepository.getAccountUsers(accountId) } + .onSuccess { _uiState.value = _uiState.value.copy(accountUsers = it) } + } } fun setThemeFlavor(flavor: CatppuccinFlavor?) = viewModelScope.launch { preferencesStore.setThemeFlavor(flavor) } fun setBiometricLockEnabled(enabled: Boolean) = viewModelScope.launch { preferencesStore.setBiometricLockEnabled(enabled) } - fun setAdminPanelEnabled(enabled: Boolean) = viewModelScope.launch { preferencesStore.setAdminPanelEnabled(enabled) } fun signOut() { authManager.signOut() diff --git a/app/src/main/java/ch/rhosys/email/presentation/stats/StatsScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/stats/StatsScreen.kt index 265a9b5..c3b4eb8 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/stats/StatsScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/stats/StatsScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -21,50 +20,98 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import ch.rhosys.email.data.repository.StatsSummary import ch.rhosys.email.di.LocalAppContainer +import ch.rhosys.email.presentation.components.EmptyState import kotlinx.coroutines.flow.first -/** Decision #41: full stats dashboard with simple bar charts (no external chart library). */ +/** + * The stats endpoint is declared with an untyped response in the OpenAPI + * document, so this renders whatever shape comes back rather than assuming a + * schema: numeric leaves become bars, nested objects become sections. + */ @Composable fun StatsScreen() { val container = LocalAppContainer.current - var summary by remember { mutableStateOf(null) } + var stats by remember { mutableStateOf?>(null) } + var loaded by remember { mutableStateOf(false) } LaunchedEffect(Unit) { - val accountId = container.accountRepository.activeAccountId().first { it != null } ?: return@LaunchedEffect - summary = runCatching { container.statsRepository.getStats(accountId) }.getOrNull() + val accountId = container.accountRepository.activeAccountId().first { it != null } + ?: return@LaunchedEffect + stats = runCatching { container.statsRepository.getStats(accountId) }.getOrNull() + loaded = true } - val stats = summary - if (stats == null) { + val current = stats + if (!loaded) { Text("Loading…", modifier = Modifier.padding(16.dp)) return } + if (current.isNullOrEmpty()) { + EmptyState(title = "No stats", message = "Nothing to report for this account yet.", celebration = false) + return + } LazyColumn(modifier = Modifier.fillMaxSize().padding(16.dp)) { - item { Text("Daily volume", style = MaterialTheme.typography.titleLarge) } - item { BarChart(stats.daily.map { it.label to it.count }) } - item { Text("Monthly volume", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(top = 24.dp)) } - item { BarChart(stats.monthly.map { it.label to it.count }) } - item { Text("Workflow breakdown", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(top = 24.dp)) } - items(stats.workflowBreakdown.entries.toList()) { (type, count) -> - Text("$type: $count", style = MaterialTheme.typography.bodyLarge) + current.forEach { (key, value) -> + item { Section(key, value) } + } + } +} + +@Composable +private fun Section(key: String, value: Any?) { + val title = key.replace(Regex("([a-z])([A-Z])"), "$1 $2").replaceFirstChar { it.uppercase() } + Column(modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) { + Text(title, style = MaterialTheme.typography.titleLarge) + when (value) { + is Number -> Text(value.toString(), style = MaterialTheme.typography.bodyLarge) + is Map<*, *> -> BarChart( + value.entries.mapNotNull { (k, v) -> + val n = (v as? Number)?.toDouble() ?: return@mapNotNull null + k.toString() to n + }, + ) + is List<*> -> BarChart( + value.mapIndexedNotNull { index, entry -> + when (entry) { + is Number -> index.toString() to entry.toDouble() + is Map<*, *> -> { + val label = (entry["label"] ?: entry["date"] ?: index).toString() + val n = (entry["count"] as? Number ?: entry["value"] as? Number)?.toDouble() + n?.let { label to it } + } + else -> null + } + }, + ) + else -> Text(value?.toString().orEmpty(), style = MaterialTheme.typography.bodyMedium) } } } @Composable -private fun BarChart(points: List>) { - val max = (points.maxOfOrNull { it.second } ?: 1).coerceAtLeast(1) +private fun BarChart(points: List>) { + if (points.isEmpty()) { + Text("No data", style = MaterialTheme.typography.bodyMedium) + return + } + val max = (points.maxOfOrNull { it.second } ?: 1.0).coerceAtLeast(1.0) Column(modifier = Modifier.fillMaxWidth()) { points.forEach { (label, count) -> - Row(modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { - Text(label, modifier = Modifier.padding(end = 8.dp), style = MaterialTheme.typography.labelLarge) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "$label (${count.toLong()})", + modifier = Modifier.padding(end = 8.dp), + style = MaterialTheme.typography.labelLarge, + ) Box( modifier = Modifier .height(16.dp) - .fillMaxWidth(fraction = (count.toFloat() / max).coerceIn(0.02f, 1f)) + .fillMaxWidth(fraction = (count / max).toFloat().coerceIn(0.02f, 1f)) .background(MaterialTheme.colorScheme.primary), ) } diff --git a/app/src/main/java/ch/rhosys/email/presentation/templates/TemplatesScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/templates/TemplatesScreen.kt index 949f655..f3eb5e2 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/templates/TemplatesScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/templates/TemplatesScreen.kt @@ -52,7 +52,7 @@ fun TemplatesScreen(onUseTemplate: (Template) -> Unit) { } LazyColumn(modifier = Modifier.fillMaxSize()) { - items(templates, key = { it.id }) { template -> + items(templates, key = { it.templateId }) { template -> Card( modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), ) { diff --git a/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadScreen.kt b/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadScreen.kt index c1f97ef..e950e4f 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadScreen.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadScreen.kt @@ -32,14 +32,17 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.material3.RadioButton +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import ch.rhosys.email.di.LocalAppContainer -import ch.rhosys.email.domain.model.Folder -import ch.rhosys.email.domain.model.Message +import androidx.compose.ui.platform.LocalUriHandler +import ch.rhosys.email.domain.model.Attachment +import ch.rhosys.email.domain.model.Signal import ch.rhosys.email.presentation.components.MarkdownText import ch.rhosys.email.presentation.components.rememberViewModel import java.text.DateFormat @@ -47,13 +50,24 @@ import java.util.Date @OptIn(ExperimentalMaterial3Api::class) @Composable -fun ThreadScreen(threadId: String, onBack: () -> Unit, onReply: (String) -> Unit) { +fun ThreadScreen(accountId: String, threadId: String, onBack: () -> Unit, onReply: (String) -> Unit) { val container = LocalAppContainer.current - val viewModel = rememberViewModel { ThreadViewModel(threadId, container.threadRepository) } + val viewModel = rememberViewModel { + ThreadViewModel(accountId, threadId, container.threadRepository, container.accountRepository) + } val thread by viewModel.thread.collectAsState() - val messages by viewModel.messages.collectAsState() + val signals by viewModel.signals.collectAsState() val uiState by viewModel.uiState.collectAsState() var showMenu by remember { mutableStateOf(false) } + val uriHandler = LocalUriHandler.current + + // Unsubscribe returns a URL to open rather than completing server-side. + LaunchedEffect(uiState.unsubscribeUrl) { + uiState.unsubscribeUrl?.let { url -> + runCatching { uriHandler.openUri(url) } + viewModel.consumeUnsubscribeUrl() + } + } Scaffold( topBar = { @@ -73,9 +87,9 @@ fun ThreadScreen(threadId: String, onBack: () -> Unit, onReply: (String) -> Unit Icon(Icons.Filled.MoreVert, contentDescription = "More") } DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) { - DropdownMenuItem(text = { Text("Block sender") }, onClick = { + DropdownMenuItem(text = { Text("Sender policy") }, onClick = { showMenu = false - viewModel.requestBlockSender() + viewModel.openSenderPolicy() }, leadingIcon = { Icon(Icons.Filled.Block, contentDescription = null) }) } }, @@ -83,44 +97,99 @@ fun ThreadScreen(threadId: String, onBack: () -> Unit, onReply: (String) -> Unit }, ) { padding -> Column(modifier = Modifier.fillMaxSize().padding(padding)) { - if (thread?.folder == Folder.QUARANTINE) { - QuarantineActionBar(onApprove = { viewModel.approveQuarantine(); onBack() }, onReject = { viewModel.rejectQuarantine(); onBack() }) - } - if (!thread?.unsubscribeUrl.isNullOrEmpty()) { + // Any inbound signal carrying unsubscribe info makes the thread + // unsubscribable; the flag no longer lives on the thread itself. + val unsubscribable = signals.any { it is Signal.InboundEmail && it.unsubscribe != null } + if (unsubscribable) { UnsubscribeBar(onUnsubscribe = { viewModel.unsubscribe() }) } - thread?.let { WorkflowPanelView(it.workflowType, it.workflowFields, modifier = Modifier.padding(12.dp)) } + thread?.let { WorkflowPanelView(it.workflow, emptyMap(), modifier = Modifier.padding(12.dp)) } LazyColumn(modifier = Modifier.fillMaxSize()) { - items(messages, key = { it.id }) { message -> - MessageCard( - message = message, - expanded = message.id in uiState.expandedMessageIds, - onToggle = { viewModel.toggleExpanded(message.id) }, - onDownload = { viewModel.downloadAttachment(it) }, + items(signals, key = { it.signalId }) { signal -> + SignalCard( + signal = signal, + expanded = signal.signalId in uiState.expandedSignalIds, + onToggle = { viewModel.toggleExpanded(signal.signalId) }, + onOpenAttachment = { att -> att.url?.let { runCatching { uriHandler.openUri(it) } } }, ) } } } } - if (uiState.showBlockSenderConfirm) { - AlertDialog( - onDismissRequest = { viewModel.dismissBlockSenderConfirm() }, - title = { Text("Block this sender?") }, - text = { Text("You won't receive future emails from this address.") }, - confirmButton = { TextButton(onClick = { viewModel.confirmBlockSender() }) { Text("Block") } }, - dismissButton = { TextButton(onClick = { viewModel.dismissBlockSenderConfirm() }) { Text("Cancel") } }, + if (uiState.showSenderPolicy) { + SenderPolicyDialog( + uiState = uiState, + onSetSenderPolicy = viewModel::setSenderPolicy, + onSetAliasPolicy = viewModel::setAliasPolicy, + onDismiss = { viewModel.dismissSenderPolicy() }, ) } } +/** + * Mirrors the web app's sender popup: a policy for this sender's domain, plus + * the receiving alias's default for senders with no explicit entry. Policies + * apply to the whole domain, which is the unit the API works in. + */ @Composable -private fun QuarantineActionBar(onApprove: () -> Unit, onReject: () -> Unit) { - Row(modifier = Modifier.fillMaxWidth().padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = onApprove) { Text("Approve") } - TextButton(onClick = onReject) { Text("Reject") } - } +private fun SenderPolicyDialog( + uiState: ThreadDetailUiState, + onSetSenderPolicy: (ch.rhosys.email.domain.model.SenderPolicy) -> Unit, + onSetAliasPolicy: (ch.rhosys.email.domain.model.UnknownSenderPolicy) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Sender policy") }, + text = { + Column { + Text( + "Domain: ${uiState.senderDomain.orEmpty()}", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text("Sender policy", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(top = 12.dp)) + ch.rhosys.email.domain.model.SenderPolicy.entries.forEach { policy -> + Row( + modifier = Modifier.fillMaxWidth().clickable(enabled = !uiState.isSavingPolicy) { + onSetSenderPolicy(policy) + }.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = uiState.senderPolicy == policy, + onClick = { onSetSenderPolicy(policy) }, + enabled = !uiState.isSavingPolicy, + ) + Text(policy.label) + } + } + Text( + "Unknown senders on this alias", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(top = 12.dp), + ) + ch.rhosys.email.domain.model.UnknownSenderPolicy.entries.forEach { policy -> + Row( + modifier = Modifier.fillMaxWidth().clickable(enabled = !uiState.isSavingPolicy) { + onSetAliasPolicy(policy) + }.padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = uiState.aliasPolicy == policy, + onClick = { onSetAliasPolicy(policy) }, + enabled = !uiState.isSavingPolicy, + ) + Text(policy.label) + } + } + } + }, + confirmButton = { TextButton(onClick = onDismiss) { Text("Done") } }, + ) } @Composable @@ -137,28 +206,55 @@ private fun UnsubscribeBar(onUnsubscribe: () -> Unit) { } } +/** + * Renders any of the three signal shapes. Attachments open at the URL the + * backend supplies on the signal — there is no download endpoint. + */ @Composable -private fun MessageCard( - message: Message, +private fun SignalCard( + signal: Signal, expanded: Boolean, onToggle: () -> Unit, - onDownload: (ch.rhosys.email.domain.model.Attachment) -> Unit, + onOpenAttachment: (Attachment) -> Unit, ) { + val sender = when (signal) { + is Signal.InboundEmail -> signal.from.display + is Signal.OutboundEmail -> signal.from.display + is Signal.SystemNotice -> signal.type.replace('_', ' ').replaceFirstChar { it.uppercase() } + } + val body = when (signal) { + is Signal.InboundEmail -> signal.body ?: signal.summary + is Signal.OutboundEmail -> signal.body.orEmpty() + is Signal.SystemNotice -> signal.detail.orEmpty() + } + val attachments = when (signal) { + is Signal.InboundEmail -> signal.attachments + is Signal.OutboundEmail -> signal.attachments + is Signal.SystemNotice -> emptyList() + } + Card(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 6.dp)) { Column(modifier = Modifier.padding(12.dp).clickable(onClick = onToggle)) { Row(horizontalArrangement = Arrangement.SpaceBetween, modifier = Modifier.fillMaxWidth()) { - Text(message.fromAddress, style = MaterialTheme.typography.titleMedium) - Text( - DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT).format(Date(message.sentAt)), - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Text(sender, style = MaterialTheme.typography.titleMedium) + signal.createdAt?.let { at -> + Text( + DateFormat.getDateTimeInstance(DateFormat.SHORT, DateFormat.SHORT) + .format(Date(at.toEpochMilli())), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (signal is Signal.OutboundEmail && signal.isDraft) { + Text("Draft", style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary) } if (expanded) { - MarkdownText(message.bodyMarkdown, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) - message.attachments.forEach { attachment -> + MarkdownText(body, modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) + attachments.forEach { attachment -> Row( - modifier = Modifier.fillMaxWidth().padding(top = 4.dp).clickable { onDownload(attachment) }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp) + .clickable(enabled = attachment.url != null) { onOpenAttachment(attachment) }, verticalAlignment = Alignment.CenterVertically, ) { Icon(Icons.Filled.AttachFile, contentDescription = null) @@ -167,7 +263,7 @@ private fun MessageCard( } } else { Text( - message.bodyMarkdown.take(80), + body.take(80), maxLines = 1, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadViewModel.kt b/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadViewModel.kt index 9199ffb..76dbb19 100644 --- a/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadViewModel.kt +++ b/app/src/main/java/ch/rhosys/email/presentation/thread/ThreadViewModel.kt @@ -3,7 +3,9 @@ package ch.rhosys.email.presentation.thread import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import ch.rhosys.email.domain.model.MailThread +import ch.rhosys.email.domain.model.AliasSender import ch.rhosys.email.domain.model.SenderPolicy +import ch.rhosys.email.domain.model.UnknownSenderPolicy import ch.rhosys.email.domain.model.Signal import ch.rhosys.email.domain.repository.AccountRepository import ch.rhosys.email.domain.repository.ThreadRepository @@ -11,14 +13,20 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch data class ThreadDetailUiState( val expandedSignalIds: Set = emptySet(), - val showBlockSenderConfirm: Boolean = false, val isLoading: Boolean = true, val unsubscribeUrl: String? = null, + /** Sender popup state, mirroring the web app's SenderInfoPopup. */ + val showSenderPolicy: Boolean = false, + val senderDomain: String? = null, + val senderPolicy: SenderPolicy? = null, + val aliasPolicy: UnknownSenderPolicy? = null, + val isSavingPolicy: Boolean = false, ) /** @@ -78,33 +86,53 @@ class ThreadViewModel( _uiState.value = _uiState.value.copy(unsubscribeUrl = null) } - fun requestBlockSender() { - _uiState.value = _uiState.value.copy(showBlockSenderConfirm = true) + /** + * Opens the sender controls, loading the current per-domain policy and the + * receiving alias's unknown-sender default — the same two settings the web + * app's sender popup exposes. There is no per-thread block. + */ + fun openSenderPolicy() { + val current = thread.value ?: return + val domain = current.sender.address.substringAfter('@', current.sender.address) + _uiState.value = _uiState.value.copy(showSenderPolicy = true, senderDomain = domain) + viewModelScope.launch { + val senders = runCatching { + accountRepository.getAliasSenders(accountId, current.recipientAddress) + }.getOrDefault(emptyList()) + val existing = senders.firstOrNull { it.sender == domain }?.policy + val alias = accountRepository.observeAliases(accountId).first() + .firstOrNull { it.alias == current.recipientAddress } + _uiState.value = _uiState.value.copy( + senderPolicy = existing, + aliasPolicy = alias?.unknownSenderPolicy, + ) + } } - fun dismissBlockSenderConfirm() { - _uiState.value = _uiState.value.copy(showBlockSenderConfirm = false) + fun dismissSenderPolicy() { + _uiState.value = _uiState.value.copy(showSenderPolicy = false) } - /** - * Blocking applies a reject policy to the sender's domain on the alias that - * received the mail — the API has no per-thread block. - */ - fun confirmBlockSender() { - val current = thread.value ?: return dismissBlockSenderConfirm() - val domain = current.sender.address.substringAfter('@', "") - if (domain.isNotBlank()) { - viewModelScope.launch { - runCatching { - accountRepository.setSenderPolicy( - accountId = accountId, - alias = current.recipientAddress, - domain = domain, - policy = SenderPolicy.BLOCK_REJECT, - ) - } - } + fun setSenderPolicy(policy: SenderPolicy) { + val current = thread.value ?: return + val domain = _uiState.value.senderDomain ?: return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSavingPolicy = true) + runCatching { + accountRepository.setSenderPolicy(accountId, current.recipientAddress, domain, policy) + }.onSuccess { _uiState.value = _uiState.value.copy(senderPolicy = policy) } + _uiState.value = _uiState.value.copy(isSavingPolicy = false) + } + } + + fun setAliasPolicy(policy: UnknownSenderPolicy) { + val current = thread.value ?: return + viewModelScope.launch { + _uiState.value = _uiState.value.copy(isSavingPolicy = true) + runCatching { + accountRepository.setAliasUnknownSenderPolicy(accountId, current.recipientAddress, policy) + }.onSuccess { _uiState.value = _uiState.value.copy(aliasPolicy = policy) } + _uiState.value = _uiState.value.copy(isSavingPolicy = false) } - dismissBlockSenderConfirm() } } diff --git a/app/src/main/java/ch/rhosys/email/widget/InboxWidget.kt b/app/src/main/java/ch/rhosys/email/widget/InboxWidget.kt index 3d3a2d4..05d1d2c 100644 --- a/app/src/main/java/ch/rhosys/email/widget/InboxWidget.kt +++ b/app/src/main/java/ch/rhosys/email/widget/InboxWidget.kt @@ -19,18 +19,25 @@ import androidx.glance.text.TextStyle import androidx.glance.unit.ColorProvider import ch.rhosys.email.EmailApp import ch.rhosys.email.MainActivity -import ch.rhosys.email.domain.model.Folder +import ch.rhosys.email.data.local.entity.ThreadEntity +import ch.rhosys.email.domain.model.ThreadStatus import ch.rhosys.email.ui.theme.Mocha import kotlinx.coroutines.flow.first -/** Decision #62: home screen widget showing the latest unread emails. */ +/** + * Home screen widget showing the most recent active threads. There is no unread + * count — the API has no read state — so the header reports the active total. + */ class InboxWidget : GlanceAppWidget() { override suspend fun provideGlance(context: Context, id: GlanceId) { val container = (context.applicationContext as EmailApp).appContainer val accountId = container.tokenStore.activeAccountId - val threads = if (accountId != null) { - runCatching { container.database.threadDao().observeByFolder(accountId, Folder.ACTIVE.name).first() } - .getOrDefault(emptyList()) + val threads: List = if (accountId != null) { + runCatching { + container.database.threadDao() + .observeByStatus(accountId, ThreadStatus.ACTIVE.wire) + .first() + }.getOrDefault(emptyList()) } else { emptyList() } @@ -44,7 +51,7 @@ class InboxWidget : GlanceAppWidget() { .clickable(actionStartActivity()), ) { Text( - "Inbox (${threads.count { !it.isRead }} unread)", + "Inbox (${threads.size})", style = TextStyle(color = ColorProvider(Mocha.text), fontWeight = FontWeight.Bold), ) threads.take(5).forEach { thread -> From 5c7a1c8ec5318e9905043957e718ef904cdc903f Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 09:54:12 +0000 Subject: [PATCH 10/10] Add todo.md and the first unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01VjmqdZdfZaN7gary9eEBsL --- app/build.gradle.kts | 3 + .../data/local/entity/SignalEntityTest.kt | 131 ++++++++++++ .../data/remote/dto/SignalDtoAdapterTest.kt | 202 ++++++++++++++++++ .../rhosys/email/domain/model/WireEnumTest.kt | 114 ++++++++++ todo.md | 108 ++++++++++ 5 files changed, 558 insertions(+) create mode 100644 app/src/test/java/ch/rhosys/email/data/local/entity/SignalEntityTest.kt create mode 100644 app/src/test/java/ch/rhosys/email/data/remote/dto/SignalDtoAdapterTest.kt create mode 100644 app/src/test/java/ch/rhosys/email/domain/model/WireEnumTest.kt create mode 100644 todo.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ff156d..aac6af3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -154,6 +154,9 @@ dependencies { debugImplementation(libs.compose.ui.test.manifest) testImplementation(libs.junit) + // Android's org.json is a stub that throws "not mocked" in JVM unit tests; + // SignalEntity encodes attachments with it, so tests need a real one. + testImplementation("org.json:json:20240303") testImplementation(libs.junit5.jupiter) testImplementation(libs.mockk) testImplementation(libs.kotlinx.coroutines.test) diff --git a/app/src/test/java/ch/rhosys/email/data/local/entity/SignalEntityTest.kt b/app/src/test/java/ch/rhosys/email/data/local/entity/SignalEntityTest.kt new file mode 100644 index 0000000..99edf69 --- /dev/null +++ b/app/src/test/java/ch/rhosys/email/data/local/entity/SignalEntityTest.kt @@ -0,0 +1,131 @@ +package ch.rhosys.email.data.local.entity + +import ch.rhosys.email.domain.model.Attachment +import ch.rhosys.email.domain.model.EmailAddress +import ch.rhosys.email.domain.model.Signal +import ch.rhosys.email.domain.model.SignalStatus +import ch.rhosys.email.domain.model.UnsubscribeInfo +import ch.rhosys.email.domain.model.Urgency +import ch.rhosys.email.domain.model.Workflow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.Instant + +/** + * Attachments are carried on the signal itself and have no download endpoint, so + * losing them in the cache round trip means losing them entirely — which is what + * an earlier version of this code did. + */ +class SignalEntityTest { + + private val now = Instant.parse("2026-08-06T10:00:00Z") + + private fun inbound(attachments: List) = Signal.InboundEmail( + signalId = "sig-1", + threadId = "thr-1", + status = SignalStatus.ACTIVE, + createdAt = now, + from = EmailAddress("sender@example.com", "Sender"), + to = listOf(EmailAddress("me@rhosys.cloud")), + cc = emptyList(), + replyTo = null, + subject = "Subject", + body = "Body", + summary = "Summary", + urgency = Urgency.HIGH, + workflow = Workflow.PACKAGE, + recipientAddress = "me@rhosys.cloud", + receivedAt = now, + attachments = attachments, + unsubscribe = UnsubscribeInfo("website", "https://example.com/unsub"), + ) + + @Test + fun `attachments survive the cache round trip`() { + val attachments = listOf( + Attachment("invoice.pdf", "application/pdf", 12_345L, "https://cdn.example.com/invoice.pdf"), + Attachment("photo.png", "image/png", 900L, null), + ) + + val restored = inbound(attachments).toEntity("acc-1").toDomain() + + assertTrue(restored is Signal.InboundEmail) + val result = (restored as Signal.InboundEmail).attachments + assertEquals(2, result.size) + assertEquals("invoice.pdf", result[0].filename) + assertEquals("application/pdf", result[0].mimeType) + assertEquals(12_345L, result[0].sizeBytes) + assertEquals("https://cdn.example.com/invoice.pdf", result[0].url) + // An attachment with no URL cannot be opened, and must not become "null". + assertNull(result[1].url) + } + + @Test + fun `an inbound signal keeps its distinguishing fields`() { + val restored = inbound(emptyList()).toEntity("acc-1").toDomain() as Signal.InboundEmail + + assertEquals("sig-1", restored.signalId) + assertEquals("Summary", restored.summary) + assertEquals(Urgency.HIGH, restored.urgency) + assertEquals(Workflow.PACKAGE, restored.workflow) + assertEquals("Sender", restored.from.name) + assertEquals("https://example.com/unsub", restored.unsubscribe?.url) + assertEquals(now, restored.receivedAt) + } + + @Test + fun `an outbound draft keeps its status and recipients`() { + val draft = Signal.OutboundEmail( + signalId = "sig-2", + threadId = "thr-1", + status = SignalStatus.DRAFT, + createdAt = now, + from = EmailAddress("me@rhosys.cloud"), + to = listOf(EmailAddress("a@example.com"), EmailAddress("b@example.com")), + cc = emptyList(), + bcc = listOf(EmailAddress("c@example.com")), + replyTo = null, + subject = "Draft subject", + body = "Draft body", + attachments = emptyList(), + sentAt = null, + sendInitiatedAt = null, + sendFailureReason = null, + ) + + val restored = draft.toEntity("acc-1").toDomain() as Signal.OutboundEmail + + assertEquals(SignalStatus.DRAFT, restored.status) + assertTrue(restored.isDraft) + assertEquals(2, restored.to.size) + assertEquals(1, restored.bcc.size) + assertEquals("Draft body", restored.body) + } + + @Test + fun `a system notice keeps its type and detail`() { + val notice = Signal.SystemNotice( + signalId = "sig-3", + threadId = null, + status = SignalStatus.ACTIVE, + createdAt = now, + type = "deliverability", + detail = "Bounced", + ) + + val restored = notice.toEntity("acc-1").toDomain() as Signal.SystemNotice + + assertEquals("deliverability", restored.type) + assertEquals("Bounced", restored.detail) + assertNull(restored.threadId) + } + + @Test + fun `malformed attachment json degrades to empty rather than throwing`() { + assertEquals(emptyList(), decodeAttachments("not json at all")) + assertEquals(emptyList(), decodeAttachments(null)) + assertEquals(emptyList(), decodeAttachments("")) + } +} diff --git a/app/src/test/java/ch/rhosys/email/data/remote/dto/SignalDtoAdapterTest.kt b/app/src/test/java/ch/rhosys/email/data/remote/dto/SignalDtoAdapterTest.kt new file mode 100644 index 0000000..4d7318f --- /dev/null +++ b/app/src/test/java/ch/rhosys/email/data/remote/dto/SignalDtoAdapterTest.kt @@ -0,0 +1,202 @@ +package ch.rhosys.email.data.remote.dto + +import com.squareup.moshi.Moshi +import com.squareup.moshi.Types +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The signal union is discriminated by `type` for eight of its ten variants, but + * inbound and outbound email both report `type: "email"` and are separable only + * by their payload. These tests pin that behaviour down. + */ +class SignalDtoAdapterTest { + + private val moshi: Moshi = Moshi.Builder() + .add(SignalDtoAdapter.Factory) + .build() + + private val adapter = moshi.adapter(SignalDto::class.java) + + @Test + fun `inbound email is chosen when the payload has receivedAt`() { + val json = """ + { + "signalId": "sig-1", + "threadId": "thr-1", + "source": "system", + "status": "active", + "createdAt": "2026-08-06T10:00:00Z", + "type": "email", + "data": { + "receivedAt": "2026-08-06T09:59:00Z", + "summary": "A summary", + "from": { "address": "sender@example.com", "name": "Sender" }, + "to": [{ "address": "me@rhosys.cloud" }], + "cc": [], + "subject": "Hello", + "body": "Body text", + "attachments": [], + "headers": {}, + "recipientAddress": "me@rhosys.cloud", + "workflow": "conversation" + } + } + """.trimIndent() + + val signal = adapter.fromJson(json) + + assertTrue(signal is EmailInboundSignalDto) + val inbound = signal as EmailInboundSignalDto + assertEquals("sig-1", inbound.signalId) + assertEquals("A summary", inbound.data.summary) + assertEquals("sender@example.com", inbound.data.from.address) + } + + @Test + fun `outbound email is chosen when the payload has sendInitiatedAt`() { + val json = """ + { + "signalId": "sig-2", + "threadId": "thr-1", + "source": "user", + "status": "sent", + "createdAt": "2026-08-06T11:00:00Z", + "type": "email", + "data": { + "from": { "address": "me@rhosys.cloud" }, + "to": [{ "address": "someone@example.com" }], + "cc": [], + "bcc": [], + "subject": "Re: Hello", + "body": "My reply", + "attachments": [], + "sendInitiatedAt": "2026-08-06T11:00:01Z" + } + } + """.trimIndent() + + val signal = adapter.fromJson(json) + + assertTrue(signal is EmailOutboundSignalDto) + val outbound = signal as EmailOutboundSignalDto + assertEquals("Re: Hello", outbound.data.subject) + assertEquals("2026-08-06T11:00:01Z", outbound.data.sendInitiatedAt) + } + + @Test + fun `a draft is an outbound email with draft status`() { + val json = """ + { + "signalId": "sig-3", + "threadId": "thr-1", + "source": "user", + "status": "draft", + "createdAt": "2026-08-06T12:00:00Z", + "type": "email", + "data": { + "from": { "address": "me@rhosys.cloud" }, + "to": [], + "cc": [], + "bcc": [], + "subject": "Unsent", + "attachments": [], + "sendInitiatedAt": "2026-08-06T12:00:00Z" + } + } + """.trimIndent() + + val signal = adapter.fromJson(json) as EmailOutboundSignalDto + + assertEquals(SignalStatus.DRAFT, signal.status) + } + + @Test + fun `non-email types fall through to the system variant`() { + val json = """ + { + "signalId": "sig-4", + "threadId": null, + "source": "system", + "status": "active", + "createdAt": "2026-08-06T13:00:00Z", + "type": "domain_misconfiguration", + "data": { "summary": "MX record missing" } + } + """.trimIndent() + + val signal = adapter.fromJson(json) + + assertTrue(signal is SystemSignalDto) + val system = signal as SystemSignalDto + assertEquals("domain_misconfiguration", system.type) + assertNull(system.threadId) + assertEquals("MX record missing", system.data["summary"]) + } + + /** + * A signal type the backend adds later must not break the whole thread, so + * it degrades to a notice rather than throwing. + */ + @Test + fun `an unrecognised type still parses`() { + val json = """ + { + "signalId": "sig-5", + "threadId": "thr-9", + "source": "system", + "status": "active", + "createdAt": "2026-08-06T14:00:00Z", + "type": "something_invented_next_year", + "data": { "detail": "who knows" } + } + """.trimIndent() + + val signal = adapter.fromJson(json) + + assertTrue(signal is SystemSignalDto) + assertEquals("something_invented_next_year", (signal as SystemSignalDto).type) + } + + @Test + fun `a list of mixed signals round-trips`() { + val json = """ + { + "signals": [ + { + "signalId": "a", "threadId": "t", "source": "system", "status": "active", + "createdAt": "2026-08-06T10:00:00Z", "type": "email", + "data": { + "receivedAt": "2026-08-06T10:00:00Z", "summary": "s", + "from": { "address": "x@example.com" }, "to": [], "cc": [], + "subject": "S", "attachments": [], "headers": {}, + "recipientAddress": "me@rhosys.cloud", "workflow": "crm" + } + }, + { + "signalId": "b", "threadId": "t", "source": "system", "status": "active", + "createdAt": "2026-08-06T10:05:00Z", "type": "deliverability", + "data": { "summary": "bounced" } + } + ], + "pagination": { "cursor": null } + } + """.trimIndent() + + val listAdapter = moshi.adapter(SignalListResponse::class.java) + val page = listAdapter.fromJson(json)!! + + assertEquals(2, page.signals.size) + assertTrue(page.signals[0] is EmailInboundSignalDto) + assertTrue(page.signals[1] is SystemSignalDto) + assertNull(page.pagination?.cursor) + } + + @Test + fun `the factory only claims the SignalDto type`() { + val notASignal = Types.newParameterizedType(List::class.java, String::class.java) + assertNull(SignalDtoAdapter.Factory.create(notASignal, emptySet(), moshi)) + } +} diff --git a/app/src/test/java/ch/rhosys/email/domain/model/WireEnumTest.kt b/app/src/test/java/ch/rhosys/email/domain/model/WireEnumTest.kt new file mode 100644 index 0000000..376dc80 --- /dev/null +++ b/app/src/test/java/ch/rhosys/email/domain/model/WireEnumTest.kt @@ -0,0 +1,114 @@ +package ch.rhosys.email.domain.model + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Every enum here maps to a documented wire value. A drifting `wire` string + * fails silently at runtime — the fromWire fallback quietly swallows it — so the + * mapping is asserted against the OpenAPI enums explicitly. + */ +class WireEnumTest { + + @Test + fun `thread status matches the spec enum`() { + assertEquals( + listOf("active", "archived", "deleted", "report_violation"), + ThreadStatus.entries.map { it.wire }, + ) + } + + @Test + fun `signal status matches the spec enum`() { + assertEquals( + listOf( + "active", "block_hidden", "block_reject", "report_violation", + "quarantine_visible", "quarantine_hidden", "draft", "pending_send", "sent", + ), + SignalStatus.entries.map { it.wire }, + ) + } + + /** + * The narrower of the two policy enums: no quarantine options, and "allow" + * rather than "allow_all". Conflating it with UnknownSenderPolicy sends + * values the API rejects. + */ + @Test + fun `sender policy is the four-value enum`() { + assertEquals( + listOf("allow", "block_hidden", "block_reject", "report_violation"), + SenderPolicy.entries.map { it.wire }, + ) + } + + @Test + fun `unknown sender policy is the six-value enum`() { + assertEquals( + listOf( + "allow_all", "quarantine_visible", "quarantine_hidden", + "block_hidden", "block_reject", "report_violation", + ), + UnknownSenderPolicy.entries.map { it.wire }, + ) + } + + @Test + fun `workflow covers all fifteen classifications`() { + assertEquals( + listOf( + "auth", "conversation", "crm", "package", "travel", "payments", + "alert", "content", "onboarding", "notice", "healthcare", "job", + "support", "test", "events", + ), + Workflow.entries.map { it.wire }, + ) + } + + @Test + fun `urgency matches the spec enum`() { + assertEquals( + listOf("critical", "high", "normal", "low", "silent"), + Urgency.entries.map { it.wire }, + ) + } + + @Test + fun `forwardCalendarInvite is camelCase while the rest are snake_case`() { + assertEquals("forwardCalendarInvite", RuleActionType.FORWARD_CALENDAR_INVITE.wire) + assertEquals("assign_label", RuleActionType.ASSIGN_LABEL.wire) + assertEquals( + RuleActionType.FORWARD_CALENDAR_INVITE, + RuleActionType.fromWire("forwardCalendarInvite"), + ) + } + + @Test + fun `every wire value round-trips through fromWire`() { + ThreadStatus.entries.forEach { assertEquals(it, ThreadStatus.fromWire(it.wire)) } + SignalStatus.entries.forEach { assertEquals(it, SignalStatus.fromWire(it.wire)) } + SenderPolicy.entries.forEach { assertEquals(it, SenderPolicy.fromWire(it.wire)) } + UnknownSenderPolicy.entries.forEach { assertEquals(it, UnknownSenderPolicy.fromWire(it.wire)) } + Workflow.entries.forEach { assertEquals(it, Workflow.fromWire(it.wire)) } + Urgency.entries.forEach { assertEquals(it, Urgency.fromWire(it.wire)) } + RuleActionType.entries.forEach { assertEquals(it, RuleActionType.fromWire(it.wire)) } + } + + @Test + fun `unknown values fall back rather than throwing`() { + assertEquals(ThreadStatus.ACTIVE, ThreadStatus.fromWire("invented")) + assertEquals(ThreadStatus.ACTIVE, ThreadStatus.fromWire(null)) + assertEquals(Urgency.NORMAL, Urgency.fromWire("catastrophic")) + assertEquals(Workflow.CONVERSATION, Workflow.fromWire(null)) + assertEquals(SenderPolicy.ALLOW, SenderPolicy.fromWire("quarantine_visible")) + } + + @Test + fun `quarantine and block statuses are recognised as such`() { + assertEquals(true, SignalStatus.QUARANTINE_VISIBLE.isQuarantined) + assertEquals(true, SignalStatus.QUARANTINE_HIDDEN.isQuarantined) + assertEquals(false, SignalStatus.ACTIVE.isQuarantined) + assertEquals(true, SignalStatus.BLOCK_REJECT.isBlocked) + assertEquals(false, SignalStatus.DRAFT.isBlocked) + } +} diff --git a/todo.md b/todo.md new file mode 100644 index 0000000..cd6733b --- /dev/null +++ b/todo.md @@ -0,0 +1,108 @@ +# TODO + +Open work on the Android app, most blocking first. + +--- + +## Blocking a working build + +### Authress application ID + +`app/build.gradle.kts` still defaults `authressApplicationId` to `numaeel_android`, +a value invented alongside the fictional Numaeel product. Login against +`login.rhosys.cloud` will fail until this is a real application registered in +Authress. + +Override per-environment with `-PauthressApplicationId=`, or change the +default once the real id is known. + +### OAuth redirect is claimed twice + +`MainActivity` declares an intent filter for `ch.rhosys.email:/oauth2redirect` +(`AndroidManifest.xml`), and AppAuth's own `RedirectUriReceiverActivity` claims +the same scheme through the `appAuthRedirectScheme` manifest placeholder +(`app/build.gradle.kts`). Two components match the same redirect, so resolution +is non-deterministic. + +If MainActivity wins, sign-in breaks silently: it never reads the incoming +intent — there is no `onNewIntent` override and `getIntent()` appears nowhere in +`app/src` — so the authorization code is dropped. AppAuth needs its own receiver +to complete the exchange, which makes the comment on the placeholder +("our redirect is actually captured by MainActivity's intent-filter") backwards. + +Fix is most likely to delete the MainActivity filter and let AppAuth handle it. +Worth doing alongside the Authress application id, since both block login. + +Separately, a custom-scheme redirect can be registered by any app on the device. +Prefer an HTTPS App Link redirect on `email.rhosys.cloud` once assetlinks.json is +served (see the Play Store section). + +### App name + +The user-visible name is still **Numaeel**, an invented brand. It appears in: + +- `app/src/main/res/values/strings.xml` — home screen label +- `app/src/main/res/values/themes.xml` — `Theme.Numaeel` +- `presentation/auth/LoginScreen.kt`, `BiometricLockScreen.kt` +- `presentation/onboarding/OnboardingScreen.kt`, `FeatureTourDialog.kt` +- `presentation/navigation/AppScaffold.kt` +- `sync/SyncForegroundService.kt` — the sync notification +- `wear/src/main/res/values/strings.xml`, `wear/.../WearMainActivity.kt` +- `res/drawable/ic_launcher_foreground.xml` — placeholder "N" monogram +- `res/xml/shortcuts.xml` — `numaeel://` deep link scheme + +Storage keys (`numaeel.db`, `numaeel_prefs`, `numaeel_secure_prefs`) are +deliberately excluded: they are invisible to users, and renaming them orphans +data on installed builds. Leave them unless a migration is worth writing. + +--- + +## Not yet verified against a live backend + +Nothing in the app has made a real call to `email.rhosys.cloud`. The routes and +response shapes come from the published OpenAPI document, but that document +declares **no request bodies** for any write operation, so every request body was +transcribed from the web client (`SES-Email-Adapter-UI/src/lib/api.ts`). Expect +mismatches on the first real round trip, particularly for: + +- `PATCH /accounts/{id}/threads/{threadId}` — status, labels, followupAt +- `POST /accounts/{id}/threads/{threadId}/signals` — draft creation +- `PUT /accounts/{id}/threads/{threadId}/signals/{id}` — draft update +- `POST /accounts/{id}/signals/{id}/quarantineResponse` + +Worth asking the backend to add request bodies to the spec. + +--- + +## Gaps the API does not currently support + +These were removed rather than faked. Each needs a backend change before the UI +can come back. + +| Feature | What is missing | +|:--|:--| +| Read / unread | No field or endpoint anywhere in the API. Inbox rows use `urgency` instead | +| Compose a new thread | Drafts post to `/threads/{threadId}/signals`; there is no route for a draft with no thread. Reply and forward work | +| Send later / undo send | No scheduling parameter, no cancel route. Sending is immediate | +| Attachment download | Attachments carry a fixed `url` and are opened directly; there is no download endpoint | +| MFA / passkey management | No endpoints | +| Billing | `billingPlan` is readable on an account, but there are no billing endpoints | +| Support tickets | No endpoint. `SupportData` in the spec is a signal workflow type, not a ticket API | +| Per-address sender blocking | Sender policy applies to a whole domain on an alias | + +--- + +## Deferred by choice + +- **Spam screen** — no API concept. Filtered mail surfaces under Quarantine +- **Admin screen** — `/healthcheck` and per-signal `reprocess` / `raw` are real + and could back a reduced version whenever it is wanted + +--- + +## Play Store + +The app links intent filter claims `email.rhosys.cloud/mail`. Verification needs +`https://email.rhosys.cloud/.well-known/assetlinks.json` listing this package and +the **Play App Signing** SHA-256 fingerprint — not the upload key. Until that is +served, `autoVerify` fails and the domain claim is rejected.