Merge pull request #201 from JMR-dev/feat-77-inline-images

feat(compose): inline images
This commit was merged in pull request #201.
This commit is contained in:
Jason Ross
2026-07-03 01:46:59 -05:00
committed by GitHub
22 changed files with 979 additions and 84 deletions
@@ -0,0 +1,467 @@
{
"formatVersion": 1,
"database": {
"version": 18,
"identityHash": "f4b80b1d988222a691f7aeea4d45e37e",
"entities": [
{
"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, `uid` INTEGER NOT NULL DEFAULT 0, 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
},
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
}
],
"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`)"
},
{
"name": "index_messages_accountId_folder_uid",
"unique": false,
"columnNames": [
"accountId",
"folder",
"uid"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
}
]
},
{
"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, `contentId` TEXT, 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
},
{
"fieldPath": "contentId",
"columnName": "contentId",
"affinity": "TEXT"
}
],
"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, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, `attachments` TEXT NOT NULL DEFAULT '', 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": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"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"
},
{
"fieldPath": "attachments",
"columnName": "attachments",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
}
],
"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, `bccAddresses` TEXT NOT NULL DEFAULT '', `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": "bccAddresses",
"columnName": "bccAddresses",
"affinity": "TEXT",
"notNull": true,
"defaultValue": "''"
},
{
"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, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, 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
},
{
"fieldPath": "specialUse",
"columnName": "specialUse",
"affinity": "INTEGER",
"notNull": true,
"defaultValue": "0"
},
{
"fieldPath": "hierarchyDelimiter",
"columnName": "hierarchyDelimiter",
"affinity": "TEXT"
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"fullName"
]
}
},
{
"tableName": "backfill_progress",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
"fields": [
{
"fieldPath": "accountId",
"columnName": "accountId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "folder",
"columnName": "folder",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "nextBeforeUid",
"columnName": "nextBeforeUid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "complete",
"columnName": "complete",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"accountId",
"folder"
]
}
}
],
"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, 'f4b80b1d988222a691f7aeea4d45e37e')"
]
}
}
@@ -200,6 +200,30 @@ class MigrationTest {
db.close()
}
/** v17 -> v18 (issue #77): `outbox.attachments` appears defaulting to '' and the queued row survives. */
@Test
fun migrate17To18_addsEmptyAttachmentsToOutbox() {
helper.createDatabase(TEST_DB, 17).apply {
// v16 dropped the account tables, so a bare outbox row is all this migration needs.
execSQL(
"INSERT INTO outbox (id, accountId, toAddresses, ccAddresses, bccAddresses, subject, body, " +
"createdAt, lastError, bodyHtml) VALUES ('out-1', 'acct', 'bob@example.org', '', '', " +
"'Queued', 'Body', 3000, NULL, NULL)",
)
close()
}
val db = helper.runMigrationsAndValidate(TEST_DB, 18, true, MIGRATION_17_18)
db.query("SELECT subject, attachments FROM outbox WHERE id = 'out-1'").use { c ->
assertTrue("the queued outbox row must survive", c.moveToFirst())
assertEquals("Queued", c.getString(0))
assertEquals("existing outbox rows read empty attachment metadata", "", c.getString(1))
assertFalse("only the one pre-upgrade outbox row must survive", c.moveToNext())
}
db.close()
}
/** The newest schema JSON exported to app/schemas (shipped to the test APK as assets). */
private fun latestExportedSchemaVersion(): Int {
val schemaFolder = checkNotNull(LibreMailDatabase::class.java.canonicalName)
@@ -35,7 +35,7 @@ import org.libremail.data.local.entity.OutboxEntity
FolderEntity::class,
BackfillProgressEntity::class,
],
version = 17,
version = 18,
exportSchema = true,
)
abstract class LibreMailDatabase : RoomDatabase() {
@@ -218,25 +218,41 @@ internal fun Draft.toEntity(): DraftEntity = DraftEntity(
subject = subject,
body = body,
updatedAt = updatedAt,
attachments = attachments.toJson(),
attachments = attachments.toOutgoingAttachmentsJson(),
bodyHtml = bodyHtml,
)
/** Serializes draft attachments as a JSON array of {uri, name} objects ("" when empty). */
private fun List<OutgoingAttachment>.toJson(): String {
/**
* Serializes outgoing attachments as a JSON array of `{uri, name, contentId?, isInline?}` objects
* ("" when empty). Shared by the drafts column and the outbox column, so an inline image's
* cid↔file pairing survives a draft save/reopen and a queued send alike. The two optional keys are
* omitted for a plain attachment, so an old draft (written before inline images) reads back with
* `contentId = null` / `isInline = false`.
*/
internal fun List<OutgoingAttachment>.toOutgoingAttachmentsJson(): String {
if (isEmpty()) return ""
val array = JSONArray()
forEach { array.put(JSONObject().put("uri", it.uri).put("name", it.name)) }
forEach { attachment ->
val obj = JSONObject().put("uri", attachment.uri).put("name", attachment.name)
attachment.contentId?.let { obj.put("contentId", it) }
if (attachment.isInline) obj.put("isInline", true)
array.put(obj)
}
return array.toString()
}
private fun String.toOutgoingAttachments(): List<OutgoingAttachment> {
internal fun String.toOutgoingAttachments(): List<OutgoingAttachment> {
if (isBlank()) return emptyList()
return runCatching {
val array = JSONArray(this)
(0 until array.length()).map { i ->
val obj = array.getJSONObject(i)
OutgoingAttachment(obj.getString("uri"), obj.optString("name"))
OutgoingAttachment(
uri = obj.getString("uri"),
name = obj.optString("name"),
contentId = obj.optString("contentId").takeIf { it.isNotEmpty() },
isInline = obj.optBoolean("isInline", false),
)
}
}.getOrDefault(emptyList())
}
@@ -344,3 +344,18 @@ val MIGRATION_16_17 = object : Migration(16, 17) {
db.execSQL("ALTER TABLE `attachments` ADD COLUMN `contentId` TEXT")
}
}
/**
* v17 -> v18: inline-image support for outgoing mail (issue #77; preserves existing data). Adds an
* `attachments` column to `outbox` holding JSON metadata (`{uri, name, contentId?, isInline?}`, one
* entry per staged file in index order) so the send worker can pair an inline image's `Content-ID`
* with its staged file. `DEFAULT ''` matches the entity's `@ColumnInfo(defaultValue = "")` so the
* fresh-install schema validates identically to the migrated one (the MIGRATION_9_10 `bccAddresses`
* pattern); a message queued before the upgrade reads back "" and its staged files are still sent as
* plain attachments (the send worker's positional fallback).
*/
val MIGRATION_17_18 = object : Migration(17, 18) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE `outbox` ADD COLUMN `attachments` TEXT NOT NULL DEFAULT ''")
}
}
@@ -19,4 +19,11 @@ data class OutboxEntity(
val lastError: String? = null,
/** HTML form of [body] when composed with formatting; null sends `text/plain` only. */
val bodyHtml: String? = null,
/**
* JSON metadata for the staged attachments, in staging-index order (the same
* `{uri, name, contentId?, isInline?}` shape drafts use), or "" when there are none. The bytes
* live on disk under `cacheDir/outbox/<id>/<index>/`; this column carries each part's `Content-ID`
* and inline flag so the send worker can pair an inline image's cid with its file.
*/
@ColumnInfo(defaultValue = "") val attachments: String = "",
)
@@ -32,6 +32,7 @@ import org.libremail.data.local.entity.MessageRouting
import org.libremail.data.local.entity.OutboxEntity
import org.libremail.data.local.toDomain
import org.libremail.data.local.toEntity
import org.libremail.data.local.toOutgoingAttachmentsJson
import org.libremail.data.settings.AccountSettingsRepository
import org.libremail.data.settings.SignatureRepository
import org.libremail.data.sync.MailConnectionFactory
@@ -404,6 +405,9 @@ class MailRepositoryImpl @Inject constructor(
body = outgoing.body,
createdAt = System.currentTimeMillis(),
bodyHtml = outgoing.bodyHtml,
// Persist the cid↔file pairing (in staging-index order) so the send worker can attach
// an inline image with its Content-ID even though the files are looked up by index.
attachments = outgoing.attachments.toOutgoingAttachmentsJson(),
),
)
sendScheduler.sendNow()
@@ -12,12 +12,15 @@ import dagger.assisted.AssistedInject
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.local.toDomain
import org.libremail.data.local.toOutgoingAttachments
import org.libremail.data.security.EncryptedCacheGuard
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.OutgoingAttachment
import org.libremail.domain.model.OutgoingMessage
import org.libremail.mail.GraphSendException
import org.libremail.mail.GraphSender
import org.libremail.mail.SendableAttachment
import org.libremail.mail.SmtpSender
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
@@ -69,15 +72,15 @@ class SendWorker @AssistedInject constructor(
body = entity.body,
bodyHtml = entity.bodyHtml,
)
val files = orderedAttachments(attachmentDir)
val attachments = stagedAttachments(attachmentDir, entity.attachments.toOutgoingAttachments())
if (account.authType == AuthType.OAUTH_OUTLOOK) {
sendOutlook(connectionFactory, account, message, files)
sendOutlook(connectionFactory, account, message, attachments)
} else {
smtpSender.send(
connectionFactory.smtpParamsFor(account),
from = account.email,
message = message,
attachments = files,
attachments = attachments,
)
}
}.fold(
@@ -114,18 +117,18 @@ class SendWorker @AssistedInject constructor(
connectionFactory: MailConnectionFactory,
account: Account,
message: OutgoingMessage,
files: List<File>,
attachments: List<SendableAttachment>,
) {
try {
val token = connectionFactory.graphTokenFor(account)
graphSender.send(token, message, files)
graphSender.send(token, message, attachments)
} catch (e: GraphSendException) {
if (e.mayHaveSent) throw e
smtpSender.send(
connectionFactory.smtpParamsFor(account),
from = account.email,
message = message,
attachments = files,
attachments = attachments,
)
} catch (e: CancellationException) {
throw e
@@ -136,13 +139,30 @@ class SendWorker @AssistedInject constructor(
connectionFactory.smtpParamsFor(account),
from = account.email,
message = message,
attachments = files,
attachments = attachments,
)
}
}
/**
* Pairs each staged file with its [metadata] entry by staging index, so an inline image keeps its
* `Content-ID`/inline flag alongside its file. A missing file (a copy that failed) is skipped.
* When [metadata] is empty — a message queued before the outbox carried attachment metadata, or a
* genuinely attachment-less one — falls back to restoring the staged files positionally as plain
* attachments, so an in-flight message from before the upgrade still sends its files.
*/
private fun stagedAttachments(dir: File, metadata: List<OutgoingAttachment>): List<SendableAttachment> {
if (metadata.isEmpty()) {
return orderedAttachmentFiles(dir).map { SendableAttachment(it) }
}
return metadata.mapIndexedNotNull { index, meta ->
val file = File(dir, index.toString()).listFiles()?.firstOrNull() ?: return@mapIndexedNotNull null
SendableAttachment(file, contentId = meta.contentId, isInline = meta.isInline)
}
}
/** Attachments are staged one-per-indexed-subdirectory so their original order is preserved. */
private fun orderedAttachments(dir: File): List<File> = dir.listFiles()
private fun orderedAttachmentFiles(dir: File): List<File> = dir.listFiles()
?.filter { it.isDirectory }
?.sortedBy { it.name.toIntOrNull() ?: Int.MAX_VALUE }
?.mapNotNull { it.listFiles()?.firstOrNull() }
@@ -23,6 +23,7 @@ import org.libremail.data.local.MIGRATION_13_14
import org.libremail.data.local.MIGRATION_14_15
import org.libremail.data.local.MIGRATION_15_16
import org.libremail.data.local.MIGRATION_16_17
import org.libremail.data.local.MIGRATION_17_18
import org.libremail.data.local.MIGRATION_1_2
import org.libremail.data.local.MIGRATION_2_3
import org.libremail.data.local.MIGRATION_3_4
@@ -65,6 +66,7 @@ object DatabaseModule {
MIGRATION_14_15,
MIGRATION_15_16,
MIGRATION_16_17,
MIGRATION_17_18,
)
// No destructive fallback: the migration chain is complete, and silently dropping the
// mail/message tables would lose cached data. A missing migration should fail loudly in
@@ -19,5 +19,17 @@ data class OutgoingMessage(
val attachments: List<OutgoingAttachment> = emptyList(),
)
/** A file the user attached, referenced by its content-URI string until it is sent. */
data class OutgoingAttachment(val uri: String, val name: String)
/**
* A file the user attached, referenced by its content-URI string until it is sent.
*
* An inline image (inserted into the body, not listed as a separate attachment) has [isInline] true
* and a [contentId]: the body's HTML references it as `<img src="cid:contentId">`, and the sender
* emits the part with a matching `Content-ID` (SMTP `multipart/related`) or `isInline`+`contentId`
* (Graph). A regular attachment leaves both at their defaults.
*/
data class OutgoingAttachment(
val uri: String,
val name: String,
val contentId: String? = null,
val isInline: Boolean = false,
)
@@ -7,7 +7,6 @@ import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.json.JSONObject
import org.libremail.domain.model.OutgoingMessage
import java.io.File
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
@@ -30,48 +29,51 @@ class GraphSendException(message: String, val mayHaveSent: Boolean, cause: Throw
@Singleton
class GraphSender @Inject constructor() {
suspend fun send(accessToken: String, message: OutgoingMessage, attachments: List<File> = emptyList()) =
withContext(Dispatchers.IO) {
val payload = buildSendMailPayload(message, attachments)
val connection = (URL(SEND_MAIL_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
doOutput = true
setRequestProperty("Authorization", "Bearer $accessToken")
setRequestProperty("Content-Type", "application/json; charset=utf-8")
}
try {
// Failure here means the request never reached Graph — safe to fall back/retry.
try {
connection.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) }
} catch (e: IOException) {
throw GraphSendException("Graph sendMail could not be transmitted", mayHaveSent = false, cause = e)
}
// The request was fully sent; if we can't read the response, Graph may already have
// accepted and sent it — do not fall back to SMTP or the message would be duplicated.
val code = try {
connection.responseCode
} catch (e: IOException) {
throw GraphSendException(
"Graph sendMail sent but no response received",
mayHaveSent = true,
cause = e,
)
}
if (code !in HTTP_OK_MIN..HTTP_OK_MAX) {
val body = (connection.errorStream ?: connection.inputStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
// An explicit non-2xx means Graph rejected (did not send) — safe to fall back.
throw GraphSendException(
"Graph sendMail failed (HTTP $code): ${body.take(ERROR_BODY_LIMIT)}",
mayHaveSent = false,
)
}
} finally {
connection.disconnect()
}
suspend fun send(
accessToken: String,
message: OutgoingMessage,
attachments: List<SendableAttachment> = emptyList(),
) = withContext(Dispatchers.IO) {
val payload = buildSendMailPayload(message, attachments)
val connection = (URL(SEND_MAIL_URL).openConnection() as HttpURLConnection).apply {
requestMethod = "POST"
connectTimeout = TIMEOUT_MS
readTimeout = TIMEOUT_MS
doOutput = true
setRequestProperty("Authorization", "Bearer $accessToken")
setRequestProperty("Content-Type", "application/json; charset=utf-8")
}
try {
// Failure here means the request never reached Graph — safe to fall back/retry.
try {
connection.outputStream.use { it.write(payload.toByteArray(Charsets.UTF_8)) }
} catch (e: IOException) {
throw GraphSendException("Graph sendMail could not be transmitted", mayHaveSent = false, cause = e)
}
// The request was fully sent; if we can't read the response, Graph may already have
// accepted and sent it — do not fall back to SMTP or the message would be duplicated.
val code = try {
connection.responseCode
} catch (e: IOException) {
throw GraphSendException(
"Graph sendMail sent but no response received",
mayHaveSent = true,
cause = e,
)
}
if (code !in HTTP_OK_MIN..HTTP_OK_MAX) {
val body = (connection.errorStream ?: connection.inputStream)
?.bufferedReader()?.use { it.readText() }.orEmpty()
// An explicit non-2xx means Graph rejected (did not send) — safe to fall back.
throw GraphSendException(
"Graph sendMail failed (HTTP $code): ${body.take(ERROR_BODY_LIMIT)}",
mayHaveSent = false,
)
}
} finally {
connection.disconnect()
}
}
private companion object {
const val SEND_MAIL_URL = "https://graph.microsoft.com/v1.0/me/sendMail"
@@ -83,7 +85,7 @@ 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 {
internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List<SendableAttachment>): 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) {
@@ -103,13 +105,17 @@ internal fun buildSendMailPayload(message: OutgoingMessage, attachments: List<Fi
}
if (attachments.isNotEmpty()) {
val items = JSONArray()
attachments.forEach { file ->
items.put(
JSONObject()
.put("@odata.type", "#microsoft.graph.fileAttachment")
.put("name", file.name)
.put("contentBytes", Base64.getEncoder().encodeToString(file.readBytes())),
)
attachments.forEach { attachment ->
val obj = JSONObject()
.put("@odata.type", "#microsoft.graph.fileAttachment")
.put("name", attachment.file.name)
.put("contentBytes", Base64.getEncoder().encodeToString(attachment.file.readBytes()))
// An inline image the HTML references as `cid:contentId` — Graph renders it in the body
// rather than listing it as a downloadable attachment.
if (attachment.isInlineImage) {
obj.put("isInline", true).put("contentId", attachment.contentId)
}
items.put(obj)
}
mail.put("attachments", items)
}
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import java.io.File
/**
* One staged attachment ready to send: the on-disk [file] plus how it should be attached. An inline
* image has [isInline] true and a non-null [contentId] — the body's HTML references it as
* `cid:contentId`, so the SMTP sender emits it inside a `multipart/related` with a matching
* `Content-ID` + inline disposition, and the Graph sender marks the payload `isInline` with that
* `contentId`. A regular attachment leaves both defaults and is attached exactly as before.
*/
data class SendableAttachment(val file: File, val contentId: String? = null, val isInline: Boolean = false) {
/** True only for a genuine inline image (inline flag set and a content id to reference it by). */
val isInlineImage: Boolean get() = isInline && contentId != null
}
@@ -13,7 +13,6 @@ import kotlinx.coroutines.withContext
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.SmtpParams
import java.io.File
import java.util.Date
import java.util.Properties
import javax.inject.Inject
@@ -27,7 +26,7 @@ class SmtpSender @Inject constructor() {
params: SmtpParams,
from: String,
message: OutgoingMessage,
attachments: List<File> = emptyList(),
attachments: List<SendableAttachment> = emptyList(),
) = withContext(Dispatchers.IO) {
val protocol = if (params.security == MailSecurity.SSL_TLS) "smtps" else "smtp"
val props = Properties().apply {
@@ -78,22 +77,52 @@ class SmtpSender @Inject constructor() {
}
/**
* Sets the message body:
* Sets the message body and attaches files:
* - 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.
*
* Inline images (referenced by the HTML as `cid:…`) wrap the body in a `multipart/related` so a
* client can resolve those references; each carries a `Content-ID` and inline disposition. Regular
* attachments keep today's shape — the body (related or plain) becomes the first part of a
* `multipart/mixed`, followed by the attachment parts.
*/
private fun applyBody(mime: MimeMessage, message: OutgoingMessage, attachments: List<File>) {
if (attachments.isEmpty()) {
setBody(mime, message)
return
private fun applyBody(mime: MimeMessage, message: OutgoingMessage, attachments: List<SendableAttachment>) {
val inline = attachments.filter { it.isInlineImage }
val regular = attachments.filterNot { it.isInlineImage }
when {
inline.isEmpty() && regular.isEmpty() -> setBody(mime, message)
regular.isEmpty() -> mime.setContent(relatedBody(message, inline))
else -> {
val mixed = MimeMultipart("mixed")
mixed.addBodyPart(bodyPart(message, inline))
regular.forEach { mixed.addBodyPart(MimeBodyPart().apply { attachFile(it.file) }) }
mime.setContent(mixed)
}
}
val mixed = MimeMultipart("mixed")
mixed.addBodyPart(MimeBodyPart().also { setBody(it, message) })
attachments.forEach { file -> mixed.addBodyPart(MimeBodyPart().apply { attachFile(file) }) }
mime.setContent(mixed)
}
/** The body part for a `multipart/mixed`: the plain/alternative body, wrapped in related if inline. */
private fun bodyPart(message: OutgoingMessage, inline: List<SendableAttachment>): MimeBodyPart =
if (inline.isEmpty()) {
MimeBodyPart().also { setBody(it, message) }
} else {
MimeBodyPart().apply { setContent(relatedBody(message, inline)) }
}
/** A `multipart/related`: the body (plain/alternative) followed by each inline image part. */
private fun relatedBody(message: OutgoingMessage, inline: List<SendableAttachment>): MimeMultipart =
MimeMultipart("related").apply {
addBodyPart(MimeBodyPart().also { setBody(it, message) })
inline.forEach { addBodyPart(inlinePart(it)) }
}
/** An inline image part: attached bytes, a `Content-ID` the HTML's `cid:` matches, inline disposition. */
private fun inlinePart(attachment: SendableAttachment): MimeBodyPart = MimeBodyPart().apply {
attachFile(attachment.file)
contentID = "<${attachment.contentId}>"
setDisposition(MimeBodyPart.INLINE)
}
/** Writes the body onto [part]: plain text, or a text/plain + text/html alternative when formatted. */
@@ -141,6 +141,32 @@ object RichTextEditing {
}
return aligns.distinct().singleOrNull()
}
/**
* Inserts an inline image at [at]: a visible [imageToken] backed by a [RichImage] carrying
* [contentId] and [name]. Every span/link/alignment/image at or after the insertion point shifts
* by the token's length (a run the caret sits inside grows to keep covering the same text), and
* the returned selection lands just past the inserted token. Deleting the token later removes the
* [RichImage] with it, since the model derives its images from the tokens in the text.
*/
fun insertImage(content: RichTextContent, at: Int, contentId: String, name: String): EditResult {
val pos = at.coerceIn(0, content.text.length)
val token = imageToken(name)
val len = token.length
val newText = content.text.substring(0, pos) + token + content.text.substring(pos)
fun shiftStart(o: Int): Int = if (o >= pos) o + len else o
fun shiftEnd(o: Int): Int = if (o > pos) o + len else o
val shiftedImages = content.images.map { it.copy(start = shiftStart(it.start), end = shiftEnd(it.end)) }
val images = (shiftedImages + RichImage(pos, pos + len, contentId, name)).sortedBy { it.start }
val updated = content.copy(
text = newText,
spans = content.spans.map { it.copy(start = shiftStart(it.start), end = shiftEnd(it.end)) },
links = content.links.map { it.copy(start = shiftStart(it.start), end = shiftEnd(it.end)) },
alignments = content.alignments.map { it.copy(start = shiftStart(it.start), end = shiftEnd(it.end)) },
images = images,
)
return EditResult(updated, pos + len, pos + len)
}
}
private fun insertFor(marker: BlockMarker, ordinal: Int): String = when (marker) {
@@ -97,6 +97,17 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
)
}
// Inline-image picker (image/*). Mirrors the attachment picker's persistable grant so a draft can
// reopen the image later; the ViewModel then hands the editor a token to drop at the caret.
val imagePicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let {
runCatching {
context.contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
viewModel.onImagePicked(it.toString(), queryFileName(context, it))
}
}
// Reflect the current READ_CONTACTS grant without ever prompting: the request now lives in the
// onboarding contacts step (#127) and the Settings entry (#129), so compose only reads state.
// Re-checked on resume so enabling autocomplete later (e.g. from Settings) takes effect the next
@@ -179,7 +190,8 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
modifier = Modifier.fillMaxWidth(),
)
AttachmentsSection(
attachments = state.attachments,
// Inline images live in the body (as tokens), not as separate attachment chips.
attachments = state.attachments.filterNot { it.isInline },
highlight = state.highlightAttach,
onHighlightShown = viewModel::consumeAttachHighlight,
onAttach = { attachmentPicker.launch(arrayOf("*/*")) },
@@ -193,6 +205,9 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
label = stringResource(R.string.compose_body),
modifier = Modifier.fillMaxWidth().weight(1f),
resolveFont = FontRegistry::resolveFontFamily,
onPickImage = { imagePicker.launch(arrayOf("image/*")) },
pendingImage = state.pendingInlineImage,
onImageInserted = viewModel::onInlineImageInserted,
)
}
@@ -60,8 +60,17 @@ data class ComposeUiState(
val showAttachmentPrompt: Boolean = false,
/** Draw the eye to the attach button — the user answered the attachment prompt with "Yes". */
val highlightAttach: Boolean = false,
/**
* A just-picked inline image waiting for the editor to drop its token at the caret. The editor
* consumes it (via [ComposeViewModel.onInlineImageInserted]) once inserted; a transient signal, so
* it is excluded from the autosaved [DraftContent].
*/
val pendingInlineImage: PendingInlineImage? = null,
)
/** A picked inline image the editor should insert: the token to show and the [contentId] to link it by. */
data class PendingInlineImage(val contentId: String, val name: String)
/** The persisted draft fields; autosave fires only when one of these actually changes (#177). */
private data class DraftContent(
val to: String,
@@ -198,8 +207,42 @@ class ComposeViewModel @Inject constructor(
* 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.
*
* Inline images are reconciled against the body here: an inline attachment is kept only while its
* `cid:` is still referenced by the HTML (or is the one currently being inserted), so deleting an
* image's `[image: …]` token drops the image itself. Regular attachments are never touched.
*/
fun onBodyChange(plain: String, html: String?) = _state.update { it.copy(body = plain, bodyHtml = html) }
fun onBodyChange(plain: String, html: String?) = _state.update { s ->
val referenced = referencedContentIds(html)
val keptAttachments = s.attachments.filter { attachment ->
!attachment.isInline ||
attachment.contentId in referenced ||
attachment.contentId == s.pendingInlineImage?.contentId
}
s.copy(body = plain, bodyHtml = html, attachments = keptAttachments)
}
/** The content ids the body's HTML still references (`cid:…`), used to prune deleted inline images. */
private fun referencedContentIds(html: String?): Set<String> =
html?.let { RichTextHtml.fromHtml(it).images.mapTo(mutableSetOf()) { image -> image.contentId } }.orEmpty()
/**
* A picked inline image: track it as an inline attachment (alongside regular ones) and hand the
* editor a [PendingInlineImage] to drop at the caret. The generated content id ties the body's
* `cid:` reference to the attachment the sender emits with a matching `Content-ID`.
*/
fun onImagePicked(uri: String, name: String) {
val contentId = "img-${UUID.randomUUID()}@libremail"
_state.update {
it.copy(
attachments = it.attachments + OutgoingAttachment(uri, name, contentId, isInline = true),
pendingInlineImage = PendingInlineImage(contentId, name),
)
}
}
/** The editor has inserted the pending image's token; clear the transient signal. */
fun onInlineImageInserted() = _state.update { it.copy(pendingInlineImage = null) }
fun selectFrom(accountId: String) {
viewModelScope.launch { applySignature(accountId) }
@@ -19,6 +19,7 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -102,6 +103,9 @@ fun RichTextBodyField(
label: String,
modifier: Modifier = Modifier,
resolveFont: (String) -> FontFamily? = { null },
onPickImage: (() -> Unit)? = null,
pendingImage: PendingInlineImage? = null,
onImageInserted: () -> Unit = {},
) {
val linkColor = MaterialTheme.colorScheme.primary
val seed = remember { seedContent(body, bodyHtml) }
@@ -132,6 +136,16 @@ fun RichTextBodyField(
var showFontColorPicker by remember { mutableStateOf(false) }
var showHighlightPicker by remember { mutableStateOf(false) }
// A picked image (chosen in ComposeScreen) is inserted here at the live caret — the editor owns the
// cursor — then reported handled so the caller clears it. Keyed on the image so re-picking the same
// file (a new instance) still fires.
LaunchedEffect(pendingImage) {
pendingImage?.let { image ->
emit(applyImageInsert(value, image.contentId, image.name, linkColor, resolveFont))
onImageInserted()
}
}
Column(modifier) {
FormattingToolbar(
value = value,
@@ -140,6 +154,7 @@ fun RichTextBodyField(
onLink = { showLinkDialog = true },
onFontColor = { showFontColorPicker = true },
onHighlight = { showHighlightPicker = true },
onInsertImage = onPickImage,
onFontSize = { pt ->
emit(
if (pt != null) {
@@ -245,6 +260,7 @@ private fun FormattingToolbar(
onFontSize: (Int?) -> Unit,
onAlignment: (RichAlign) -> Unit,
onFont: (String?) -> Unit,
onInsertImage: (() -> Unit)?,
) {
val content = value.annotatedString.toRichContent()
val start = value.selection.min
@@ -339,6 +355,16 @@ private fun FormattingToolbar(
selected = RichTextEditing.alignmentAt(content, start, end),
onSelect = onAlignment,
)
// The image button is appended last (like the other trailing controls) so it never shifts the
// block buttons the compose E2E taps without scrolling. Only shown when the host wires a picker.
if (onInsertImage != null) {
FormatButton(
label = "🖼",
description = stringResource(R.string.format_image),
active = false,
onClick = onInsertImage,
)
}
}
}
@@ -534,6 +560,26 @@ internal fun applyAlignment(
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
}
/** Inserts an inline-image token at the caret and lands the selection just past it (for toolbar wiring). */
internal fun applyImageInsert(
value: TextFieldValue,
contentId: String,
name: String,
linkColor: Color,
resolveFont: (String) -> FontFamily? = { null },
): TextFieldValue {
val result = RichTextEditing.insertImage(
value.annotatedString.toRichContent(),
value.selection.min,
contentId,
name,
)
return TextFieldValue(
result.content.toAnnotatedString(linkColor, resolveFont),
TextRange(result.selectionStart, result.selectionEnd),
)
}
/**
* Clears every span of style kind [kind] over the selection - the color pickers' "no color"/"none"
* entry, which must remove the style outright regardless of its value. [applyStyle] cannot do this:
+1
View File
@@ -129,6 +129,7 @@
<string name="format_align_end">Align end</string>
<string name="format_font">Font</string>
<string name="format_font_default">Default</string>
<string name="format_image">Insert image</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
@@ -0,0 +1,67 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import org.junit.Test
import org.libremail.data.local.entity.DraftEntity
import org.libremail.domain.model.Draft
import org.libremail.domain.model.OutgoingAttachment
import kotlin.test.assertEquals
/**
* The draft attachment JSON mapper must preserve an inline image's cid↔file pairing across a save and
* reopen (#77), while still reading an older draft (written before inline images) as a plain
* attachment. The same `{uri, name, contentId?, isInline?}` serialization also backs the outbox
* column, so this pins the wire format both use.
*/
class OutgoingAttachmentMapperTest {
@Test
fun `draft attachments including an inline image round-trip through the entity`() {
val draft = Draft(
id = "d1",
accountId = "acct",
to = "bob@example.org",
cc = "",
subject = "With image",
body = "See [image: cat.png]",
updatedAt = 1_000L,
bodyHtml = "<p>See <img src=\"cid:cat@libremail\" alt=\"cat.png\"></p>",
attachments = listOf(
OutgoingAttachment("content://docs/report", "report.pdf"),
OutgoingAttachment("content://images/cat", "cat.png", contentId = "cat@libremail", isInline = true),
),
)
val restored = draft.toEntity().toDomain()
assertEquals(draft.attachments, restored.attachments)
assertEquals(draft.bodyHtml, restored.bodyHtml)
}
@Test
fun `a draft json written before inline images reads back as a plain attachment`() {
val legacy = DraftEntity(
id = "d",
accountId = null,
toAddresses = "x@y.org",
ccAddresses = "",
bccAddresses = "",
subject = "",
body = "",
updatedAt = 0L,
attachments = """[{"uri":"content://f","name":"a.txt"}]""",
bodyHtml = null,
)
assertEquals(
OutgoingAttachment("content://f", "a.txt", contentId = null, isInline = false),
legacy.toDomain().attachments.single(),
)
}
@Test
fun `no attachments serialize to the empty string`() {
assertEquals("", emptyList<OutgoingAttachment>().toOutgoingAttachmentsJson())
assertEquals(emptyList(), "".toOutgoingAttachments())
}
}
@@ -93,7 +93,8 @@ class GraphSenderTest {
fun `payload omits cc and bcc when blank and encodes attachments as base64`() {
val file = File.createTempFile("graph-att", ".txt").apply { writeText("hello") }
try {
val msg = JSONObject(buildSendMailPayload(message(to = "a@x.com"), listOf(file))).getJSONObject("message")
val msg = JSONObject(buildSendMailPayload(message(to = "a@x.com"), listOf(SendableAttachment(file))))
.getJSONObject("message")
assertFalse(msg.has("ccRecipients"))
assertFalse(msg.has("bccRecipients"))
@@ -103,8 +104,27 @@ class GraphSenderTest {
assertEquals("#microsoft.graph.fileAttachment", attachment.getString("@odata.type"))
assertEquals(file.name, attachment.getString("name"))
assertEquals("aGVsbG8=", attachment.getString("contentBytes")) // base64("hello")
// A plain attachment carries no inline markers.
assertFalse(attachment.has("isInline"))
assertFalse(attachment.has("contentId"))
} finally {
file.delete()
}
}
@Test
fun `an inline image attachment is marked isInline with its contentId`() {
val image = File.createTempFile("graph-inline", ".png").apply { writeText("PNGDATA") }
try {
val message = message(to = "a@x.com").copy(bodyHtml = "<p><img src=\"cid:logo@libremail\"></p>")
val inline = SendableAttachment(image, contentId = "logo@libremail", isInline = true)
val attachment = JSONObject(buildSendMailPayload(message, listOf(inline)))
.getJSONObject("message").getJSONArray("attachments").getJSONObject(0)
assertEquals(image.name, attachment.getString("name"))
assertTrue(attachment.getBoolean("isInline"))
assertEquals("logo@libremail", attachment.getString("contentId"))
} finally {
image.delete()
}
}
}
@@ -117,7 +117,7 @@ class SmtpSenderTest {
body = "Body",
bodyHtml = "<p><b>Body</b></p>",
),
attachments = listOf(file),
attachments = listOf(SendableAttachment(file)),
)
greenMail.waitForIncomingEmail(1)
@@ -130,6 +130,42 @@ class SmtpSenderTest {
file.delete()
}
@Test
fun `an inline image is sent as multipart related with a matching Content-ID`() = runTest {
val image = File.createTempFile("libremail-inline", ".png").apply { writeText("PNGDATA") }
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 = "Inline",
body = "See image",
bodyHtml = "<p>See <img src=\"cid:logo@libremail\" alt=\"logo\"></p>",
),
attachments = listOf(SendableAttachment(image, contentId = "logo@libremail", isInline = true)),
)
greenMail.waitForIncomingEmail(1)
val received = greenMail.receivedMessages.single()
assertTrue(received.contentType.contains("multipart/related", ignoreCase = true), received.contentType)
val raw = GreenMailUtil.getWholeMessage(received)
// The HTML's cid reference and the image part's Content-ID must name the same content id.
assertTrue(raw.contains("cid:logo@libremail"), "html must reference the cid")
assertTrue(raw.contains("<logo@libremail>"), "inline part must carry a matching Content-ID")
assertTrue(raw.contains("multipart/alternative", ignoreCase = true), "the body stays an alternative")
image.delete()
}
@Test
fun `send delivers a message with an attachment`() = runTest {
val file = File.createTempFile("libremail-report", ".txt").apply { writeText("quarterly numbers") }
@@ -151,7 +187,7 @@ class SmtpSenderTest {
subject = "With file",
body = "See the attached report.",
),
attachments = listOf(file),
attachments = listOf(SendableAttachment(file)),
)
greenMail.waitForIncomingEmail(1)
@@ -232,4 +232,27 @@ class RichTextEditingTest {
fun `alignmentAt treats a plain paragraph as START`() {
assertEquals(RichAlign.START, RichTextEditing.alignmentAt(RichTextContent("plain"), 0, 5))
}
// --- insertImage ---
@Test
fun `insertImage drops a token and RichImage at the caret and shifts later spans`() {
val base = RichTextContent("ab", spans = listOf(RichSpan(1, 2, RichStyle.Bold)))
val result = RichTextEditing.insertImage(base, 1, "cid1@x", "pic")
val token = imageToken("pic")
assertEquals("a${token}b", result.content.text)
assertEquals(listOf(RichImage(1, 1 + token.length, "cid1@x", "pic")), result.content.images)
// The bold run that began at the caret shifts entirely past the inserted token.
assertEquals(listOf(RichSpan(1 + token.length, 2 + token.length, RichStyle.Bold)), result.content.spans)
assertEquals(1 + token.length, result.selectionStart)
assertEquals(1 + token.length, result.selectionEnd)
}
@Test
fun `insertImage output round-trips its image through html`() {
val result = RichTextEditing.insertImage(RichTextContent("hi"), 2, "c@x", "cat.png")
val restored = RichTextHtml.fromHtml(RichTextHtml.toHtml(result.content))
assertEquals(result.content.text, restored.text)
assertEquals(result.content.images, restored.images)
}
}