feat(compose): rich HTML editor, multipart send, and signatures

Bring rich composition to LibreMail (issues #36, #37, #38, and #23).

#36 HTML editor + toolbar
- New pure, JVM-testable rich-text model (`richtext/`): RichTextContent with
  inline styles + links and block markers ("• ", "N. ", "> "), serializing to a
  narrow email-safe HTML subset and back (fromHtml is a faithful inverse).
- Rich editor in ComposeScreen with a bold/italic/underline, bulleted/numbered
  list, block-quote, and link toolbar, backed by AnnotatedString. Unformatted
  text stays plaintext-only (null HTML) so it feels unchanged and is accessible.
- #23: rounded corners on the compose fields/body via MaterialTheme.shapes.

#37 multipart/alternative + reply/forward quoting
- SmtpSender builds multipart/alternative (text/plain + text/html), nested in
  multipart/mixed when there are attachments; GraphSender sends HTML content.
- HtmlToText produces a readable text/plain fallback; ReplyBuilder quotes HTML
  originals as clean blockquotes (tags stripped) without corruption.
- HTML body persists/restores through drafts and the outbox (new nullable
  bodyHtml columns; Room v9->v10 migration + schema).

#38 signatures
- New signatures table (multiple per account, one default) + repository/DAO;
  migration backfills the existing per-account signature as the default.
- Rich signatures reuse the #36 editor; a Signatures management screen (list,
  add/edit/delete, set default) is linked from per-account settings.
- The account's default signature auto-inserts on new compose / reply / forward
  (honoring the enable toggle), placed above the quote, and stays editable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 00:30:33 -05:00
