diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 992a019..aac6af3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,17 +16,19 @@ 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. + // 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://api.numaeel.com/"}\"", + "\"${(findProperty("apiBaseUrl") as? String) ?: "https://email.rhosys.cloud/api/"}\"", ) buildConfigField( "String", "AUTHRESS_CUSTOM_DOMAIN", - "\"${(findProperty("authressDomain") as? String) ?: "login.numaeel.com"}\"", + "\"${(findProperty("authressDomain") as? String) ?: "login.rhosys.cloud"}\"", ) buildConfigField( "String", @@ -152,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/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/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 12909b4..a162c16 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -36,12 +36,15 @@ - + - + @@ -78,8 +81,6 @@ android:exported="false" android:foregroundServiceType="dataSync" /> - - 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..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 @@ -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.UnknownSenderPolicy +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 = UnknownSenderPolicy.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 = UnknownSenderPolicy.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..67e2c1e --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/local/entity/SignalEntity.kt @@ -0,0 +1,212 @@ +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 org.json.JSONArray +import org.json.JSONObject +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) } + +/** + * 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(): Signal { + val attachments = decodeAttachments(attachmentsJson) + return 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, attachments included. */ +fun Signal.toEntity( + accountId: String, + 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, + 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 = encodedAttachments, 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/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..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 @@ -4,20 +4,107 @@ 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 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) +data class AliasSenderListResponse( + val senders: List = emptyList(), +) + +@JsonClass(generateAdapter = true) +data class SetAliasSenderRequest( + val policy: String, +) + +/** + * 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" + 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/Mappers.kt b/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt new file mode 100644 index 0000000..978f561 --- /dev/null +++ b/app/src/main/java/ch/rhosys/email/data/remote/dto/Mappers.kt @@ -0,0 +1,178 @@ +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.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 +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 = UnknownSenderPolicy.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 = UnknownSenderPolicy.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, +) + +internal fun AliasSenderDto.toDomain() = AliasSender( + alias = alias, + sender = sender, + policy = SenderPolicy.fromWire(policy), +) 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/data/repository/AccountRepositoryImpl.kt b/app/src/main/java/ch/rhosys/email/data/repository/AccountRepositoryImpl.kt index 2a5839d..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,8 +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 @@ -21,21 +27,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 +56,26 @@ 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, + alias: String, + domain: String, + policy: SenderPolicy, + ) { + 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/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..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 @@ -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() 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() 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/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/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..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 @@ -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() } } - 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() } } - } - - 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) + } + } } } 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..5b32fc7 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 @@ -42,7 +40,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 { @@ -68,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() + .build() } val accountRepository: AccountRepository by lazy { @@ -76,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 { @@ -97,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/domain/model/Account.kt b/app/src/main/java/ch/rhosys/email/domain/model/Account.kt index 09b3ab1..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 @@ -1,20 +1,103 @@ 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: UnknownSenderPolicy, + 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: UnknownSenderPolicy, + val createdAt: Instant?, + val updatedAt: Instant?, +) + +/** + * 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(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 + } +} + +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. The wire field for the domain is + * `sender`, not `domain`. + */ +data class AliasSender( + val alias: String, + val sender: 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/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/domain/repository/Repositories.kt b/app/src/main/java/ch/rhosys/email/domain/repository/Repositories.kt index 4d0873e..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 @@ -3,15 +3,17 @@ 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.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 import kotlinx.coroutines.flow.Flow +import java.time.Instant interface AccountRepository { fun observeAccounts(): Flow> @@ -19,63 +21,91 @@ interface AccountRepository { suspend fun refresh() suspend fun setActiveAccount(accountId: String) fun activeAccountId(): Flow + + /** + * 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 { - 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) } 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/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/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/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/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/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/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/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/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/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/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/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/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") } - } -} 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 cabc67c..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 @@ -2,83 +2,137 @@ 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.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 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.first import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch data class ThreadDetailUiState( - val expandedMessageIds: Set = emptySet(), - val showBlockSenderConfirm: Boolean = false, + val expandedSignalIds: Set = emptySet(), 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, ) +/** + * 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) + /** + * 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) } - fun confirmBlockSender() { - viewModelScope.launch { threadRepository.blockSender(threadId) } - dismissBlockSenderConfirm() + 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 approveQuarantine() = viewModelScope.launch { threadRepository.approveQuarantine(threadId) } - fun rejectQuarantine() = viewModelScope.launch { threadRepository.rejectQuarantine(threadId) } + 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) + } + } } 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" } 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) - } -} 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 -> 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.