diff --git a/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json b/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json new file mode 100644 index 0000000..65389fd --- /dev/null +++ b/app/schemas/org.libremail.data.local.LibreMailDatabase/10.json @@ -0,0 +1,585 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "dda823c68d6b6207f3b648b46a0182e6", + "entities": [ + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `email` TEXT NOT NULL, `displayName` TEXT NOT NULL, `authType` TEXT NOT NULL, `imap_host` TEXT NOT NULL, `imap_port` INTEGER NOT NULL, `imap_security` TEXT NOT NULL, `smtp_host` TEXT NOT NULL, `smtp_port` INTEGER NOT NULL, `smtp_security` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authType", + "columnName": "authType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imap.host", + "columnName": "imap_host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imap.port", + "columnName": "imap_port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "imap.security", + "columnName": "imap_security", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smtp.host", + "columnName": "smtp_host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smtp.port", + "columnName": "smtp_port", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "smtp.security", + "columnName": "smtp_security", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "account_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `signature` TEXT NOT NULL, `signatureEnabled` INTEGER NOT NULL, `notificationsEnabled` INTEGER NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signature", + "columnName": "signature", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "signatureEnabled", + "columnName": "signatureEnabled", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notificationsEnabled", + "columnName": "notificationsEnabled", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + }, + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sender", + "columnName": "sender", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "senderEmail", + "columnName": "senderEmail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "snippet", + "columnName": "snippet", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isHtml", + "columnName": "isHtml", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestampMillis", + "columnName": "timestampMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRead", + "columnName": "isRead", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isStarred", + "columnName": "isStarred", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "folder", + "columnName": "folder", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'INBOX'" + }, + { + "fieldPath": "inInbox", + "columnName": "inInbox", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bodyFetched", + "columnName": "bodyFetched", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_messages_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_messages_timestampMillis", + "unique": false, + "columnNames": [ + "timestampMillis" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)" + } + ] + }, + { + "tableName": "credentials", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `encryptedSecret` TEXT NOT NULL, PRIMARY KEY(`accountId`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "encryptedSecret", + "columnName": "encryptedSecret", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId" + ] + } + }, + { + "tableName": "attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "messageId", + "columnName": "messageId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "partIndex", + "columnName": "partIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "filename", + "columnName": "filename", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "sizeBytes", + "columnName": "sizeBytes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "messageId", + "partIndex" + ] + }, + "indices": [ + { + "name": "index_attachments_messageId", + "unique": false, + "columnNames": [ + "messageId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)" + } + ], + "foreignKeys": [ + { + "table": "messages", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "messageId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "outbox", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "toAddresses", + "columnName": "toAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccAddresses", + "columnName": "ccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastError", + "columnName": "lastError", + "affinity": "TEXT" + }, + { + "fieldPath": "bodyHtml", + "columnName": "bodyHtml", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "drafts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT" + }, + { + "fieldPath": "toAddresses", + "columnName": "toAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ccAddresses", + "columnName": "ccAddresses", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "subject", + "columnName": "subject", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "body", + "columnName": "body", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "attachments", + "columnName": "attachments", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bodyHtml", + "columnName": "bodyHtml", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "folders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `fullName`))", + "fields": [ + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullName", + "columnName": "fullName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "role", + "columnName": "role", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "selectable", + "columnName": "selectable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sortOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "accountId", + "fullName" + ] + } + }, + { + "tableName": "signatures", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, `contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contentHtml", + "columnName": "contentHtml", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isDefault", + "columnName": "isDefault", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_signatures_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, 'dda823c68d6b6207f3b648b46a0182e6')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt index f79725a..97777c4 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/compose/ComposeScreenTest.kt @@ -24,6 +24,7 @@ import org.libremail.R import org.libremail.contacts.ContactsRepository import org.libremail.data.local.LibreMailDatabase import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.SignatureRepository import org.libremail.domain.model.Account import org.libremail.domain.model.AuthType import org.libremail.domain.model.MailSecurity @@ -81,6 +82,7 @@ class ComposeScreenTest { accountRepository = FakeAccountRepository(accounts = listOf(account)), contactsRepository = ContactsRepository(context), accountSettingsRepository = AccountSettingsRepository(database.accountSettingsDao()), + signatureRepository = SignatureRepository(database.signatureDao()), ) composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt index d4f26b7..8bafcf7 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/AccountSettingsScreenTest.kt @@ -6,7 +6,6 @@ import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performTextInput import androidx.lifecycle.SavedStateHandle import androidx.room.Room import androidx.test.core.app.ApplicationProvider @@ -19,6 +18,7 @@ import org.libremail.R import org.libremail.data.local.LibreMailDatabase import org.libremail.data.local.toEntity import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.SignatureRepository import org.libremail.domain.model.Account import org.libremail.domain.model.AuthType import org.libremail.domain.model.MailSecurity @@ -48,6 +48,8 @@ class AccountSettingsScreenTest { private fun string(resId: Int) = composeTestRule.activity.getString(resId) + private var manageSignaturesClicked = false + private fun setContent(): AccountSettingsRepository { val context = ApplicationProvider.getApplicationContext() // Intentionally not closed in an @After: the ViewModel's `settings` Room Flow (kept alive by @@ -64,24 +66,27 @@ class AccountSettingsScreenTest { savedStateHandle = SavedStateHandle(mapOf(Routes.ACCOUNT_SETTINGS_ARG_ID to account.id)), accountRepository = FakeAccountRepository(accounts = listOf(account)), accountSettingsRepository = repository, + signatureRepository = SignatureRepository(db.signatureDao()), ) composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { - AccountSettingsScreen(onBack = {}, viewModel = viewModel) + AccountSettingsScreen( + onBack = {}, + onManageSignatures = { manageSignaturesClicked = true }, + viewModel = viewModel, + ) } } return repository } @Test - fun editingSignature_persistsThroughTheRepository() { - val repository = setContent() + fun manageSignatures_opensTheSignaturesScreen() { + setContent() - composeTestRule.onNodeWithText(string(R.string.settings_signature_hint)).performTextInput("Cheers") + composeTestRule.onNodeWithText(string(R.string.settings_signatures_manage)).performClick() - composeTestRule.waitUntil(5_000) { - runBlocking { repository.get(account.id).signature } == "Cheers" - } + composeTestRule.waitUntil(5_000) { manageSignaturesClicked } } @Test diff --git a/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt index ec0164e..a8067c7 100644 --- a/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt +++ b/app/src/main/kotlin/org/libremail/data/ReplyBuilder.kt @@ -2,40 +2,55 @@ package org.libremail.data import org.libremail.domain.model.ReplyMode +import org.libremail.mail.HtmlToText import org.libremail.mail.ReplyContext +import org.libremail.richtext.RichTextContent +import org.libremail.richtext.RichTextHtml import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -/** The pre-filled compose fields for a reply/forward (everything else the user fills in). */ -data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String) +/** + * The pre-filled compose fields for a reply/forward. [body] is the plaintext form; [bodyHtml] is the + * matching HTML (the quote rendered as a `
`), so the reply can go out as + * `multipart/alternative` without the user having to re-format the quote. + */ +data class ReplyContent(val to: String, val cc: String, val subject: String, val body: String, val bodyHtml: String) /** * Pure builder that turns an original message ([ReplyContext]) into the pre-filled compose fields for a * reply, reply-all, or forward. Kept free of Android/IMAP dependencies so it can be unit-tested directly. + * + * HTML originals are quoted by first reducing them to readable text (via [HtmlToText]) and then + * quoting that — never by prefixing "> " onto raw tags — so the quote can never corrupt the markup. + * The plaintext quote's "> " / attribution structure is then rendered to a clean `
` for + * the HTML alternative. */ object ReplyBuilder { fun build(context: ReplyContext, mode: ReplyMode, selfEmail: String): ReplyContent = when (mode) { - ReplyMode.REPLY -> ReplyContent( - to = context.fromEmail, - cc = "", - subject = prefixedSubject(context.subject, "Re:"), - body = quotedReply(context), - ) + ReplyMode.REPLY -> reply(context, cc = "") + ReplyMode.REPLY_ALL -> reply(context, cc = replyAllCc(context, selfEmail).joinToString(", ")) + ReplyMode.FORWARD -> { + val body = forwardedBody(context) + ReplyContent( + to = "", + cc = "", + subject = prefixedSubject(context.subject, "Fwd:"), + body = body, + bodyHtml = htmlOf(body), + ) + } + } - ReplyMode.REPLY_ALL -> ReplyContent( + private fun reply(context: ReplyContext, cc: String): ReplyContent { + val body = quotedReply(context) + return ReplyContent( to = context.fromEmail, - cc = replyAllCc(context, selfEmail).joinToString(", "), + cc = cc, subject = prefixedSubject(context.subject, "Re:"), - body = quotedReply(context), - ) - - ReplyMode.FORWARD -> ReplyContent( - to = "", - cc = "", - subject = prefixedSubject(context.subject, "Fwd:"), - body = forwardedBody(context), + body = body, + bodyHtml = htmlOf(body), ) } @@ -68,15 +83,12 @@ object ReplyBuilder { append(bodyText(context)) } - /** The original body as plain text (HTML stripped), suitable for quoting in a plain-text compose. */ - private fun bodyText(context: ReplyContext): String = if (context.isHtml) htmlToText(context.body) else context.body + /** The original body as plain text (HTML stripped), suitable for quoting in a compose field. */ + private fun bodyText(context: ReplyContext): String = + if (context.isHtml) HtmlToText.convert(context.body) else context.body - private fun htmlToText(html: String): String = html - .replace(Regex("(?i)"), "\n") - .replace(Regex("(?i)"), "\n\n") - .replace(Regex("<[^>]*>"), "") - .replace(Regex("[ \\t]+"), " ") - .trim() + /** Renders the plaintext quote (with its "> " markers) to the equivalent clean HTML. */ + private fun htmlOf(body: String): String = RichTextHtml.toHtml(RichTextContent(body)) private fun formatDate(millis: Long): String = SimpleDateFormat("MMM d, yyyy, h:mm a", Locale.US).format(Date(millis)) diff --git a/app/src/main/kotlin/org/libremail/data/SignatureBlock.kt b/app/src/main/kotlin/org/libremail/data/SignatureBlock.kt new file mode 100644 index 0000000..a46b109 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/SignatureBlock.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data + +import org.libremail.domain.model.Signature +import org.libremail.richtext.RichTextContent +import org.libremail.richtext.RichTextHtml + +/** + * A signature rendered into both forms the composer needs: [plain] for the plaintext body/fallback + * and [html] for the HTML body. The two are kept in sync — parsing [html] back through the rich-text + * model yields exactly [plain] — so the editor shows the same content whichever it seeds from, and a + * From-account swap can strip the previously applied block from either representation. + * + * The block opens with the RFC 3676 "-- " delimiter so receiving clients recognize it as a signature. + */ +data class SignatureBlock(val plain: String, val html: String) { + val isEmpty: Boolean get() = plain.isEmpty() && html.isEmpty() + + companion object { + val EMPTY = SignatureBlock("", "") + + /** The block for [signature], or [EMPTY] when there is none / it is blank. */ + fun of(signature: Signature?): SignatureBlock { + if (signature == null) return EMPTY + val plainSig = signature.plainText().trimEnd() + if (plainSig.isBlank() && signature.html.isBlank()) return EMPTY + return SignatureBlock( + plain = "$DELIMITER_PLAIN$plainSig", + html = RichTextHtml.toHtml(RichTextContent(DELIMITER_PLAIN)) + signature.html, + ) + } + + private const val DELIMITER_PLAIN = "\n\n-- \n" + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt index 0b1b2e5..2825a61 100644 --- a/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt +++ b/app/src/main/kotlin/org/libremail/data/local/LibreMailDatabase.kt @@ -11,6 +11,7 @@ import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.FolderDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.dao.SignatureDao import org.libremail.data.local.entity.AccountEntity import org.libremail.data.local.entity.AccountSettingsEntity import org.libremail.data.local.entity.AttachmentEntity @@ -19,6 +20,7 @@ import org.libremail.data.local.entity.DraftEntity import org.libremail.data.local.entity.FolderEntity import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.OutboxEntity +import org.libremail.data.local.entity.SignatureEntity @Database( entities = [ @@ -30,8 +32,9 @@ import org.libremail.data.local.entity.OutboxEntity OutboxEntity::class, DraftEntity::class, FolderEntity::class, + SignatureEntity::class, ], - version = 9, + version = 10, exportSchema = true, ) abstract class LibreMailDatabase : RoomDatabase() { @@ -43,4 +46,5 @@ abstract class LibreMailDatabase : RoomDatabase() { abstract fun outboxDao(): OutboxDao abstract fun draftDao(): DraftDao abstract fun folderDao(): FolderDao + abstract fun signatureDao(): SignatureDao } diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index bf975f1..9c4773d 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -162,6 +162,7 @@ internal fun DraftEntity.toDomain(): Draft = Draft( subject = subject, body = body, updatedAt = updatedAt, + bodyHtml = bodyHtml, attachments = attachments.toOutgoingAttachments(), ) @@ -174,6 +175,7 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity( body = body, updatedAt = updatedAt, attachments = attachments.toJson(), + bodyHtml = bodyHtml, ) /** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */ @@ -202,6 +204,7 @@ internal fun OutboxEntity.toDomain(): OutboxMessage = OutboxMessage( body = body, createdAt = createdAt, lastError = lastError, + bodyHtml = bodyHtml, ) private fun String.toMailSecurity(): MailSecurity = runCatching { diff --git a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt index 0338ce3..7093a3a 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Migrations.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Migrations.kt @@ -171,3 +171,35 @@ val MIGRATION_8_9 = object : Migration(8, 9) { ) } } + +/** + * v9 -> v10: rich composition (preserves existing data). + * - `drafts`/`outbox`: add a nullable `bodyHtml` column carrying the HTML form of the body when a + * message was composed with formatting (null = plaintext-only, sent/kept exactly as before). + * - add the `signatures` table (multiple named signatures per account, one default), with a + * cascading foreign key to `accounts`, and backfill each account's existing per-account settings + * signature as its default signature so nobody loses one on upgrade. + */ +val MIGRATION_9_10 = object : Migration(9, 10) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `drafts` ADD COLUMN `bodyHtml` TEXT") + db.execSQL("ALTER TABLE `outbox` ADD COLUMN `bodyHtml` TEXT") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `signatures` (" + + "`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `name` TEXT NOT NULL, " + + "`contentHtml` TEXT NOT NULL, `isDefault` INTEGER NOT NULL, PRIMARY KEY(`id`), " + + "FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) " + + "ON UPDATE NO ACTION ON DELETE CASCADE)", + ) + db.execSQL("CREATE INDEX IF NOT EXISTS `index_signatures_accountId` ON `signatures` (`accountId`)") + // Preserve any existing plain-text per-account signature as that account's default signature. + // Newlines become
so the HTML keeps the original line breaks; other characters are rare + // in signatures and pass through unescaped. + db.execSQL( + "INSERT INTO `signatures` (`id`, `accountId`, `name`, `contentHtml`, `isDefault`) " + + "SELECT `accountId` || ':default-signature', `accountId`, 'Signature', " + + "replace(`signature`, char(10), '
'), 1 " + + "FROM `account_settings` WHERE `signature` <> ''", + ) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt b/app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt new file mode 100644 index 0000000..ddf12c9 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/dao/SignatureDao.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import kotlinx.coroutines.flow.Flow +import org.libremail.data.local.entity.SignatureEntity + +@Dao +interface SignatureDao { + @Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY isDefault DESC, name COLLATE NOCASE") + fun observeForAccount(accountId: String): Flow> + + @Query("SELECT * FROM signatures WHERE id = :id LIMIT 1") + suspend fun getById(id: String): SignatureEntity? + + @Query("SELECT * FROM signatures WHERE accountId = :accountId AND isDefault = 1 LIMIT 1") + suspend fun getDefault(accountId: String): SignatureEntity? + + @Query("SELECT * FROM signatures WHERE accountId = :accountId ORDER BY name COLLATE NOCASE LIMIT 1") + suspend fun firstForAccount(accountId: String): SignatureEntity? + + @Query("SELECT COUNT(*) FROM signatures WHERE accountId = :accountId") + suspend fun countForAccount(accountId: String): Int + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(signature: SignatureEntity) + + @Query("DELETE FROM signatures WHERE id = :id") + suspend fun delete(id: String) + + @Query("UPDATE signatures SET isDefault = 0 WHERE accountId = :accountId") + suspend fun clearDefault(accountId: String) + + @Query("UPDATE signatures SET isDefault = 1 WHERE id = :id") + suspend fun markDefault(id: String) + + /** Makes [id] the account's sole default in one transaction (clears the others first). */ + @Transaction + suspend fun setDefault(accountId: String, id: String) { + clearDefault(accountId) + markDefault(id) + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt index ed6dd3b..b781e8c 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/DraftEntity.kt @@ -16,4 +16,6 @@ data class DraftEntity( val updatedAt: Long, /** JSON array of the draft's attachments ([uri, name] pairs); empty string when there are none. */ val attachments: String = "", + /** HTML form of [body] when the draft carries formatting; null for plaintext drafts. */ + val bodyHtml: String? = null, ) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt index 96d5f44..6ade954 100644 --- a/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt +++ b/app/src/main/kotlin/org/libremail/data/local/entity/OutboxEntity.kt @@ -15,4 +15,6 @@ data class OutboxEntity( val body: String, val createdAt: Long, val lastError: String? = null, + /** HTML form of [body] when composed with formatting; null sends `text/plain` only. */ + val bodyHtml: String? = null, ) diff --git a/app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt b/app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt new file mode 100644 index 0000000..23c1c13 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/entity/SignatureEntity.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local.entity + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.PrimaryKey + +/** + * One saved signature. The [accountId] foreign key cascades on delete, so removing an account drops + * its signatures. [isDefault] marks the one auto-inserted when composing from the account; the + * repository keeps at most one default per account. + */ +@Entity( + tableName = "signatures", + foreignKeys = [ + ForeignKey( + entity = AccountEntity::class, + parentColumns = ["id"], + childColumns = ["accountId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("accountId")], +) +data class SignatureEntity( + @PrimaryKey val id: String, + val accountId: String, + val name: String, + val contentHtml: String, + val isDefault: Boolean = false, +) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index 94657d8..6f941da 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -8,6 +8,7 @@ import jakarta.mail.Flags import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import org.libremail.data.ReplyBuilder +import org.libremail.data.SignatureBlock import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AttachmentDao import org.libremail.data.local.dao.DraftDao @@ -20,6 +21,7 @@ import org.libremail.data.local.entity.OutboxEntity import org.libremail.data.local.toDomain import org.libremail.data.local.toEntity import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.SignatureRepository import org.libremail.data.sync.MailConnectionFactory import org.libremail.data.sync.SendScheduler import org.libremail.domain.model.Attachment @@ -52,6 +54,7 @@ class MailRepositoryImpl @Inject constructor( private val connectionFactory: MailConnectionFactory, private val sendScheduler: SendScheduler, private val accountSettingsRepository: AccountSettingsRepository, + private val signatureRepository: SignatureRepository, ) : MailRepository { override fun observeMessages(): Flow> = messageDao.observeAll().map { rows -> @@ -191,9 +194,15 @@ class MailRepositoryImpl @Inject constructor( val params = connectionFactory.imapParamsFor(account) val context = imapClient.fetchForReply(params, entity.folder, uidOf(messageId)) val content = ReplyBuilder.build(context, mode, account.email) - // Bake the sending account's signature into the reply/forward body so it round-trips as part - // of the draft (compose won't re-append for drafts). - val signature = accountSettingsRepository.get(entity.accountId).signatureBlock() + // Bake the sending account's default signature into the reply/forward body — above the quoted + // original — so it round-trips as part of the draft (compose won't re-append for drafts). Both + // the plaintext and HTML forms are stored so the reply can go out as multipart/alternative. + val settings = accountSettingsRepository.get(entity.accountId) + val sig = if (settings.signatureEnabled) { + SignatureBlock.of(signatureRepository.getDefault(entity.accountId)) + } else { + SignatureBlock.EMPTY + } val draftId = UUID.randomUUID().toString() saveDraft( Draft( @@ -202,8 +211,9 @@ class MailRepositoryImpl @Inject constructor( to = content.to, cc = content.cc, subject = content.subject, - body = content.body + signature, + body = sig.plain + content.body, updatedAt = System.currentTimeMillis(), + bodyHtml = sig.html + content.bodyHtml, attachments = emptyList(), ), ) @@ -269,6 +279,7 @@ class MailRepositoryImpl @Inject constructor( subject = outgoing.subject, body = outgoing.body, createdAt = System.currentTimeMillis(), + bodyHtml = outgoing.bodyHtml, ), ) sendScheduler.sendNow() diff --git a/app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt new file mode 100644 index 0000000..5bc3955 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/settings/SignatureRepository.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.settings + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.libremail.data.local.dao.SignatureDao +import org.libremail.data.local.entity.SignatureEntity +import org.libremail.domain.model.Signature +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton + +/** + * CRUD for per-account signatures, keeping the "at most one default per account" invariant. The + * first signature created for an account becomes its default automatically; deleting the default + * promotes another (if any) so an account with signatures always has one to auto-insert. + */ +@Singleton +class SignatureRepository @Inject constructor(private val dao: SignatureDao) { + + fun observeForAccount(accountId: String): Flow> = + dao.observeForAccount(accountId).map { rows -> rows.map { it.toDomain() } } + + suspend fun get(id: String): Signature? = dao.getById(id)?.toDomain() + + suspend fun getDefault(accountId: String): Signature? = dao.getDefault(accountId)?.toDomain() + + /** Creates a signature; makes it the default when it is the account's first. Returns its id. */ + suspend fun create(accountId: String, name: String, html: String): String { + val id = UUID.randomUUID().toString() + val isFirst = dao.countForAccount(accountId) == 0 + dao.upsert(SignatureEntity(id, accountId, name, html, isDefault = isFirst)) + return id + } + + suspend fun update(id: String, name: String, html: String) { + val existing = dao.getById(id) ?: return + dao.upsert(existing.copy(name = name, contentHtml = html)) + } + + suspend fun delete(id: String) { + val existing = dao.getById(id) ?: return + dao.delete(id) + // If we removed the default, promote the account's first remaining signature. + if (existing.isDefault) { + dao.firstForAccount(existing.accountId)?.let { dao.markDefault(it.id) } + } + } + + suspend fun setDefault(accountId: String, id: String) = dao.setDefault(accountId, id) + + private fun SignatureEntity.toDomain() = Signature(id, accountId, name, contentHtml, isDefault) +} diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt index 840182c..1af8bee 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -56,6 +56,7 @@ class SendWorker @AssistedInject constructor( cc = entity.ccAddresses, subject = entity.subject, body = entity.body, + bodyHtml = entity.bodyHtml, ) val files = orderedAttachments(attachmentDir) if (account.authType == AuthType.OAUTH_OUTLOOK) { diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index 8e93ea3..68e8a01 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -21,6 +21,7 @@ import org.libremail.data.local.MIGRATION_5_6 import org.libremail.data.local.MIGRATION_6_7 import org.libremail.data.local.MIGRATION_7_8 import org.libremail.data.local.MIGRATION_8_9 +import org.libremail.data.local.MIGRATION_9_10 import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.AccountSettingsDao import org.libremail.data.local.dao.AttachmentDao @@ -29,6 +30,7 @@ import org.libremail.data.local.dao.DraftDao import org.libremail.data.local.dao.FolderDao import org.libremail.data.local.dao.MessageDao import org.libremail.data.local.dao.OutboxDao +import org.libremail.data.local.dao.SignatureDao import org.libremail.data.security.DatabaseKeyStore import org.libremail.data.settings.SettingsRepository import javax.inject.Singleton @@ -54,6 +56,7 @@ object DatabaseModule { MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, + MIGRATION_9_10, ) // No destructive fallback: the migration chain is complete, and silently dropping the // accounts/credentials/mail tables would lose stored secrets. A missing migration should @@ -100,5 +103,8 @@ object DatabaseModule { @Provides fun provideFolderDao(database: LibreMailDatabase): FolderDao = database.folderDao() + @Provides + fun provideSignatureDao(database: LibreMailDatabase): SignatureDao = database.signatureDao() + private const val DB_NAME = "libremail.db" } diff --git a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt index e6cecd9..29e1701 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/Draft.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/Draft.kt @@ -9,5 +9,7 @@ data class Draft( val subject: String, val body: String, val updatedAt: Long, + /** HTML form of [body] when the draft was composed with formatting; null for plaintext drafts. */ + val bodyHtml: String? = null, val attachments: List = emptyList(), ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt index bfa25bb..89104de 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/OutboxMessage.kt @@ -9,4 +9,6 @@ data class OutboxMessage( val body: String, val createdAt: Long, val lastError: String?, + /** HTML form of [body] when composed with formatting; null for plaintext messages. */ + val bodyHtml: String? = null, ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt index 559720d..e8b70d6 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt @@ -1,13 +1,20 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.domain.model -/** A message the user is sending. [to]/[cc] are comma-separated address lists. */ +/** + * A message the user is sending. [to]/[cc] are comma-separated address lists. + * + * [body] is always the plain-text form. [bodyHtml] carries the HTML form when the message was + * composed with formatting; when it is null the message is sent as `text/plain` only (unchanged + * from the plaintext-only path), otherwise as `multipart/alternative` with both parts. + */ data class OutgoingMessage( val accountId: String, val to: String, val cc: String = "", val subject: String, val body: String, + val bodyHtml: String? = null, val attachments: List = emptyList(), ) diff --git a/app/src/main/kotlin/org/libremail/domain/model/Signature.kt b/app/src/main/kotlin/org/libremail/domain/model/Signature.kt new file mode 100644 index 0000000..2537fab --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/Signature.kt @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +import org.libremail.mail.HtmlToText + +/** + * A named, rich (HTML) email signature belonging to an account. Each account may have several; at + * most one is its [isDefault], which is the one auto-inserted when composing from that account. + */ +data class Signature( + val id: String, + val accountId: String, + val name: String, + /** The signature body as HTML (authored in the same rich editor as the message body). */ + val html: String, + val isDefault: Boolean = false, +) { + /** The signature as plain text, for the `text/plain` alternative and plaintext-only composing. */ + fun plainText(): String = HtmlToText.convert(html) +} diff --git a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt index b583df4..6d42818 100644 --- a/app/src/main/kotlin/org/libremail/mail/GraphSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/GraphSender.kt @@ -84,9 +84,16 @@ class GraphSender @Inject constructor() { /** Builds the Graph `sendMail` JSON body (a pure function, so it is unit-testable without a network). */ internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List): String { + // Graph carries a single body object: send HTML when the message was formatted (Outlook renders + // it and derives its own plaintext), otherwise plain text so unformatted mail is unchanged. + val body = if (message.bodyHtml != null) { + JSONObject().put("contentType", "HTML").put("content", message.bodyHtml) + } else { + JSONObject().put("contentType", "Text").put("content", message.body) + } val mail = JSONObject() .put("subject", message.subject) - .put("body", JSONObject().put("contentType", "Text").put("content", message.body)) + .put("body", body) .put("toRecipients", recipientsJson(message.to)) if (message.cc.isNotBlank()) { mail.put("ccRecipients", recipientsJson(message.cc)) diff --git a/app/src/main/kotlin/org/libremail/mail/HtmlToText.kt b/app/src/main/kotlin/org/libremail/mail/HtmlToText.kt new file mode 100644 index 0000000..b61f3d5 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/HtmlToText.kt @@ -0,0 +1,45 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +/** + * Converts an HTML body into readable plain text for the `text/plain` alternative part and for + * quoting HTML originals in replies/forwards. Pure (no Android/Compose), so it is unit-testable on + * the JVM. + * + * It is intentionally lightweight — block elements become line breaks, list items gain a "• " + * marker, tags are dropped and entities decoded — rather than a full HTML renderer. The goal is a + * legible fallback, not a faithful reproduction. + */ +object HtmlToText { + + private val BLOCK_BREAK = Regex( + "(?i)]*>|", + ) + private val LIST_ITEM = Regex("(?i)]*>") + + fun convert(html: String): String { + var s = html + // Drop script/style contents outright so their text never leaks into the output. + s = s.replace(Regex("(?is)<(script|style)\\b[^>]*>.*?"), "") + s = LIST_ITEM.replace(s, "\n• ") + s = BLOCK_BREAK.replace(s, "\n") + s = s.replace(Regex("<[^>]*>"), "") + s = decodeEntities(s) + // Collapse runs of spaces/tabs, then trim trailing spaces and cap consecutive blank lines. + s = s.replace(Regex("[ \\t]+"), " ") + s = s.lineSequence().joinToString("\n") { it.trim() } + s = s.replace(Regex("\n{3,}"), "\n\n") + return s.trim() + } + + private fun decodeEntities(s: String): String = s + .replace(" ", " ") + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace("—", "—") + .replace("–", "–") + .replace("&", "&") +} diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt index b034896..f243029 100644 --- a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -7,6 +7,7 @@ import jakarta.mail.internet.InternetAddress import jakarta.mail.internet.MimeBodyPart import jakarta.mail.internet.MimeMessage import jakarta.mail.internet.MimeMultipart +import jakarta.mail.internet.MimePart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.libremail.domain.model.MailSecurity @@ -62,16 +63,7 @@ class SmtpSender @Inject constructor() { subject = message.subject sentDate = Date() } - if (attachments.isEmpty()) { - mime.setText(message.body, "UTF-8") - } else { - val multipart = MimeMultipart() - multipart.addBodyPart(MimeBodyPart().apply { setText(message.body, "UTF-8") }) - attachments.forEach { file -> - multipart.addBodyPart(MimeBodyPart().apply { attachFile(file) }) - } - mime.setContent(multipart) - } + applyBody(mime, message, attachments) val transport = session.getTransport(protocol) transport.connect(params.host, params.port, params.username, params.secret) @@ -82,6 +74,40 @@ class SmtpSender @Inject constructor() { } } + /** + * Sets the message body: + * - plaintext-only ([OutgoingMessage.bodyHtml] null): a single `text/plain` part, exactly as + * before, so unformatted mail is unchanged on the wire; + * - formatted: a `multipart/alternative` of `text/plain` (fallback, first) + `text/html` + * (preferred, last, per RFC 2046). + * When there are attachments the body is nested inside a `multipart/mixed` as its first part. + */ + private fun applyBody(mime: MimeMessage, message: OutgoingMessage, attachments: List) { + if (attachments.isEmpty()) { + setBody(mime, message) + return + } + val mixed = MimeMultipart("mixed") + mixed.addBodyPart(MimeBodyPart().also { setBody(it, message) }) + attachments.forEach { file -> mixed.addBodyPart(MimeBodyPart().apply { attachFile(file) }) } + mime.setContent(mixed) + } + + /** Writes the body onto [part]: plain text, or a text/plain + text/html alternative when formatted. */ + private fun setBody(part: MimePart, message: OutgoingMessage) { + val html = message.bodyHtml + if (html == null) { + part.setText(message.body, "UTF-8") + } else { + part.setContent(alternative(message.body, html)) + } + } + + private fun alternative(plain: String, html: String): MimeMultipart = MimeMultipart("alternative").apply { + addBodyPart(MimeBodyPart().apply { setText(plain, "UTF-8") }) + addBodyPart(MimeBodyPart().apply { setContent(html, "text/html; charset=UTF-8") }) + } + private companion object { const val TIMEOUT_MS = "15000" } diff --git a/app/src/main/kotlin/org/libremail/richtext/RichText.kt b/app/src/main/kotlin/org/libremail/richtext/RichText.kt new file mode 100644 index 0000000..923ee13 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/richtext/RichText.kt @@ -0,0 +1,335 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.richtext + +/** Inline character styles the compose editor supports. */ +enum class RichStyle { BOLD, ITALIC, UNDERLINE } + +/** A run of [style] over the half-open range [[start], [end]) of the plain text. */ +data class RichSpan(val start: Int, val end: Int, val style: RichStyle) + +/** A hyperlink over the half-open range [[start], [end]) pointing at [url]. */ +data class RichLink(val start: Int, val end: Int, val url: String) + +/** + * The compose editor's internal rich-text model: plain [text] plus inline [spans] and [links]. + * + * Block structure (unordered/ordered lists and block quotes) is encoded as recognizable line + * prefixes inside [text] — "• " for bullets, "N. " for numbered items, and "> " for quotes — so + * the editor can stay a single plain-text field (keeping the plaintext-only experience smooth) + * while the model still round-trips cleanly to real HTML and to a readable plaintext fallback. + */ +data class RichTextContent( + val text: String = "", + val spans: List = emptyList(), + val links: List = emptyList(), +) { + val isBlank: Boolean get() = text.isBlank() + + /** + * True when the content carries anything a plaintext field could not represent: inline styling, + * a link, or a block marker. When false, callers should send/persist plaintext only so an + * unformatted message stays byte-for-byte identical to the old plaintext-only path. + */ + fun hasFormatting(): Boolean = + spans.isNotEmpty() || links.isNotEmpty() || text.lineSequence().any { lineMarker(it) != null } +} + +/** Recognized block markers and the tags they map to. */ +internal const val BULLET_PREFIX = "• " +internal const val QUOTE_PREFIX = "> " +private val ORDERED_PREFIX = Regex("^\\d+\\. ") + +private enum class Kind { PARAGRAPH, BULLET, ORDERED, QUOTE } + +private data class Line(val kind: Kind, val contentStart: Int, val contentEnd: Int) + +/** The block marker prefixing [line], or null for an ordinary paragraph line. */ +internal fun lineMarker(line: String): String? = when { + line.startsWith(BULLET_PREFIX) -> BULLET_PREFIX + line.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX + else -> ORDERED_PREFIX.find(line)?.value +} + +/** + * Serializes [RichTextContent] to a small, email-safe HTML subset and back. Pure (no Android or + * Compose types), so the whole conversion is unit-testable on the JVM. + * + * The emitted subset — `
    /
      /

    1. ` — is deliberately narrow + * so [fromHtml] is a faithful inverse for anything [toHtml] produces (drafts round-trip losslessly). + */ +object RichTextHtml { + + fun toHtml(content: RichTextContent): String { + if (content.text.isEmpty()) return "" + val lines = classify(content.text) + val sb = StringBuilder() + var i = 0 + while (i < lines.size) { + i = when (lines[i].kind) { + Kind.BULLET -> emitList(sb, content, lines, i, Kind.BULLET, "ul") + Kind.ORDERED -> emitList(sb, content, lines, i, Kind.ORDERED, "ol") + Kind.QUOTE -> emitQuote(sb, content, lines, i) + Kind.PARAGRAPH -> emitParagraph(sb, content, lines, i) + } + } + return sb.toString() + } + + /** A readable plaintext rendering — the model's [RichTextContent.text] already carries markers. */ + fun toPlainText(content: RichTextContent): String = content.text + + fun fromHtml(html: String): RichTextContent = HtmlToRichParser(html).parse() +} + +private fun classify(text: String): List { + val lines = ArrayList() + var start = 0 + while (true) { + val nl = text.indexOf('\n', start) + val end = if (nl == -1) text.length else nl + val marker = lineMarker(text.substring(start, end)) + val kind = when (marker) { + BULLET_PREFIX -> Kind.BULLET + QUOTE_PREFIX -> Kind.QUOTE + null -> Kind.PARAGRAPH + else -> Kind.ORDERED + } + lines.add(Line(kind, start + (marker?.length ?: 0), end)) + if (nl == -1) break + start = nl + 1 + } + return lines +} + +private fun emitList( + sb: StringBuilder, + content: RichTextContent, + lines: List, + from: Int, + kind: Kind, + tag: String, +): Int { + sb.append("<").append(tag).append(">") + var i = from + while (i < lines.size && lines[i].kind == kind) { + sb.append("
    2. ").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("
    3. ") + i++ + } + sb.append("") + return i +} + +private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List, from: Int): Int { + sb.append("
      ") + var i = from + while (i < lines.size && lines[i].kind == Kind.QUOTE) { + if (i > from) sb.append("
      ") + sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)) + i++ + } + sb.append("
      ") + return i +} + +private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List, from: Int): Int { + sb.append("

      ") + var i = from + while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) { + if (i > from) sb.append("
      ") + sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)) + i++ + } + sb.append("

      ") + return i +} + +/** Renders the inline styling/links over [[from], [to]) as nested `
      ///` tags. */ +private fun renderInline(content: RichTextContent, from: Int, to: Int): String { + if (from >= to) return "" + val points = cutPoints(content, from, to) + val sb = StringBuilder() + for (idx in 0 until points.size - 1) { + val a = points[idx] + val b = points[idx + 1] + if (a < b) appendRun(sb, content, a, b) + } + return sb.toString() +} + +/** The sorted set of offsets where a span or link starts/ends within [[from], [to]]. */ +private fun cutPoints(content: RichTextContent, from: Int, to: Int): List { + val cuts = sortedSetOf(from, to) + fun add(start: Int, end: Int) { + if (end > from && start < to) { + cuts.add(start.coerceIn(from, to)) + cuts.add(end.coerceIn(from, to)) + } + } + content.spans.forEach { add(it.start, it.end) } + content.links.forEach { add(it.start, it.end) } + return cuts.toList() +} + +/** Emits one constant-styling run [[a], [b]) with fully-closed tags, so nesting is always valid. */ +private fun appendRun(sb: StringBuilder, content: RichTextContent, a: Int, b: Int) { + val styles = content.spans.filter { it.start <= a && b <= it.end }.map { it.style }.toSet() + val link = content.links.firstOrNull { it.start <= a && b <= it.end } + if (link != null) sb.append("") + if (RichStyle.BOLD in styles) sb.append("") + if (RichStyle.ITALIC in styles) sb.append("") + if (RichStyle.UNDERLINE in styles) sb.append("") + sb.append(escape(content.text.substring(a, b))) + if (RichStyle.UNDERLINE in styles) sb.append("") + if (RichStyle.ITALIC in styles) sb.append("") + if (RichStyle.BOLD in styles) sb.append("") + if (link != null) sb.append("") +} + +/** + * Parses the narrow HTML subset [toHtml][RichTextHtml.toHtml] emits (plus `strong`/`em` and + * pretty-printer whitespace) back into a [RichTextContent]. A small state machine keeps the nesting + * shallow: [handleTag] dispatches to one-liner helpers and [handleText] appends decoded text. + */ +private class HtmlToRichParser(private val html: String) { + private val text = StringBuilder() + private val spans = ArrayList() + private val links = ArrayList() + private var boldStart = -1 + private var italicStart = -1 + private var underlineStart = -1 + private var linkStart = -1 + private var linkUrl = "" + private var listType: Char? = null + private var olCount = 0 + private var inQuote = false + + fun parse(): RichTextContent { + var i = 0 + while (i < html.length) { + if (html[i] == '<') { + val gt = html.indexOf('>', i) + if (gt == -1) break + handleTag(html.substring(i + 1, gt).trim()) + i = gt + 1 + } else { + val lt = html.indexOf('<', i) + val end = if (lt == -1) html.length else lt + handleText(html.substring(i, end)) + i = end + } + } + return finish() + } + + private fun atLineStart() = text.isEmpty() || text.last() == '\n' + + private fun newlineIfNeeded() { + if (!atLineStart()) text.append('\n') + } + + private fun handleTag(raw: String) { + val closing = raw.startsWith("/") + val body = raw.removePrefix("/").trim() + when (body.substringBefore(' ').substringBefore('/').lowercase()) { + "br" -> { + text.append('\n') + if (inQuote) text.append(QUOTE_PREFIX) + } + "b", "strong" -> boldStart = toggle(closing, boldStart, RichStyle.BOLD) + "i", "em" -> italicStart = toggle(closing, italicStart, RichStyle.ITALIC) + "u" -> underlineStart = toggle(closing, underlineStart, RichStyle.UNDERLINE) + "a" -> handleAnchor(closing, body) + "ul" -> handleList(closing, 'u') + "ol" -> handleList(closing, 'o') + "li" -> if (!closing) startListItem() + "blockquote" -> handleQuote(closing) + "p", "div" -> newlineIfNeeded() + else -> Unit + } + } + + /** Opens a style (returns the current offset) or closes it (records the span, returns -1). */ + private fun toggle(closing: Boolean, openOffset: Int, style: RichStyle): Int { + if (!closing) return text.length + if (openOffset >= 0) spans.add(RichSpan(openOffset, text.length, style)) + return -1 + } + + private fun handleAnchor(closing: Boolean, body: String) { + if (closing) { + if (linkStart >= 0) { + links.add(RichLink(linkStart, text.length, linkUrl)) + linkStart = -1 + linkUrl = "" + } + } else { + linkStart = text.length + linkUrl = extractHref(body) + } + } + + private fun handleList(closing: Boolean, type: Char) { + if (closing) { + listType = null + } else { + listType = type + if (type == 'o') olCount = 0 + } + newlineIfNeeded() + } + + private fun startListItem() { + newlineIfNeeded() + if (listType == 'o') { + olCount++ + text.append("$olCount. ") + } else { + text.append(BULLET_PREFIX) + } + } + + private fun handleQuote(closing: Boolean) { + newlineIfNeeded() + inQuote = !closing + if (!closing) text.append(QUOTE_PREFIX) + } + + private fun handleText(chunk: String) { + // Drop the insignificant whitespace a pretty-printer leaves between block tags (blank runs at + // a line start, or any blank run with a newline), but keep a real space between inline runs. + if (!(chunk.isBlank() && (atLineStart() || chunk.contains('\n')))) text.append(unescape(chunk)) + } + + private fun finish(): RichTextContent { + val out = text.toString().trimEnd('\n') + val len = out.length + if (boldStart in 0 until len) spans.add(RichSpan(boldStart, len, RichStyle.BOLD)) + if (italicStart in 0 until len) spans.add(RichSpan(italicStart, len, RichStyle.ITALIC)) + if (underlineStart in 0 until len) spans.add(RichSpan(underlineStart, len, RichStyle.UNDERLINE)) + if (linkStart in 0 until len) links.add(RichLink(linkStart, len, linkUrl)) + return RichTextContent( + text = out, + spans = spans.filter { it.end <= len && it.start < it.end }, + links = links.filter { it.end <= len && it.start < it.end }, + ) + } +} + +private fun extractHref(tagBody: String): String { + val match = Regex("href\\s*=\\s*(?:\"([^\"]*)\"|'([^']*)')", RegexOption.IGNORE_CASE).find(tagBody) ?: return "" + val (doubleQuoted, singleQuoted) = match.destructured + return unescape(doubleQuoted.ifEmpty { singleQuoted }) +} + +private fun escape(s: String): String = s.replace("&", "&").replace("<", "<").replace(">", ">") + +private fun escapeAttr(s: String): String = escape(s).replace("\"", """) + +private fun unescape(s: String): String = s + .replace("<", "<") + .replace(">", ">") + .replace(""", "\"") + .replace("'", "'") + .replace("'", "'") + .replace(" ", " ") + .replace("&", "&") diff --git a/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt b/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt new file mode 100644 index 0000000..0e7a149 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/richtext/RichTextEditing.kt @@ -0,0 +1,200 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.richtext + +/** A block-level marker the toolbar can toggle over the selected lines. */ +enum class BlockMarker { BULLET, ORDERED, QUOTE } + +/** The result of an editing op: the new [content] and where the selection should land. */ +data class EditResult(val content: RichTextContent, val selectionStart: Int, val selectionEnd: Int) + +/** + * Pure editing operations over [RichTextContent] — toggling inline styles/links and block markers — + * shared by the Compose editor's toolbar. Keeping them free of Compose types makes the tricky index + * bookkeeping (splitting spans, shifting offsets when a line marker is inserted/removed) unit-testable + * on the JVM. + */ +object RichTextEditing { + + /** Adds [style] over [[start], [end]) if it is not already fully styled, otherwise removes it. */ + fun toggleStyle(content: RichTextContent, start: Int, end: Int, style: RichStyle): RichTextContent { + if (start >= end) return content + val others = content.spans.filter { it.style != style } + val same = content.spans.filter { it.style == style } + val updated = if (isFullyStyled(same, start, end)) { + subtractRange(same, start, end) + } else { + mergeSameStyle(same + RichSpan(start, end, style)) + } + return content.copy(spans = (others + updated).sortedBy { it.start }) + } + + /** Links [[start], [end]) to [url], replacing any links that overlap the range. */ + fun applyLink(content: RichTextContent, start: Int, end: Int, url: String): RichTextContent { + if (start >= end || url.isBlank()) return content + val kept = content.links.filter { it.end <= start || it.start >= end } + return content.copy(links = (kept + RichLink(start, end, url)).sortedBy { it.start }) + } + + /** Removes any links overlapping [[start], [end]). */ + fun removeLink(content: RichTextContent, start: Int, end: Int): RichTextContent { + if (start >= end) return content + return content.copy(links = content.links.filter { it.end <= start || it.start >= end }) + } + + /** Whether [[start], [end]) is entirely covered by [style] (drives the toolbar's toggle state). */ + fun isStyled(content: RichTextContent, start: Int, end: Int, style: RichStyle): Boolean = + start < end && isFullyStyled(content.spans.filter { it.style == style }, start, end) + + /** Whether every line the selection touches carries [marker]. */ + fun hasBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): Boolean { + val lineStarts = lineStartsTouching(content.text, start, end) + return lineStarts.isNotEmpty() && lineStarts.all { markerAt(content.text, it) == marker } + } + + /** + * Toggles [marker] across every line the selection touches: if all those lines already carry it, + * it is removed; otherwise it is applied (replacing any other block marker already there). Spans, + * links and the selection are shifted to track the inserted/removed prefixes. + */ + fun toggleBlock(content: RichTextContent, start: Int, end: Int, marker: BlockMarker): EditResult { + val text = content.text + val lineStarts = lineStartsTouching(text, start, end) + val allHaveMarker = lineStarts.all { markerAt(text, it) == marker } + val edits = ArrayList() + var ordinal = 1 + for (lineStart in lineStarts) { + val existing = markerLengthAt(text, lineStart) + when { + allHaveMarker && existing > 0 -> edits.add(LineEdit(lineStart, existing, "")) + allHaveMarker -> Unit + else -> edits.add(LineEdit(lineStart, existing, insertFor(marker, ordinal++))) + } + } + val (newText, remap) = applyEdits(text, edits) + val newSpans = content.spans.mapNotNull { remapSpan(it, remap) } + val newLinks = content.links.mapNotNull { remapLink(it, remap) } + return EditResult(RichTextContent(newText, newSpans, newLinks), remap(start), remap(end)) + } +} + +private fun insertFor(marker: BlockMarker, ordinal: Int): String = when (marker) { + BlockMarker.BULLET -> BULLET_PREFIX + BlockMarker.QUOTE -> QUOTE_PREFIX + BlockMarker.ORDERED -> "$ordinal. " +} + +private fun remapSpan(span: RichSpan, remap: (Int) -> Int): RichSpan? { + val s = remap(span.start) + val e = remap(span.end) + return if (s < e) RichSpan(s, e, span.style) else null +} + +private fun remapLink(link: RichLink, remap: (Int) -> Int): RichLink? { + val s = remap(link.start) + val e = remap(link.end) + return if (s < e) RichLink(s, e, link.url) else null +} + +// --- inline style helpers --- + +private fun isFullyStyled(spans: List, start: Int, end: Int): Boolean { + var pos = start + for (span in spans.filter { it.end > start && it.start < end }.sortedBy { it.start }) { + if (span.start > pos) return false + pos = maxOf(pos, span.end) + if (pos >= end) return true + } + return pos >= end +} + +private fun subtractRange(spans: List, start: Int, end: Int): List = spans.flatMap { span -> + when { + span.end <= start || span.start >= end -> listOf(span) + else -> buildList { + if (span.start < start) add(span.copy(end = start)) + if (span.end > end) add(span.copy(start = end)) + } + } +} + +private fun mergeSameStyle(spans: List): List { + val merged = ArrayList() + for (span in spans.sortedBy { it.start }) { + val last = merged.lastOrNull() + if (last != null && span.start <= last.end) { + merged[merged.size - 1] = last.copy(end = maxOf(last.end, span.end)) + } else { + merged.add(span) + } + } + return merged +} + +// --- block marker helpers --- + +private val ORDERED = Regex("^\\d+\\. ") + +private fun markerAt(text: String, lineStart: Int): BlockMarker? { + val rest = text.substring(lineStart) + return when { + rest.startsWith(BULLET_PREFIX) -> BlockMarker.BULLET + rest.startsWith(QUOTE_PREFIX) -> BlockMarker.QUOTE + // ORDERED is anchored at ^, so find() matches only when this line starts with "N. ". + ORDERED.find(rest) != null -> BlockMarker.ORDERED + else -> null + } +} + +private fun markerLengthAt(text: String, lineStart: Int): Int { + val rest = text.substring(lineStart) + return when { + rest.startsWith(BULLET_PREFIX) -> BULLET_PREFIX.length + rest.startsWith(QUOTE_PREFIX) -> QUOTE_PREFIX.length + else -> ORDERED.find(rest)?.value?.length ?: 0 + } +} + +/** Start offsets of every line the range [[start], [end]] intersects (a caret counts as its line). */ +private fun lineStartsTouching(text: String, start: Int, end: Int): List { + val from = start.coerceIn(0, text.length) + val to = end.coerceIn(from, text.length) + val result = ArrayList() + var lineStart = if (from == 0) 0 else text.lastIndexOf('\n', from - 1).let { if (it == -1) 0 else it + 1 } + while (lineStart <= text.length) { + result.add(lineStart) + val nl = text.indexOf('\n', lineStart) + if (nl == -1 || nl >= to) break + lineStart = nl + 1 + } + return result +} + +private data class LineEdit(val offset: Int, val deleteLen: Int, val insert: String) + +/** Applies line-start [edits] (ascending, non-overlapping) and returns the new text + an index remap. */ +private fun applyEdits(text: String, edits: List): Pair Int> { + if (edits.isEmpty()) return text to { it } + val sorted = edits.sortedBy { it.offset } + val sb = StringBuilder() + var cursor = 0 + for (edit in sorted) { + sb.append(text, cursor, edit.offset) + sb.append(edit.insert) + cursor = edit.offset + edit.deleteLen + } + sb.append(text, cursor, text.length) + val remap: (Int) -> Int = { index -> remapIndex(index, sorted).coerceIn(0, sb.length) } + return sb.toString() to remap +} + +private fun remapIndex(index: Int, edits: List): Int { + var delta = 0 + for (edit in edits) { + val delEnd = edit.offset + edit.deleteLen + when { + delEnd <= index -> delta += edit.insert.length - edit.deleteLen + edit.offset < index -> delta += edit.insert.length - (index - edit.offset) + } + } + return index + delta +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 0301b0f..5ac1e12 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -24,6 +24,8 @@ import org.libremail.ui.outbox.OutboxScreen import org.libremail.ui.reader.ReaderScreen import org.libremail.ui.settings.AccountSettingsScreen import org.libremail.ui.settings.SettingsScreen +import org.libremail.ui.settings.SignatureEditScreen +import org.libremail.ui.settings.SignaturesScreen @Composable fun LibreMailApp() { @@ -86,8 +88,35 @@ fun LibreMailApp() { composable( route = Routes.ACCOUNT_SETTINGS_PATTERN, arguments = listOf(navArgument(Routes.ACCOUNT_SETTINGS_ARG_ID) { type = NavType.StringType }), + ) { backStackEntry -> + val accountId = backStackEntry.arguments?.getString(Routes.ACCOUNT_SETTINGS_ARG_ID).orEmpty() + AccountSettingsScreen( + onBack = navController::popBackStack, + onManageSignatures = { navController.navigate(Routes.signatures(accountId)) }, + ) + } + composable( + route = Routes.SIGNATURES_PATTERN, + arguments = listOf(navArgument(Routes.SIGNATURES_ARG_ACCOUNT) { type = NavType.StringType }), + ) { backStackEntry -> + val accountId = backStackEntry.arguments?.getString(Routes.SIGNATURES_ARG_ACCOUNT).orEmpty() + SignaturesScreen( + onBack = navController::popBackStack, + onEdit = { signatureId -> navController.navigate(Routes.signatureEdit(accountId, signatureId)) }, + onAdd = { navController.navigate(Routes.signatureEdit(accountId)) }, + ) + } + composable( + route = Routes.SIGNATURE_EDIT_PATTERN, + arguments = listOf( + navArgument(Routes.SIGNATURE_EDIT_ARG_ACCOUNT) { type = NavType.StringType }, + navArgument(Routes.SIGNATURE_EDIT_ARG_ID) { + type = NavType.StringType + defaultValue = "" + }, + ), ) { - AccountSettingsScreen(onBack = navController::popBackStack) + SignatureEditScreen(onBack = navController::popBackStack) } composable(Routes.ACCOUNT_SETUP) { AccountSetupScreen( diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 50e748b..9c229d5 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -145,6 +145,7 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode label = { Text(stringResource(R.string.compose_to)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth(), ) if (state.suggestions.isNotEmpty()) { @@ -158,6 +159,7 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode label = { Text(stringResource(R.string.compose_cc)) }, singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth(), ) Spacer(Modifier.height(8.dp)) @@ -166,6 +168,7 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode onValueChange = viewModel::onSubjectChange, label = { Text(stringResource(R.string.compose_subject)) }, singleLine = true, + shape = MaterialTheme.shapes.medium, modifier = Modifier.fillMaxWidth(), ) AttachmentsSection( @@ -174,10 +177,11 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode onRemove = viewModel::removeAttachment, ) Spacer(Modifier.height(8.dp)) - OutlinedTextField( - value = state.body, - onValueChange = viewModel::onBodyChange, - label = { Text(stringResource(R.string.compose_body)) }, + RichTextBodyField( + body = state.body, + bodyHtml = state.bodyHtml, + onBodyChange = viewModel::onBodyChange, + label = stringResource(R.string.compose_body), modifier = Modifier.fillMaxWidth().weight(1f), ) } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index d37ca90..cc6a796 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -18,13 +18,17 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.libremail.contacts.ContactSuggestion import org.libremail.contacts.ContactsRepository +import org.libremail.data.SignatureBlock import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.SignatureRepository import org.libremail.domain.model.Account import org.libremail.domain.model.Draft import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository +import org.libremail.richtext.RichTextContent +import org.libremail.richtext.RichTextHtml import org.libremail.ui.navigation.Routes import java.util.UUID import javax.inject.Inject @@ -33,7 +37,10 @@ data class ComposeUiState( val to: String = "", val cc: String = "", val subject: String = "", + /** The plaintext form of the body (also the `text/plain` fallback when sending). */ val body: String = "", + /** The HTML form of the body, or null when the message carries no formatting (plaintext-only). */ + val bodyHtml: String? = null, val fromAccountId: String? = null, val attachments: List = emptyList(), val suggestions: List = emptyList(), @@ -49,6 +56,7 @@ class ComposeViewModel @Inject constructor( private val accountRepository: AccountRepository, private val contactsRepository: ContactsRepository, private val accountSettingsRepository: AccountSettingsRepository, + private val signatureRepository: SignatureRepository, ) : ViewModel() { private val draftId: String? = @@ -76,7 +84,7 @@ class ComposeViewModel @Inject constructor( @Volatile private var navigated = false /** The signature block last appended to the body, so a From-change can swap it out cleanly. */ - private var appliedSignatureBlock = "" + private var appliedSignatureBlock = SignatureBlock.EMPTY init { if (draftId != null) { @@ -88,6 +96,7 @@ class ComposeViewModel @Inject constructor( cc = draft.cc, subject = draft.subject, body = draft.body, + bodyHtml = draft.bodyHtml, fromAccountId = draft.accountId ?: it.fromAccountId, attachments = draft.attachments, ) @@ -112,27 +121,63 @@ class ComposeViewModel @Inject constructor( fun onCcChange(value: String) = _state.update { it.copy(cc = value) } fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) } - fun onBodyChange(value: String) = _state.update { it.copy(body = value) } + + /** + * The rich editor reports the current body in both forms: [plain] (also the plaintext fallback) + * and [html], which is null when the content carries no formatting so the message stays + * plaintext-only. Both are held for sending and for saving the draft. + */ + fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) } + fun selectFrom(accountId: String) { viewModelScope.launch { applySignature(accountId) } } /** - * Sets the sending account and swaps its signature into the body: strips the previously-appended - * signature block (when the body still ends with it) and appends the newly-selected account's. + * Sets the sending account and swaps its default signature into the body: strips the + * previously-appended block (when the body still ends with it) and appends the newly-selected + * account's, in both the plaintext and HTML representations. Honors the account's + * "append signature" preference. */ private suspend fun applySignature(accountId: String) { - val block = accountSettingsRepository.get(accountId).signatureBlock() + val settings = accountSettingsRepository.get(accountId) + val block = if (settings.signatureEnabled) { + SignatureBlock.of(signatureRepository.getDefault(accountId)) + } else { + SignatureBlock.EMPTY + } _state.update { s -> - val base = if (appliedSignatureBlock.isNotEmpty() && s.body.endsWith(appliedSignatureBlock)) { - s.body.removeSuffix(appliedSignatureBlock) - } else { - s.body - } - s.copy(fromAccountId = accountId, body = base + block) + val basePlain = s.body.stripSuffixIfPresent(appliedSignatureBlock.plain) + val newBody = basePlain + block.plain + s.copy(fromAccountId = accountId, body = newBody, bodyHtml = swapHtmlSignature(s.bodyHtml, newBody, block)) } appliedSignatureBlock = block } + + /** + * Swaps the signature in the HTML body. When the old block is still a clean suffix (the common + * case — the user changed accounts before editing), it is stripped and the new one appended, + * preserving any formatting the user applied. Otherwise the HTML was re-serialized after editing + * and no longer ends with the old block, so it is rebuilt from the plaintext to avoid ever + * duplicating the signature (inline styling from before the switch is not preserved in that case). + */ + private fun swapHtmlSignature(currentHtml: String?, newBody: String, block: SignatureBlock): String? { + val old = appliedSignatureBlock.html + val cleanlyStrippable = old.isEmpty() || currentHtml == null || currentHtml.endsWith(old) + val combined = if (cleanlyStrippable) { + (currentHtml?.removeSuffix(old) ?: "") + block.html + } else { + RichTextHtml.toHtml(RichTextContent(newBody)) + } + return normalizedHtml(combined) + } + + private fun String.stripSuffixIfPresent(suffix: String): String = + if (suffix.isNotEmpty() && endsWith(suffix)) removeSuffix(suffix) else this + + /** Keeps an HTML body only when it actually carries formatting, so plaintext stays plaintext. */ + private fun normalizedHtml(html: String): String? = + if (html.isBlank() || !RichTextHtml.fromHtml(html).hasFormatting()) null else html fun addAttachments(items: List) = _state.update { it.copy(attachments = it.attachments + items) } fun removeAttachment(uri: String) = _state.update { @@ -200,6 +245,7 @@ class ComposeViewModel @Inject constructor( subject = s.subject, body = s.body, updatedAt = System.currentTimeMillis(), + bodyHtml = s.bodyHtml, attachments = s.attachments, ), ) @@ -220,7 +266,15 @@ class ComposeViewModel @Inject constructor( else -> { _state.update { it.copy(sending = true, error = null) } mailRepository.sendMessage( - OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body, s.attachments), + OutgoingMessage( + accountId = account.id, + to = s.to, + cc = s.cc, + subject = s.subject, + body = s.body, + bodyHtml = s.bodyHtml, + attachments = s.attachments, + ), ).fold( onSuccess = { draftId?.let { mailRepository.deleteDraft(it) } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt new file mode 100644 index 0000000..95a51b2 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/compose/RichTextEditor.kt @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.compose + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.unit.dp +import org.libremail.R +import org.libremail.richtext.BlockMarker +import org.libremail.richtext.RichLink +import org.libremail.richtext.RichSpan +import org.libremail.richtext.RichStyle +import org.libremail.richtext.RichTextContent +import org.libremail.richtext.RichTextEditing +import org.libremail.richtext.RichTextHtml + +/** String-annotation tag the editor uses to carry a span's link target inside the [AnnotatedString]. */ +private const val URL_TAG = "libremail:url" + +/** + * A rich-text body editor: a formatting toolbar (bold / italic / underline, bulleted + numbered + * lists, block quote, and link) above a rounded [OutlinedTextField]. It converts its + * [AnnotatedString] to the app's [RichTextContent] model and reports both the plaintext form and its + * HTML — or null HTML when nothing is formatted, so an unformatted message stays plaintext-only and + * feels exactly like the old editor. + * + * The field is a normal Compose text field, so TalkBack, text selection, and large system fonts all + * work as usual; the toolbar buttons carry content descriptions and toggle state for accessibility. + */ +@Composable +fun RichTextBodyField( + body: String, + bodyHtml: String?, + onBodyChange: (plain: String, html: String?) -> Unit, + label: String, + modifier: Modifier = Modifier, +) { + val linkColor = MaterialTheme.colorScheme.primary + var value by remember { mutableStateOf(seedValue(body, bodyHtml, linkColor)) } + // Tracks the (plain, html) we last pushed up, so an external change (draft load / signature swap) + // re-seeds the field but our own emissions do not fight the user's cursor. + var lastEmitted by remember { mutableStateOf(body to bodyHtml) } + + if (body to bodyHtml != lastEmitted) { + value = seedValue(body, bodyHtml, linkColor) + lastEmitted = body to bodyHtml + } + + fun emit(newValue: TextFieldValue) { + value = newValue + val content = newValue.annotatedString.toRichContent() + val html = if (content.hasFormatting()) RichTextHtml.toHtml(content) else null + lastEmitted = content.text to html + onBodyChange(content.text, html) + } + + var showLinkDialog by remember { mutableStateOf(false) } + + Column(modifier) { + FormattingToolbar( + value = value, + onToggleStyle = { style -> emit(applyStyle(value, style, linkColor)) }, + onToggleBlock = { marker -> emit(applyBlock(value, marker, linkColor)) }, + onLink = { showLinkDialog = true }, + ) + OutlinedTextField( + value = value, + onValueChange = ::emit, + label = { Text(label) }, + shape = MaterialTheme.shapes.large, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text), + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } + + if (showLinkDialog) { + val hasSelection = value.selection.min < value.selection.max + LinkDialog( + enabled = hasSelection, + onDismiss = { showLinkDialog = false }, + onConfirm = { url -> + emit(applyLink(value, url, linkColor)) + showLinkDialog = false + }, + ) + } +} + +@Composable +private fun FormattingToolbar( + value: TextFieldValue, + onToggleStyle: (RichStyle) -> Unit, + onToggleBlock: (BlockMarker) -> Unit, + onLink: () -> Unit, +) { + val content = value.annotatedString.toRichContent() + val start = value.selection.min + val end = value.selection.max + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FormatButton( + label = "B", + description = stringResource(R.string.format_bold), + active = RichTextEditing.isStyled(content, start, end, RichStyle.BOLD), + fontWeight = FontWeight.Bold, + onClick = { onToggleStyle(RichStyle.BOLD) }, + ) + FormatButton( + label = "I", + description = stringResource(R.string.format_italic), + active = RichTextEditing.isStyled(content, start, end, RichStyle.ITALIC), + fontStyle = FontStyle.Italic, + onClick = { onToggleStyle(RichStyle.ITALIC) }, + ) + FormatButton( + label = "U", + description = stringResource(R.string.format_underline), + active = RichTextEditing.isStyled(content, start, end, RichStyle.UNDERLINE), + underline = true, + onClick = { onToggleStyle(RichStyle.UNDERLINE) }, + ) + FormatButton( + label = "•", + description = stringResource(R.string.format_bullet_list), + active = RichTextEditing.hasBlock(content, start, end, BlockMarker.BULLET), + onClick = { onToggleBlock(BlockMarker.BULLET) }, + ) + FormatButton( + label = "1.", + description = stringResource(R.string.format_numbered_list), + active = RichTextEditing.hasBlock(content, start, end, BlockMarker.ORDERED), + onClick = { onToggleBlock(BlockMarker.ORDERED) }, + ) + FormatButton( + label = "❝", + description = stringResource(R.string.format_quote), + active = RichTextEditing.hasBlock(content, start, end, BlockMarker.QUOTE), + onClick = { onToggleBlock(BlockMarker.QUOTE) }, + ) + FormatButton( + label = "🔗", + description = stringResource(R.string.format_link), + active = false, + onClick = onLink, + ) + } +} + +@Composable +private fun FormatButton( + label: String, + description: String, + active: Boolean, + onClick: () -> Unit, + fontWeight: FontWeight? = null, + fontStyle: FontStyle? = null, + underline: Boolean = false, +) { + val colors = MaterialTheme.colorScheme + val background = if (active) colors.secondaryContainer else Color.Transparent + val textColor = if (active) colors.onSecondaryContainer else colors.onSurfaceVariant + Box( + modifier = Modifier + .clip(MaterialTheme.shapes.small) + .background(background) + .clickable(onClick = onClick, role = Role.Button, onClickLabel = description) + .padding(horizontal = 12.dp, vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = textColor, + style = LocalTextStyle.current.copy( + fontWeight = fontWeight, + fontStyle = fontStyle, + textDecoration = if (underline) TextDecoration.Underline else null, + ), + ) + } +} + +@Composable +private fun LinkDialog(enabled: Boolean, onDismiss: () -> Unit, onConfirm: (String) -> Unit) { + var url by remember { mutableStateOf("") } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.format_link_title)) }, + text = { + if (enabled) { + OutlinedTextField( + value = url, + onValueChange = { url = it }, + singleLine = true, + label = { Text(stringResource(R.string.format_link_url)) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + modifier = Modifier.fillMaxWidth(), + ) + } else { + Text(stringResource(R.string.format_link_needs_selection)) + } + }, + confirmButton = { + TextButton(onClick = { onConfirm(url.trim()) }, enabled = enabled && url.isNotBlank()) { + Text(stringResource(R.string.format_link_apply)) + } + }, + dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } }, + ) +} + +// --- editor-op plumbing (TextFieldValue <-> RichTextContent) --- + +private fun applyStyle(value: TextFieldValue, style: RichStyle, linkColor: Color): TextFieldValue { + val updated = RichTextEditing.toggleStyle( + value.annotatedString.toRichContent(), + value.selection.min, + value.selection.max, + style, + ) + return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection) +} + +private fun applyBlock(value: TextFieldValue, marker: BlockMarker, linkColor: Color): TextFieldValue { + val result = RichTextEditing.toggleBlock( + value.annotatedString.toRichContent(), + value.selection.min, + value.selection.max, + marker, + ) + return TextFieldValue( + result.content.toAnnotatedString(linkColor), + TextRange(result.selectionStart, result.selectionEnd), + ) +} + +private fun applyLink(value: TextFieldValue, url: String, linkColor: Color): TextFieldValue { + val updated = RichTextEditing.applyLink( + value.annotatedString.toRichContent(), + value.selection.min, + value.selection.max, + url, + ) + return TextFieldValue(updated.toAnnotatedString(linkColor), value.selection) +} + +private fun seedValue(body: String, bodyHtml: String?, linkColor: Color): TextFieldValue { + val content = if (bodyHtml != null) RichTextHtml.fromHtml(bodyHtml) else RichTextContent(body) + val annotated = content.toAnnotatedString(linkColor) + return TextFieldValue(annotated, TextRange(annotated.length)) +} + +/** Maps the app rich-text model onto a Compose [AnnotatedString] for display/editing. */ +internal fun RichTextContent.toAnnotatedString(linkColor: Color): AnnotatedString = buildAnnotatedString { + append(text) + spans.forEach { span -> addStyle(spanStyleFor(span.style), span.start, span.end) } + links.forEach { link -> + addStyle(SpanStyle(color = linkColor), link.start, link.end) + addStringAnnotation(URL_TAG, link.url, link.start, link.end) + } +} + +/** Maps a Compose [AnnotatedString] back to the app model, reading single-attribute span styles. */ +internal fun AnnotatedString.toRichContent(): RichTextContent { + val richSpans = spanStyles.mapNotNull { range -> + styleOf(range.item)?.let { RichSpan(range.start, range.end, it) } + } + val links = getStringAnnotations(URL_TAG, 0, length).map { RichLink(it.start, it.end, it.item) } + return RichTextContent(text, richSpans, links) +} + +private fun spanStyleFor(style: RichStyle): SpanStyle = when (style) { + RichStyle.BOLD -> SpanStyle(fontWeight = FontWeight.Bold) + RichStyle.ITALIC -> SpanStyle(fontStyle = FontStyle.Italic) + RichStyle.UNDERLINE -> SpanStyle(textDecoration = TextDecoration.Underline) +} + +private fun styleOf(span: SpanStyle): RichStyle? = when { + span.fontWeight == FontWeight.Bold -> RichStyle.BOLD + span.fontStyle == FontStyle.Italic -> RichStyle.ITALIC + span.textDecoration == TextDecoration.Underline -> RichStyle.UNDERLINE + else -> null // e.g. the link color span, which is carried by the URL annotation instead +} diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index 5ddb2d5..76ae954 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -20,6 +20,17 @@ object Routes { const val ACCOUNT_SETTINGS_PATTERN = "account_settings/{$ACCOUNT_SETTINGS_ARG_ID}" fun accountSettings(accountId: String) = "account_settings/${Uri.encode(accountId)}" + const val SIGNATURES_ARG_ACCOUNT = "accountId" + const val SIGNATURES_PATTERN = "signatures/{$SIGNATURES_ARG_ACCOUNT}" + fun signatures(accountId: String) = "signatures/${Uri.encode(accountId)}" + + const val SIGNATURE_EDIT_ARG_ACCOUNT = "accountId" + const val SIGNATURE_EDIT_ARG_ID = "signatureId" + const val SIGNATURE_EDIT_PATTERN = + "signature_edit/{$SIGNATURE_EDIT_ARG_ACCOUNT}?$SIGNATURE_EDIT_ARG_ID={$SIGNATURE_EDIT_ARG_ID}" + fun signatureEdit(accountId: String, signatureId: String = ""): String = + "signature_edit/${Uri.encode(accountId)}?$SIGNATURE_EDIT_ARG_ID=${Uri.encode(signatureId)}" + const val COMPOSE_ARG_TO = "to" const val COMPOSE_ARG_SUBJECT = "subject" const val COMPOSE_ARG_FROM = "from" diff --git a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt index 07e1c38..bdb68b5 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsScreen.kt @@ -6,7 +6,6 @@ import android.content.Intent import android.provider.Settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll @@ -17,7 +16,6 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -26,17 +24,21 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewModel = hiltViewModel()) { +fun AccountSettingsScreen( + onBack: () -> Unit, + onManageSignatures: () -> Unit, + viewModel: AccountSettingsViewModel = hiltViewModel(), +) { val account by viewModel.account.collectAsStateWithLifecycle() val settings by viewModel.settings.collectAsStateWithLifecycle() - val signature by viewModel.signature.collectAsStateWithLifecycle() + val signatureCount by viewModel.signatureCount.collectAsStateWithLifecycle() + val defaultSignatureName by viewModel.defaultSignatureName.collectAsStateWithLifecycle() val context = LocalContext.current val fallbackTitle = stringResource(R.string.settings_account_title) @@ -67,15 +69,14 @@ fun AccountSettingsScreen(onBack: () -> Unit, viewModel: AccountSettingsViewMode checked = settings.signatureEnabled, onCheckedChange = viewModel::setSignatureEnabled, ) - OutlinedTextField( - value = signature ?: "", - onValueChange = viewModel::onSignatureChange, - enabled = settings.signatureEnabled, - label = { Text(stringResource(R.string.settings_signature_hint)) }, - minLines = 3, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + ClickRow( + title = stringResource(R.string.settings_signatures_manage), + subtitle = if (signatureCount == 0) { + stringResource(R.string.settings_signatures_summary_none) + } else { + stringResource(R.string.settings_signatures_summary, signatureCount, defaultSignatureName) + }, + onClick = onManageSignatures, ) HorizontalDivider() diff --git a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt index 2436360..733cd74 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/AccountSettingsViewModel.kt @@ -5,15 +5,13 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.SignatureRepository import org.libremail.domain.model.Account import org.libremail.domain.model.AccountSettings import org.libremail.domain.repository.AccountRepository @@ -26,6 +24,7 @@ class AccountSettingsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val accountRepository: AccountRepository, private val accountSettingsRepository: AccountSettingsRepository, + signatureRepository: SignatureRepository, ) : ViewModel() { private val accountId: String = @@ -38,26 +37,20 @@ class AccountSettingsViewModel @Inject constructor( val settings: StateFlow = accountSettingsRepository.observe(accountId) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AccountSettings(accountId)) - // The signature text is edited locally (seeded once from persistence) so the field stays - // responsive — a fully DB-driven value would lag each keystroke and jump the cursor. - private val _signature = MutableStateFlow(null) - val signature: StateFlow = _signature.asStateFlow() + private val signaturesFlow = signatureRepository.observeForAccount(accountId) + + val signatureCount: StateFlow = signaturesFlow + .map { it.size } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), 0) + + /** The name of the account's default signature (for the settings summary line), or "". */ + val defaultSignatureName: StateFlow = signaturesFlow + .map { list -> list.firstOrNull { it.isDefault }?.name ?: list.firstOrNull()?.name ?: "" } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), "") /** This account's notification channel id, for deep-linking into Android's system settings. */ val notificationChannelId: String = MailNotifier.channelId(accountId) - init { - viewModelScope.launch { - val loaded = accountSettingsRepository.get(accountId).signature - _signature.update { it ?: loaded } // don't clobber any text typed before the load returned - } - } - - fun onSignatureChange(value: String) { - _signature.value = value - viewModelScope.launch { accountSettingsRepository.setSignature(accountId, value) } - } - fun setSignatureEnabled(value: Boolean) { viewModelScope.launch { accountSettingsRepository.setSignatureEnabled(accountId, value) } } diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt new file mode 100644 index 0000000..b656ebd --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditScreen.kt @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.libremail.R +import org.libremail.ui.compose.RichTextBodyField + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignatureEditScreen(onBack: () -> Unit, viewModel: SignatureEditViewModel = hiltViewModel()) { + val state by viewModel.state.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + stringResource( + if (viewModel.isNew) R.string.signature_new_title else R.string.signature_edit_title, + ), + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + actions = { + TextButton(onClick = { viewModel.save(onBack) }) { + Text(stringResource(R.string.signature_save)) + } + }, + ) + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .padding(16.dp), + ) { + OutlinedTextField( + value = state.name, + onValueChange = viewModel::onNameChange, + label = { Text(stringResource(R.string.signature_name)) }, + singleLine = true, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + // Reuse the compose editor so signatures are rich in exactly the same way message bodies are. + if (state.loaded) { + RichTextBodyField( + body = state.body, + bodyHtml = state.bodyHtml, + onBodyChange = viewModel::onBodyChange, + label = stringResource(R.string.signature_content), + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt new file mode 100644 index 0000000..3bfe833 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/SignatureEditViewModel.kt @@ -0,0 +1,81 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.data.settings.SignatureRepository +import org.libremail.richtext.RichTextContent +import org.libremail.richtext.RichTextHtml +import org.libremail.ui.navigation.Routes +import javax.inject.Inject + +data class SignatureEditUiState( + val name: String = "", + val body: String = "", + val bodyHtml: String? = null, + val loaded: Boolean = false, +) + +@HiltViewModel +class SignatureEditViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val signatureRepository: SignatureRepository, +) : ViewModel() { + + private val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURE_EDIT_ARG_ACCOUNT]) + private val signatureId: String? = + savedStateHandle.get(Routes.SIGNATURE_EDIT_ARG_ID)?.takeIf { it.isNotBlank() } + + val isNew: Boolean = signatureId == null + + private val _state = MutableStateFlow(SignatureEditUiState(loaded = isNew)) + val state: StateFlow = _state.asStateFlow() + + init { + if (signatureId != null) { + viewModelScope.launch { + signatureRepository.get(signatureId)?.let { signature -> + _state.update { + it.copy( + name = signature.name, + body = signature.plainText(), + bodyHtml = signature.html.ifBlank { null }, + loaded = true, + ) + } + } + } + } + } + + fun onNameChange(value: String) = _state.update { it.copy(name = value) } + + fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) } + + /** Persists the signature (create or update), then invokes [onSaved]. */ + fun save(onSaved: () -> Unit) { + val s = _state.value + val name = s.name.trim().ifBlank { DEFAULT_NAME } + // Store real HTML so the signature round-trips; derive it from the plaintext when unformatted. + val html = s.bodyHtml ?: RichTextHtml.toHtml(RichTextContent(s.body)) + viewModelScope.launch { + if (signatureId == null) { + signatureRepository.create(accountId, name, html) + } else { + signatureRepository.update(signatureId, name, html) + } + onSaved() + } + } + + private companion object { + const val DEFAULT_NAME = "Signature" + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt new file mode 100644 index 0000000..25ef189 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesScreen.kt @@ -0,0 +1,135 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.libremail.R +import org.libremail.domain.model.Signature + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SignaturesScreen( + onBack: () -> Unit, + onEdit: (signatureId: String) -> Unit, + onAdd: () -> Unit, + viewModel: SignaturesViewModel = hiltViewModel(), +) { + val signatures by viewModel.signatures.collectAsStateWithLifecycle() + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.signatures_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton(onClick = onAdd) { + Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.signatures_add)) + } + }, + ) { padding -> + if (signatures.isEmpty()) { + Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) { + Text( + stringResource(R.string.signatures_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn(Modifier.fillMaxSize().padding(padding)) { + items(signatures, key = { it.id }) { signature -> + SignatureRow( + signature = signature, + onClick = { onEdit(signature.id) }, + onMakeDefault = { viewModel.setDefault(signature.id) }, + onDelete = { viewModel.delete(signature.id) }, + ) + } + } + } + } +} + +@Composable +private fun SignatureRow(signature: Signature, onClick: () -> Unit, onMakeDefault: () -> Unit, onDelete: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + RadioButton( + selected = signature.isDefault, + onClick = onMakeDefault, + ) + Column(Modifier.weight(1f)) { + Text( + signature.name.ifBlank { stringResource(R.string.signature_content) }, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val preview = signature.plainText().replace('\n', ' ').trim() + if (preview.isNotEmpty()) { + Text( + preview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (signature.isDefault) { + Text( + stringResource(R.string.signature_default_badge), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + ) + } + } + IconButton(onClick = onDelete) { + Icon(Icons.Filled.Delete, contentDescription = stringResource(R.string.signature_delete)) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt new file mode 100644 index 0000000..baa6b94 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/settings/SignaturesViewModel.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.settings + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.libremail.data.settings.SignatureRepository +import org.libremail.domain.model.Signature +import org.libremail.ui.navigation.Routes +import javax.inject.Inject + +@HiltViewModel +class SignaturesViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val signatureRepository: SignatureRepository, +) : ViewModel() { + + val accountId: String = checkNotNull(savedStateHandle[Routes.SIGNATURES_ARG_ACCOUNT]) + + val signatures: StateFlow> = signatureRepository.observeForAccount(accountId) + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + fun setDefault(id: String) { + viewModelScope.launch { signatureRepository.setDefault(accountId, id) } + } + + fun delete(id: String) { + viewModelScope.launch { signatureRepository.delete(id) } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4df47f..5f5363b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -77,6 +77,19 @@ Attach file Remove attachment + + Bold + Italic + Underline + Bulleted list + Numbered list + Block quote + Insert link + Insert link + Link address + Link + Select some text first, then add a link. + Drafts No drafts @@ -146,8 +159,25 @@ Account Signature - Append signature + Append signature automatically Your signature + Manage signatures + No signatures yet + %1$d signature(s), default: %2$s + + + Signatures + No signatures yet. Add one to append it to your messages. + Add signature + New signature + Edit signature + Name + Signature + Save + Delete signature + Default + Set as default + Default Notifications New mail notifications Show notifications for this account diff --git a/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt index 5475d28..49fc6c2 100644 --- a/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt +++ b/app/src/test/kotlin/org/libremail/data/ReplyBuilderTest.kt @@ -105,4 +105,26 @@ class ReplyBuilderTest { assertTrue(result.body.contains("Hello"), "body=${result.body}") assertTrue(result.body.contains("there"), "body=${result.body}") } + + @Test + fun `html original is quoted into a blockquote without leaking original tags`() { + val result = ReplyBuilder.build( + context(body = "

      Hello there

      ", isHtml = true), + ReplyMode.REPLY, + "me@example.org", + ) + + // The HTML alternative wraps the (tag-stripped) original in a blockquote — never raw tags. + assertTrue(result.bodyHtml.contains("
      "), "html=${result.bodyHtml}") + assertTrue(result.bodyHtml.contains("Hello there"), "html=${result.bodyHtml}") + assertFalse(result.bodyHtml.contains("

      Hello"), "html=${result.bodyHtml}") + } + + @Test + fun `plaintext reply also carries an html blockquote alternative`() { + val result = ReplyBuilder.build(context(body = "First line\nSecond line"), ReplyMode.REPLY, "me@example.org") + + assertTrue(result.bodyHtml.contains("

      "), "html=${result.bodyHtml}") + assertTrue(result.bodyHtml.contains("First line"), "html=${result.bodyHtml}") + } } diff --git a/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt b/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt new file mode 100644 index 0000000..b3c7119 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/SignatureBlockTest.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data + +import org.junit.Test +import org.libremail.domain.model.Signature +import org.libremail.richtext.RichTextHtml +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SignatureBlockTest { + + private fun signature(html: String) = + Signature(id = "s", accountId = "a", name = "Sig", html = html, isDefault = true) + + @Test + fun `plain form opens with the RFC 3676 delimiter`() { + val block = SignatureBlock.of(signature("Cheers, Alice")) + assertEquals("\n\n-- \nCheers, Alice", block.plain) + } + + @Test + fun `html form parses back to exactly the plain form`() { + val block = SignatureBlock.of(signature("Cheers, Alice")) + assertEquals(block.plain, RichTextHtml.fromHtml(block.html).text) + } + + @Test + fun `a rich signature keeps its formatting in the html form`() { + val block = SignatureBlock.of(signature("Cheers, Alice")) + assertTrue(block.html.endsWith("Cheers, Alice"), block.html) + assertEquals("\n\n-- \nCheers, Alice", block.plain) + } + + @Test + fun `null or blank signature yields the empty block`() { + assertTrue(SignatureBlock.of(null).isEmpty) + assertTrue(SignatureBlock.of(signature("")).isEmpty) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt b/app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt new file mode 100644 index 0000000..2e57478 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/local/MappersHtmlBodyTest.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.local + +import org.junit.Test +import org.libremail.data.local.entity.OutboxEntity +import org.libremail.domain.model.Draft +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class MappersHtmlBodyTest { + + @Test + fun `draft html body round-trips through the entity`() { + val draft = Draft( + id = "d1", + accountId = "acct", + to = "a@x.com", + cc = "", + subject = "Hi", + body = "Hello", + updatedAt = 1L, + bodyHtml = "

      Hello there

      ", + ) + + val restored = draft.toEntity().toDomain() + + assertEquals("

      Hello there

      ", restored.bodyHtml) + assertEquals("Hello", restored.body) + } + + @Test + fun `a plaintext draft keeps a null html body`() { + val draft = Draft("d2", "acct", "a@x.com", "", "Hi", "Hello", 1L) + assertNull(draft.toEntity().toDomain().bodyHtml) + } + + @Test + fun `outbox entity maps its html body to the domain`() { + val entity = OutboxEntity( + id = "o1", + accountId = "acct", + toAddresses = "a@x.com", + ccAddresses = "", + subject = "Hi", + body = "Hello", + createdAt = 1L, + bodyHtml = "

      Hi

      ", + ) + + assertEquals("

      Hi

      ", entity.toDomain().bodyHtml) + } +} diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index 56edec2..a40ed20 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -25,6 +25,7 @@ import org.libremail.data.local.entity.FolderEntity import org.libremail.data.local.entity.MessageEntity import org.libremail.data.local.entity.ServerConfigEmbedded import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.SignatureRepository import org.libremail.data.sync.MailConnectionFactory import org.libremail.domain.model.AccountSettings import org.libremail.domain.model.FolderRole @@ -54,6 +55,7 @@ class MailRepositoryImplTest { private val connectionFactory = mockk() private val context = mockk(relaxed = true) private val accountSettingsRepository = mockk() + private val signatureRepository = mockk() private val repository = MailRepositoryImpl( context = context, messageDao = messageDao, @@ -66,6 +68,7 @@ class MailRepositoryImplTest { connectionFactory = connectionFactory, sendScheduler = mockk(), accountSettingsRepository = accountSettingsRepository, + signatureRepository = signatureRepository, ) @Test @@ -202,6 +205,7 @@ class MailRepositoryImplTest { coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") coEvery { accountDao.getById("acct") } returns accountEntity() coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct") + coEvery { signatureRepository.getDefault(any()) } returns null coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() coEvery { imapClient.fetchForReply(any(), "INBOX", "2") } returns ReplyContext( fromEmail = "boss@example.org", @@ -220,6 +224,42 @@ class MailRepositoryImplTest { assertTrue(result.isSuccess) assertEquals("boss@example.org", draft.captured.toAddresses) assertEquals("Re: Plan", draft.captured.subject) + // The reply carries an HTML alternative with the quote rendered as a blockquote. + assertTrue(draft.captured.bodyHtml?.contains("
      ") == true, "html=${draft.captured.bodyHtml}") + } + + @Test + fun `buildReplyDraft bakes the account default signature above the quote`() = runTest { + val id = "acct:INBOX:3" + coEvery { messageDao.getById(id) } returns messageEntity(id, "INBOX") + coEvery { accountDao.getById("acct") } returns accountEntity() + coEvery { accountSettingsRepository.get(any()) } returns AccountSettings("acct") + coEvery { signatureRepository.getDefault("acct") } returns org.libremail.domain.model.Signature( + id = "acct:sig", + accountId = "acct", + name = "Signature", + html = "Regards, Ada", + isDefault = true, + ) + coEvery { connectionFactory.imapParamsFor(any()) } returns imapParams() + coEvery { imapClient.fetchForReply(any(), "INBOX", "3") } returns ReplyContext( + fromEmail = "boss@example.org", + toRecipients = listOf("ada@example.org"), + ccRecipients = emptyList(), + subject = "Plan", + sentDateMillis = 0L, + body = "Original", + isHtml = false, + ) + val draft = slot() + coEvery { draftDao.upsert(capture(draft)) } just Runs + + repository.buildReplyDraft(id, ReplyMode.REPLY) + + val body = draft.captured.body + // Signature is placed before (above) the quoted original. + assertTrue(body.contains("Regards, Ada"), "body=$body") + assertTrue(body.indexOf("Regards, Ada") < body.indexOf("> Original"), "body=$body") } @Test diff --git a/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt b/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt new file mode 100644 index 0000000..b38c0df --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/settings/SignatureRepositoryTest.kt @@ -0,0 +1,74 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.data.settings + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.libremail.data.local.dao.SignatureDao +import org.libremail.data.local.entity.SignatureEntity +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SignatureRepositoryTest { + + private val dao = mockk(relaxed = true) + private val repository = SignatureRepository(dao) + + private fun entity(id: String, isDefault: Boolean) = + SignatureEntity(id, accountId = "acct", name = "N", contentHtml = "

      x

      ", isDefault = isDefault) + + @Test + fun `the first signature for an account becomes its default`() = runTest { + coEvery { dao.countForAccount("acct") } returns 0 + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.create("acct", "Work", "

      hi

      ") + + assertTrue(saved.captured.isDefault) + } + + @Test + fun `later signatures are not made default`() = runTest { + coEvery { dao.countForAccount("acct") } returns 2 + val saved = slot() + coEvery { dao.upsert(capture(saved)) } just Runs + + repository.create("acct", "Personal", "

      hey

      ") + + assertFalse(saved.captured.isDefault) + } + + @Test + fun `deleting the default promotes the first remaining signature`() = runTest { + coEvery { dao.getById("s1") } returns entity("s1", isDefault = true) + coEvery { dao.firstForAccount("acct") } returns entity("s2", isDefault = false) + + repository.delete("s1") + + coVerify { dao.delete("s1") } + coVerify { dao.markDefault("s2") } + } + + @Test + fun `deleting a non-default signature promotes nothing`() = runTest { + coEvery { dao.getById("s2") } returns entity("s2", isDefault = false) + + repository.delete("s2") + + coVerify { dao.delete("s2") } + coVerify(exactly = 0) { dao.firstForAccount(any()) } + coVerify(exactly = 0) { dao.markDefault(any()) } + } + + @Test + fun `setDefault delegates to the dao's atomic swap`() = runTest { + repository.setDefault("acct", "s1") + coVerify { dao.setDefault("acct", "s1") } + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt index 758f3af..0e9a25f 100644 --- a/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/GraphSenderTest.kt @@ -34,6 +34,28 @@ class GraphSenderTest { assertEquals("c@z.com", cc.getJSONObject(0).getJSONObject("emailAddress").getString("address")) } + @Test + fun `payload uses the HTML content type when a formatted body is present`() { + val message = OutgoingMessage( + accountId = "outlook:me@example.com", + to = "a@x.com", + subject = "Hi", + body = "Hello world", + bodyHtml = "

      Hello world

      ", + ) + val body = JSONObject(buildSendMailPayload(message, emptyList())) + .getJSONObject("message").getJSONObject("body") + assertEquals("HTML", body.getString("contentType")) + assertEquals("

      Hello world

      ", body.getString("content")) + } + + @Test + fun `payload falls back to plain text when there is no HTML body`() { + val body = JSONObject(buildSendMailPayload(message(to = "a@x.com"), emptyList())) + .getJSONObject("message").getJSONObject("body") + assertEquals("Text", body.getString("contentType")) + } + @Test fun `recipients parse RFC822 display names into bare addresses`() { val json = JSONObject( diff --git a/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt new file mode 100644 index 0000000..a5e67be --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/HtmlToTextTest.kt @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.mail + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HtmlToTextTest { + + @Test + fun `strips inline tags and keeps the words`() { + assertEquals("Hello world", HtmlToText.convert("

      Hello world

      ")) + } + + @Test + fun `br and paragraphs become line breaks`() { + assertEquals("Line one\nLine two", HtmlToText.convert("Line one
      Line two")) + assertEquals("A\n\nB", HtmlToText.convert("

      A

      B

      ")) + } + + @Test + fun `list items gain bullet markers`() { + assertEquals("• One\n• Two", HtmlToText.convert("
      • One
      • Two
      ")) + } + + @Test + fun `decodes entities and drops script and style content`() { + assertEquals("Tom & Jerry", HtmlToText.convert("Tom & Jerry")) + val converted = HtmlToText.convert("Safe") + assertEquals("Safe", converted) + assertFalse(converted.contains("alert")) + } + + @Test + fun `collapses excess whitespace`() { + val converted = HtmlToText.convert("

      Hello there

      \n\n\n

      bye

      ") + assertTrue(converted.contains("Hello there"), converted) + assertFalse(converted.contains("Hello there"), converted) + } +} diff --git a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt index 32fa157..52fd553 100644 --- a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt @@ -61,6 +61,74 @@ class SmtpSenderTest { assertTrue(GreenMailUtil.getBody(received[0]).contains("Hello there")) } + @Test + fun `a formatted message is sent as multipart alternative with both parts`() = runTest { + val params = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "sender@example.org", + secret = "secret", + useXoauth2 = false, + ) + + sender.send( + params = params, + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + subject = "Rich", + body = "Hello world", + bodyHtml = "

      Hello world

      ", + ), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages.single() + assertTrue(received.contentType.contains("multipart/alternative", ignoreCase = true), received.contentType) + val raw = GreenMailUtil.getWholeMessage(received) + assertTrue(raw.contains("text/plain", ignoreCase = true), "missing text/plain") + assertTrue(raw.contains("text/html", ignoreCase = true), "missing text/html") + assertTrue(raw.contains("world"), "missing html body") + assertTrue(raw.contains("Hello world"), "missing plaintext fallback") + } + + @Test + fun `a formatted message with an attachment nests the alternative inside mixed`() = runTest { + val file = File.createTempFile("libremail-note", ".txt").apply { writeText("attached note") } + val params = SmtpParams( + host = "127.0.0.1", + port = greenMail.smtp.port, + security = MailSecurity.NONE, + username = "sender@example.org", + secret = "secret", + useXoauth2 = false, + ) + + sender.send( + params = params, + from = "sender@example.org", + message = OutgoingMessage( + accountId = "x", + to = "bob@example.org", + subject = "Rich + file", + body = "Body", + bodyHtml = "

      Body

      ", + ), + attachments = listOf(file), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages.single() + assertTrue(received.contentType.contains("multipart/mixed", ignoreCase = true), received.contentType) + val raw = GreenMailUtil.getWholeMessage(received) + assertTrue(raw.contains("multipart/alternative", ignoreCase = true), "missing alternative part") + assertTrue(raw.contains(file.name), "missing attachment") + assertTrue(raw.contains("Body"), "missing html body") + file.delete() + } + @Test fun `send delivers a message with an attachment`() = runTest { val file = File.createTempFile("libremail-report", ".txt").apply { writeText("quarterly numbers") } diff --git a/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt b/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt new file mode 100644 index 0000000..f0cb15b --- /dev/null +++ b/app/src/test/kotlin/org/libremail/richtext/RichTextEditingTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.richtext + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RichTextEditingTest { + + @Test + fun `toggleStyle adds then removes a style over the selection`() { + val base = RichTextContent("hello") + val bold = RichTextEditing.toggleStyle(base, 0, 5, RichStyle.BOLD) + assertEquals(listOf(RichSpan(0, 5, RichStyle.BOLD)), bold.spans) + + val plain = RichTextEditing.toggleStyle(bold, 0, 5, RichStyle.BOLD) + assertTrue(plain.spans.isEmpty()) + } + + @Test + fun `toggleStyle over a fully styled sub-range removes just that part`() { + val bold = RichTextContent("hello", spans = listOf(RichSpan(0, 5, RichStyle.BOLD))) + val result = RichTextEditing.toggleStyle(bold, 1, 3, RichStyle.BOLD) + assertEquals( + listOf(RichSpan(0, 1, RichStyle.BOLD), RichSpan(3, 5, RichStyle.BOLD)), + result.spans.sortedBy { it.start }, + ) + } + + @Test + fun `applyLink links the selection and removes overlapping links`() { + val base = RichTextContent("see here") + val linked = RichTextEditing.applyLink(base, 4, 8, "http://x") + assertEquals(listOf(RichLink(4, 8, "http://x")), linked.links) + + val relinked = RichTextEditing.applyLink(linked, 0, 8, "http://y") + assertEquals(listOf(RichLink(0, 8, "http://y")), relinked.links) + } + + @Test + fun `toggleBlock adds a bullet marker to the caret's line and shifts spans`() { + val base = RichTextContent("ab", spans = listOf(RichSpan(0, 2, RichStyle.BOLD))) + val result = RichTextEditing.toggleBlock(base, 0, 0, BlockMarker.BULLET) + assertEquals("• ab", result.content.text) + assertEquals(listOf(RichSpan(2, 4, RichStyle.BOLD)), result.content.spans) + } + + @Test + fun `toggleBlock numbers each line of a multi-line selection`() { + val base = RichTextContent("a\nb\nc") + val result = RichTextEditing.toggleBlock(base, 0, 5, BlockMarker.ORDERED) + assertEquals("1. a\n2. b\n3. c", result.content.text) + } + + @Test + fun `toggleBlock removes the marker when every selected line already has it`() { + val base = RichTextContent("• a\n• b") + val result = RichTextEditing.toggleBlock(base, 0, base.text.length, BlockMarker.BULLET) + assertEquals("a\nb", result.content.text) + assertFalse(RichTextEditing.hasBlock(result.content, 0, result.content.text.length, BlockMarker.BULLET)) + } + + @Test + fun `toggleBlock replaces a different marker in place`() { + val base = RichTextContent("• a") + val result = RichTextEditing.toggleBlock(base, 0, 3, BlockMarker.QUOTE) + assertEquals("> a", result.content.text) + } +} diff --git a/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt new file mode 100644 index 0000000..7bf7657 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/richtext/RichTextHtmlTest.kt @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.richtext + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class RichTextHtmlTest { + + @Test + fun `plain paragraphs become p with br line breaks`() { + val html = RichTextHtml.toHtml(RichTextContent("Hello\nWorld")) + assertEquals("

      Hello
      World

      ", html) + } + + @Test + fun `inline styles nest as b i u`() { + val content = RichTextContent( + text = "bold italic under", + spans = listOf( + RichSpan(0, 4, RichStyle.BOLD), + RichSpan(5, 11, RichStyle.ITALIC), + RichSpan(12, 17, RichStyle.UNDERLINE), + ), + ) + assertEquals("

      bold italic under

      ", RichTextHtml.toHtml(content)) + } + + @Test + fun `overlapping styles stay valid html`() { + val content = RichTextContent( + text = "abcd", + spans = listOf(RichSpan(0, 3, RichStyle.BOLD), RichSpan(1, 4, RichStyle.ITALIC)), + ) + // b over [0,3), i over [1,4): every run fully closes its tags, so nesting is always valid. + assertEquals("

      abcd

      ", RichTextHtml.toHtml(content)) + } + + @Test + fun `bulleted and numbered lists and quotes map to block tags`() { + assertEquals("
      • Milk
      • Eggs
      ", RichTextHtml.toHtml(RichTextContent("• Milk\n• Eggs"))) + assertEquals("
      1. One
      2. Two
      ", RichTextHtml.toHtml(RichTextContent("1. One\n2. Two"))) + assertEquals("
      a
      b
      ", RichTextHtml.toHtml(RichTextContent("> a\n> b"))) + } + + @Test + fun `links render as anchors and text is html escaped`() { + val content = RichTextContent("a&c", links = listOf(RichLink(0, 1, "http://x?y=1&z"))) + val html = RichTextHtml.toHtml(content) + assertTrue(html.contains("a"), html) + assertTrue(html.contains("<b>&c"), html) + } + + @Test + fun `plaintext keeps the readable markers`() { + assertEquals("• Milk\n> quote", RichTextHtml.toPlainText(RichTextContent("• Milk\n> quote"))) + } + + @Test + fun `hasFormatting is false for unstyled markerless text`() { + assertFalse(RichTextContent("just words\nmore words").hasFormatting()) + assertTrue(RichTextContent("• bullet").hasFormatting()) + assertTrue(RichTextContent("x", spans = listOf(RichSpan(0, 1, RichStyle.BOLD))).hasFormatting()) + } + + @Test + fun `fromHtml round-trips paragraphs styles lists quotes and links`() { + listOf( + RichTextContent("Hello\nWorld"), + RichTextContent("bold", spans = listOf(RichSpan(0, 4, RichStyle.BOLD))), + RichTextContent("• Milk\n• Eggs"), + RichTextContent("1. One\n2. Two"), + RichTextContent("> a\n> b"), + RichTextContent("see here", links = listOf(RichLink(4, 8, "http://example.com"))), + RichTextContent("a&c"), + ).forEach { original -> + val restored = RichTextHtml.fromHtml(RichTextHtml.toHtml(original)) + assertEquals(original.text, restored.text, "text: $original") + assertEquals( + original.spans.sortedBy(RichSpan::start), + restored.spans.sortedBy(RichSpan::start), + "spans: $original", + ) + assertEquals( + original.links.sortedBy(RichLink::start), + restored.links.sortedBy(RichLink::start), + "links: $original", + ) + } + } + + @Test + fun `fromHtml tolerates strong em and pretty-printed whitespace`() { + val restored = RichTextHtml.fromHtml("
        \n
      • One
      • \n
      • Two
      • \n
      ") + assertEquals("• One\n• Two", restored.text) + // "Two" occupies [8,11) of "• One\n• Two". + assertEquals(listOf(RichSpan(8, 11, RichStyle.BOLD)), restored.spans) + } + + @Test + fun `empty content produces empty html`() { + assertEquals("", RichTextHtml.toHtml(RichTextContent(""))) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt index a2bda85..c48c499 100644 --- a/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/compose/ComposeViewModelTest.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.SavedStateHandle import io.mockk.coEvery import io.mockk.every import io.mockk.mockk +import io.mockk.slot import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -16,16 +17,21 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.libremail.data.settings.AccountSettingsRepository +import org.libremail.data.settings.SignatureRepository import org.libremail.domain.model.Account import org.libremail.domain.model.AccountSettings import org.libremail.domain.model.AuthType import org.libremail.domain.model.Draft import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.model.ServerConfig +import org.libremail.domain.model.Signature import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository import org.libremail.ui.navigation.Routes import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) class ComposeViewModelTest { @@ -48,10 +54,14 @@ class ComposeViewModelTest { smtp = ServerConfig("smtp.example.org", 465, MailSecurity.SSL_TLS), ) + private fun signature(accountId: String, html: String) = + Signature(id = "$accountId:sig", accountId = accountId, name = "Signature", html = html, isDefault = true) + private fun viewModel( accounts: List = listOf(alice), savedState: SavedStateHandle = SavedStateHandle(), - signatures: Map = emptyMap(), + signatures: Map = emptyMap(), + settings: Map = emptyMap(), mailRepository: MailRepository = mockk(relaxed = true), ): ComposeViewModel { val accountRepository = mockk() @@ -59,22 +69,36 @@ class ComposeViewModelTest { val accountSettingsRepository = mockk() coEvery { accountSettingsRepository.get(any()) } answers { val id = firstArg() - signatures[id] ?: AccountSettings(id) + settings[id] ?: AccountSettings(id) } + val signatureRepository = mockk() + coEvery { signatureRepository.getDefault(any()) } answers { signatures[firstArg()] } return ComposeViewModel( savedStateHandle = savedState, mailRepository = mailRepository, accountRepository = accountRepository, contactsRepository = mockk(relaxed = true), accountSettingsRepository = accountSettingsRepository, + signatureRepository = signatureRepository, ) } @Test - fun `appends the sending account signature to a new message`() = runTest(testDispatcher) { - val vm = viewModel(signatures = mapOf("imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice"))) + fun `appends the sending account default signature to a new message`() = runTest(testDispatcher) { + val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice"))) assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body) + // A plain signature carries no formatting, so the message stays plaintext-only. + assertNull(vm.state.value.bodyHtml) + } + + @Test + fun `a rich signature makes the new message carry an HTML body`() = runTest(testDispatcher) { + val vm = viewModel(signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice"))) + + assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body) + val html = vm.state.value.bodyHtml + assertTrue(html != null && html.contains("Alice"), "html=$html") } @Test @@ -82,8 +106,8 @@ class ComposeViewModelTest { val vm = viewModel( accounts = listOf(alice, bob), signatures = mapOf( - "imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice"), - "imap:b" to AccountSettings("imap:b", signature = "Best, Bob"), + "imap:a" to signature("imap:a", "Cheers, Alice"), + "imap:b" to signature("imap:b", "Best, Bob"), ), ) assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body) @@ -93,6 +117,16 @@ class ComposeViewModelTest { assertEquals("\n\n-- \nBest, Bob", vm.state.value.body) } + @Test + fun `does not append a signature when the account disabled it`() = runTest(testDispatcher) { + val vm = viewModel( + signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")), + settings = mapOf("imap:a" to AccountSettings("imap:a", signatureEnabled = false)), + ) + + assertEquals("", vm.state.value.body) + } + @Test fun `does not append a signature when resuming a draft`() = runTest(testDispatcher) { val mailRepository = mockk(relaxed = true) @@ -104,14 +138,32 @@ class ComposeViewModelTest { subject = "Hi", body = "Draft body", updatedAt = 0L, + bodyHtml = "

      Draft body

      ", attachments = emptyList(), ) val vm = viewModel( savedState = SavedStateHandle(mapOf(Routes.COMPOSE_ARG_DRAFT to "d1")), - signatures = mapOf("imap:a" to AccountSettings("imap:a", signature = "Cheers, Alice")), + signatures = mapOf("imap:a" to signature("imap:a", "Cheers, Alice")), mailRepository = mailRepository, ) assertEquals("Draft body", vm.state.value.body) + // The draft's HTML body is restored so it round-trips back out on send. + assertEquals("

      Draft body

      ", vm.state.value.bodyHtml) + } + + @Test + fun `send carries the HTML body through to the outgoing message`() = runTest(testDispatcher) { + val mailRepository = mockk(relaxed = true) + val sent = slot() + coEvery { mailRepository.sendMessage(capture(sent)) } returns Result.success(Unit) + val vm = viewModel(mailRepository = mailRepository) + + vm.onToChange("bob@example.org") + vm.onBodyChange("Hello", "

      Hello world

      ") + vm.send() + + assertEquals("

      Hello world

      ", sent.captured.bodyHtml) + assertEquals("Hello", sent.captured.body) } }