co-authored by Claude Opus 4.8
parent 64fb82ebd9
commit bee5737ec4
48 changed files with 2954 additions and 109 deletions
@@ -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')"
]
}
}
@@ -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) {
@@ -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<Context>()
// 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
@@ -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 `<blockquote>`), 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 `<blockquote>` 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)<br\\s*/?>"), "\n")
.replace(Regex("(?i)</p\\s*>"), "\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))
@@ -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"
}
}
@@ -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
}
@@ -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 {
@@ -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 <br> 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), '<br>'), 1 " +
"FROM `account_settings` WHERE `signature` <> ''",
)
}
}
@@ -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<List<SignatureEntity>>
@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)
}
}
@@ -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,
)
@@ -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,
)
@@ -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,
)
@@ -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<List<Message>> = 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()
@@ -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<List<Signature>> =
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)
}
@@ -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) {
@@ -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"
}
@@ -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<OutgoingAttachment> = emptyList(),
)
@@ -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,
)
@@ -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<OutgoingAttachment> = emptyList(),
)
@@ -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)
}
@@ -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<File>): 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))
@@ -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)</?(p|div|tr|table|ul|ol|h[1-6]|blockquote)\\b[^>]*>|<br\\s*/?>",
)
private val LIST_ITEM = Regex("(?i)<li\\b[^>]*>")
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[^>]*>.*?</\\1>"), "")
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("&nbsp;", " ")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&mdash;", "—")
.replace("&ndash;", "–")
.replace("&amp;", "&")
}
@@ -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<File>) {
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"
}
@@ -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<RichSpan> = emptyList(),
val links: List<RichLink> = 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 — `<b> <i> <u> <a> <ul>/<ol>/<li> <blockquote> <br>` — 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<Line> {
val lines = ArrayList<Line>()
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<Line>,
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("<li>").append(renderInline(content, lines[i].contentStart, lines[i].contentEnd)).append("</li>")
i++
}
sb.append("</").append(tag).append(">")
return i
}
private fun emitQuote(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
sb.append("<blockquote>")
var i = from
while (i < lines.size && lines[i].kind == Kind.QUOTE) {
if (i > from) sb.append("<br>")
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
i++
}
sb.append("</blockquote>")
return i
}
private fun emitParagraph(sb: StringBuilder, content: RichTextContent, lines: List<Line>, from: Int): Int {
sb.append("<p>")
var i = from
while (i < lines.size && lines[i].kind == Kind.PARAGRAPH) {
if (i > from) sb.append("<br>")
sb.append(renderInline(content, lines[i].contentStart, lines[i].contentEnd))
i++
}
sb.append("</p>")
return i
}
/** Renders the inline styling/links over [[from], [to]) as nested `<a>/<b>/<i>/<u>` 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<Int> {
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("<a href=\"").append(escapeAttr(link.url)).append("\">")
if (RichStyle.BOLD in styles) sb.append("<b>")
if (RichStyle.ITALIC in styles) sb.append("<i>")
if (RichStyle.UNDERLINE in styles) sb.append("<u>")
sb.append(escape(content.text.substring(a, b)))
if (RichStyle.UNDERLINE in styles) sb.append("</u>")
if (RichStyle.ITALIC in styles) sb.append("</i>")
if (RichStyle.BOLD in styles) sb.append("</b>")
if (link != null) sb.append("</a>")
}
/**
* 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<RichSpan>()
private val links = ArrayList<RichLink>()
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
private fun escapeAttr(s: String): String = escape(s).replace("\"", "&quot;")
private fun unescape(s: String): String = s
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
@@ -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<LineEdit>()
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<RichSpan>, 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<RichSpan>, start: Int, end: Int): List<RichSpan> = 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<RichSpan>): List<RichSpan> {
val merged = ArrayList<RichSpan>()
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<Int> {
val from = start.coerceIn(0, text.length)
val to = end.coerceIn(from, text.length)
val result = ArrayList<Int>()
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<LineEdit>): Pair<String, (Int) -> 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<LineEdit>): 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
}
@@ -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(
@@ -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),
)
}
@@ -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<OutgoingAttachment> = emptyList(),
val suggestions: List<ContactSuggestion> = 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<OutgoingAttachment>) =
_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) }
@@ -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
}
@@ -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"
@@ -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()
@@ -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<AccountSettings> = 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<String?>(null)
val signature: StateFlow<String?> = _signature.asStateFlow()
private val signaturesFlow = signatureRepository.observeForAccount(accountId)
val signatureCount: StateFlow<Int> = 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<String> = 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) }
}
@@ -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),
)
}
}
}
}
@@ -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<String>(Routes.SIGNATURE_EDIT_ARG_ID)?.takeIf { it.isNotBlank() }
val isNew: Boolean = signatureId == null
private val _state = MutableStateFlow(SignatureEditUiState(loaded = isNew))
val state: StateFlow<SignatureEditUiState> = _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"
}
}
@@ -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))
}
}
}
@@ -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<List<Signature>> = 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) }
}
}
+31 -1
View File
@@ -77,6 +77,19 @@
<string name="compose_attach">Attach file</string>
<string name="compose_attachment_remove">Remove attachment</string>
<!-- Compose formatting toolbar -->
<string name="format_bold">Bold</string>
<string name="format_italic">Italic</string>
<string name="format_underline">Underline</string>
<string name="format_bullet_list">Bulleted list</string>
<string name="format_numbered_list">Numbered list</string>
<string name="format_quote">Block quote</string>
<string name="format_link">Insert link</string>
<string name="format_link_title">Insert link</string>
<string name="format_link_url">Link address</string>
<string name="format_link_apply">Link</string>
<string name="format_link_needs_selection">Select some text first, then add a link.</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
<string name="drafts_empty">No drafts</string>
@@ -146,8 +159,25 @@
<!-- Per-account settings -->
<string name="settings_account_title">Account</string>
<string name="settings_signature">Signature</string>
<string name="settings_signature_enable">Append signature</string>
<string name="settings_signature_enable">Append signature automatically</string>
<string name="settings_signature_hint">Your signature</string>
<string name="settings_signatures_manage">Manage signatures</string>
<string name="settings_signatures_summary_none">No signatures yet</string>
<string name="settings_signatures_summary">%1$d signature(s), default: %2$s</string>
<!-- Signatures management -->
<string name="signatures_title">Signatures</string>
<string name="signatures_empty">No signatures yet. Add one to append it to your messages.</string>
<string name="signatures_add">Add signature</string>
<string name="signature_new_title">New signature</string>
<string name="signature_edit_title">Edit signature</string>
<string name="signature_name">Name</string>
<string name="signature_content">Signature</string>
<string name="signature_save">Save</string>
<string name="signature_delete">Delete signature</string>
<string name="signature_default">Default</string>
<string name="signature_make_default">Set as default</string>
<string name="signature_default_badge">Default</string>
<string name="settings_account_notifications">Notifications</string>
<string name="settings_account_new_mail">New mail notifications</string>
<string name="settings_account_new_mail_summary">Show notifications for this account</string>
@@ -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 = "<p>Hello <b>there</b></p>", 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("<blockquote>"), "html=${result.bodyHtml}")
assertTrue(result.bodyHtml.contains("Hello there"), "html=${result.bodyHtml}")
assertFalse(result.bodyHtml.contains("<p>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("<blockquote>"), "html=${result.bodyHtml}")
assertTrue(result.bodyHtml.contains("First line"), "html=${result.bodyHtml}")
}
}
@@ -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, <b>Alice</b>"))
assertTrue(block.html.endsWith("Cheers, <b>Alice</b>"), 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)
}
}
@@ -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 = "<p>Hello <b>there</b></p>",
)
val restored = draft.toEntity().toDomain()
assertEquals("<p>Hello <b>there</b></p>", 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 = "<p>Hi</p>",
)
assertEquals("<p>Hi</p>", entity.toDomain().bodyHtml)
}
}
@@ -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<MailConnectionFactory>()
private val context = mockk<Context>(relaxed = true)
private val accountSettingsRepository = mockk<AccountSettingsRepository>()
private val signatureRepository = mockk<SignatureRepository>()
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("<blockquote>") == 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<DraftEntity>()
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
@@ -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<SignatureDao>(relaxed = true)
private val repository = SignatureRepository(dao)
private fun entity(id: String, isDefault: Boolean) =
SignatureEntity(id, accountId = "acct", name = "N", contentHtml = "<p>x</p>", isDefault = isDefault)
@Test
fun `the first signature for an account becomes its default`() = runTest {
coEvery { dao.countForAccount("acct") } returns 0
val saved = slot<SignatureEntity>()
coEvery { dao.upsert(capture(saved)) } just Runs
repository.create("acct", "Work", "<p>hi</p>")
assertTrue(saved.captured.isDefault)
}
@Test
fun `later signatures are not made default`() = runTest {
coEvery { dao.countForAccount("acct") } returns 2
val saved = slot<SignatureEntity>()
coEvery { dao.upsert(capture(saved)) } just Runs
repository.create("acct", "Personal", "<p>hey</p>")
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") }
}
}
@@ -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 = "<p>Hello <b>world</b></p>",
)
val body = JSONObject(buildSendMailPayload(message, emptyList()))
.getJSONObject("message").getJSONObject("body")
assertEquals("HTML", body.getString("contentType"))
assertEquals("<p>Hello <b>world</b></p>", 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(
@@ -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("<p>Hello <b>world</b></p>"))
}
@Test
fun `br and paragraphs become line breaks`() {
assertEquals("Line one\nLine two", HtmlToText.convert("Line one<br>Line two"))
assertEquals("A\n\nB", HtmlToText.convert("<p>A</p><p>B</p>"))
}
@Test
fun `list items gain bullet markers`() {
assertEquals("• One\n• Two", HtmlToText.convert("<ul><li>One</li><li>Two</li></ul>"))
}
@Test
fun `decodes entities and drops script and style content`() {
assertEquals("Tom & Jerry", HtmlToText.convert("Tom &amp; Jerry<style>.x{color:red}</style>"))
val converted = HtmlToText.convert("<script>alert('x')</script>Safe")
assertEquals("Safe", converted)
assertFalse(converted.contains("alert"))
}
@Test
fun `collapses excess whitespace`() {
val converted = HtmlToText.convert("<p>Hello there</p>\n\n\n<p>bye</p>")
assertTrue(converted.contains("Hello there"), converted)
assertFalse(converted.contains("Hello there"), converted)
}
}
@@ -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 = "<p>Hello <b>world</b></p>",
),
)
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("<b>world</b>"), "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 = "<p><b>Body</b></p>",
),
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("<b>Body</b>"), "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") }
@@ -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)
}
}
@@ -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("<p>Hello<br>World</p>", 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("<p><b>bold</b> <i>italic</i> <u>under</u></p>", 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("<p><b>a</b><b><i>bc</i></b><i>d</i></p>", RichTextHtml.toHtml(content))
}
@Test
fun `bulleted and numbered lists and quotes map to block tags`() {
assertEquals("<ul><li>Milk</li><li>Eggs</li></ul>", RichTextHtml.toHtml(RichTextContent("• Milk\n• Eggs")))
assertEquals("<ol><li>One</li><li>Two</li></ol>", RichTextHtml.toHtml(RichTextContent("1. One\n2. Two")))
assertEquals("<blockquote>a<br>b</blockquote>", RichTextHtml.toHtml(RichTextContent("> a\n> b")))
}
@Test
fun `links render as anchors and text is html escaped`() {
val content = RichTextContent("a<b>&c", links = listOf(RichLink(0, 1, "http://x?y=1&z")))
val html = RichTextHtml.toHtml(content)
assertTrue(html.contains("<a href=\"http://x?y=1&amp;z\">a</a>"), html)
assertTrue(html.contains("&lt;b&gt;&amp;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<b>&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("<ul>\n <li>One</li>\n <li><strong>Two</strong></li>\n</ul>")
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("")))
}
}
@@ -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<Account> = listOf(alice),
savedState: SavedStateHandle = SavedStateHandle(),
signatures: Map<String, AccountSettings> = emptyMap(),
signatures: Map<String, Signature> = emptyMap(),
settings: Map<String, AccountSettings> = emptyMap(),
mailRepository: MailRepository = mockk(relaxed = true),
): ComposeViewModel {
val accountRepository = mockk<AccountRepository>()
@@ -59,22 +69,36 @@ class ComposeViewModelTest {
val accountSettingsRepository = mockk<AccountSettingsRepository>()
coEvery { accountSettingsRepository.get(any()) } answers {
val id = firstArg<String>()
signatures[id] ?: AccountSettings(id)
settings[id] ?: AccountSettings(id)
}
val signatureRepository = mockk<SignatureRepository>()
coEvery { signatureRepository.getDefault(any()) } answers { signatures[firstArg<String>()] }
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, <b>Alice</b>")))
assertEquals("\n\n-- \nCheers, Alice", vm.state.value.body)
val html = vm.state.value.bodyHtml
assertTrue(html != null && html.contains("<b>Alice</b>"), "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<MailRepository>(relaxed = true)
@@ -104,14 +138,32 @@ class ComposeViewModelTest {
subject = "Hi",
body = "Draft body",
updatedAt = 0L,
bodyHtml = "<p>Draft <b>body</b></p>",
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("<p>Draft <b>body</b></p>", vm.state.value.bodyHtml)
}
@Test
fun `send carries the HTML body through to the outgoing message`() = runTest(testDispatcher) {
val mailRepository = mockk<MailRepository>(relaxed = true)
val sent = slot<OutgoingMessage>()
coEvery { mailRepository.sendMessage(capture(sent)) } returns Result.success(Unit)
val vm = viewModel(mailRepository = mailRepository)
vm.onToChange("bob@example.org")
vm.onBodyChange("Hello", "<p>Hello <i>world</i></p>")
vm.send()
assertEquals("<p>Hello <i>world</i></p>", sent.captured.bodyHtml)
assertEquals("Hello", sent.captured.body)
}
}