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