From f65226a4d2e71544bbfcd3d96e1cd47397c7ebda Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Sat, 27 Jun 2026 16:54:39 -0500 Subject: [PATCH] Send attachments - Compose gains an "Attach file" picker (OpenMultipleDocuments) and shows each pick as a removable chip; OutgoingMessage carries the picked URIs. - On send the repository copies the picked files into the outbox message's own cache directory; SendWorker passes them to SmtpSender, which builds a multipart message (text body + a part per file via attachFile). Files are cleaned up on success/cancel. - assemble/test/lint green, including a new SmtpSender test that sends an attachment and asserts GreenMail received a multipart message containing it; the compose "Attach file" affordance verified on the Android 17 emulator. Co-Authored-By: Claude Opus 4.8 --- README.md | 3 +- .../data/repository/MailRepositoryImpl.kt | 25 +++++++- .../org/libremail/data/sync/SendWorker.kt | 9 ++- .../libremail/domain/model/OutgoingMessage.kt | 7 +++ .../kotlin/org/libremail/mail/SmtpSender.kt | 21 ++++++- .../org/libremail/ui/compose/ComposeScreen.kt | 59 +++++++++++++++++++ .../libremail/ui/compose/ComposeViewModel.kt | 8 ++- app/src/main/res/values/strings.xml | 2 + .../org/libremail/mail/SmtpSenderTest.kt | 34 +++++++++++ 9 files changed, 161 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index d404498..7eed124 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,8 @@ experience with power-user features tucked under an **Advanced Settings** group. > later; **on-device new-mail > notifications** (no push service) with persisted settings; **instant push** via a > foreground **IMAP IDLE** service; **attachments** — downloaded on demand and opened in a -> system viewer; **multiple accounts** — a unified inbox with per-account filtering; and +> system viewer, and attach files when composing; **multiple accounts** — a unified inbox +> with per-account filtering; and > **search** across cached mail. Outlook Graph send is upcoming. ## Features (target MVP) diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index a9697f5..abec432 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -2,6 +2,7 @@ package org.libremail.data.repository import android.content.Context +import android.net.Uri import dagger.hilt.android.qualifiers.ApplicationContext import jakarta.mail.Flags import java.io.File @@ -25,6 +26,7 @@ import org.libremail.domain.model.Attachment import org.libremail.domain.model.Draft import org.libremail.domain.model.Message import org.libremail.domain.model.OutboxMessage +import org.libremail.domain.model.OutgoingAttachment import org.libremail.domain.model.OutgoingMessage import org.libremail.domain.repository.MailRepository import org.libremail.mail.DownloadedAttachment @@ -94,9 +96,11 @@ class MailRepositoryImpl @Inject constructor( /** Queues the message in the outbox and triggers the send worker; delivery happens in the background. */ override suspend fun sendMessage(outgoing: OutgoingMessage): Result = runCatching { requireNotNull(accountDao.getById(outgoing.accountId)) { "Account not found" } + val outboxId = UUID.randomUUID().toString() + copyAttachments(outboxId, outgoing.attachments) outboxDao.insert( OutboxEntity( - id = UUID.randomUUID().toString(), + id = outboxId, accountId = outgoing.accountId, toAddresses = outgoing.to, ccAddresses = outgoing.cc, @@ -108,6 +112,20 @@ class MailRepositoryImpl @Inject constructor( sendScheduler.sendNow() } + /** Copies the picked attachment URIs into the outbox message's own directory for the worker. */ + private fun copyAttachments(outboxId: String, attachments: List) { + if (attachments.isEmpty()) return + val dir = File(context.cacheDir, "outbox/$outboxId").apply { mkdirs() } + attachments.forEach { attachment -> + val safeName = attachment.name.substringAfterLast('/').substringAfterLast('\\').ifBlank { "attachment" } + runCatching { + context.contentResolver.openInputStream(Uri.parse(attachment.uri))?.use { input -> + File(dir, safeName).outputStream().use { output -> input.copyTo(output) } + } + } + } + } + override fun observeDrafts(): Flow> = draftDao.observeAll().map { rows -> rows.map { it.toDomain() } } @@ -120,7 +138,10 @@ class MailRepositoryImpl @Inject constructor( override fun observeOutbox(): Flow> = outboxDao.observeAll().map { rows -> rows.map { it.toDomain() } } - override suspend fun cancelOutboxMessage(id: String) = outboxDao.delete(id) + override suspend fun cancelOutboxMessage(id: String) { + outboxDao.delete(id) + File(context.cacheDir, "outbox/$id").deleteRecursively() + } override suspend fun retryOutbox() = sendScheduler.sendNow() diff --git a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt index 73afcd3..ada49ce 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/SendWorker.kt @@ -7,6 +7,7 @@ import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import java.io.File import org.libremail.data.local.dao.AccountDao import org.libremail.data.local.dao.OutboxDao import org.libremail.data.local.toDomain @@ -30,9 +31,11 @@ class SendWorker @AssistedInject constructor( var anyFailed = false for (entity in pending) { + val attachmentDir = File(applicationContext.cacheDir, "outbox/${entity.id}") val account = accountDao.getById(entity.accountId)?.toDomain() if (account == null) { outboxDao.delete(entity.id) // account removed — drop the queued message + attachmentDir.deleteRecursively() continue } runCatching { @@ -46,9 +49,13 @@ class SendWorker @AssistedInject constructor( subject = entity.subject, body = entity.body, ), + attachments = attachmentDir.listFiles()?.toList().orEmpty(), ) }.fold( - onSuccess = { outboxDao.delete(entity.id) }, + onSuccess = { + outboxDao.delete(entity.id) + attachmentDir.deleteRecursively() + }, onFailure = { e -> outboxDao.setError(entity.id, e.message) anyFailed = true diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt index 7fd397e..89ba3c7 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt @@ -8,4 +8,11 @@ data class OutgoingMessage( val cc: String = "", val subject: String, val body: String, + val attachments: List = 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, ) diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt index 5048452..dc7c612 100644 --- a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -4,7 +4,10 @@ package org.libremail.mail import jakarta.mail.Message import jakarta.mail.Session import jakarta.mail.internet.InternetAddress +import jakarta.mail.internet.MimeBodyPart import jakarta.mail.internet.MimeMessage +import jakarta.mail.internet.MimeMultipart +import java.io.File import java.util.Date import java.util.Properties import javax.inject.Inject @@ -19,7 +22,12 @@ import org.libremail.domain.model.SmtpParams @Singleton class SmtpSender @Inject constructor() { - suspend fun send(params: SmtpParams, from: String, message: OutgoingMessage) = + suspend fun send( + params: SmtpParams, + from: String, + message: OutgoingMessage, + attachments: List = emptyList(), + ) = withContext(Dispatchers.IO) { val protocol = if (params.security == MailSecurity.SSL_TLS) "smtps" else "smtp" val props = Properties().apply { @@ -50,9 +58,18 @@ class SmtpSender @Inject constructor() { setRecipients(Message.RecipientType.CC, InternetAddress.parse(message.cc)) } subject = message.subject - setText(message.body, "UTF-8") 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) + } val transport = session.getTransport(protocol) transport.connect(params.host, params.port, params.username, params.secret) diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index 097d81a..77f57c6 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -2,7 +2,11 @@ package org.libremail.ui.compose import android.Manifest +import android.content.Context +import android.content.Intent import android.content.pm.PackageManager +import android.net.Uri +import android.provider.OpenableColumns import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -20,7 +24,9 @@ import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem @@ -28,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -56,6 +63,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import kotlinx.coroutines.flow.collect import org.libremail.R import org.libremail.domain.model.Account +import org.libremail.domain.model.OutgoingAttachment @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -72,6 +80,19 @@ fun ComposeScreen( ActivityResultContracts.RequestPermission(), ) { granted -> viewModel.onContactsPermission(granted) } + val attachmentPicker = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments(), + ) { uris -> + viewModel.addAttachments( + uris.map { uri -> + runCatching { + context.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + OutgoingAttachment(uri.toString(), queryFileName(context, uri)) + }, + ) + } + LaunchedEffect(Unit) { val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) == PackageManager.PERMISSION_GRANTED @@ -141,6 +162,11 @@ fun ComposeScreen( singleLine = true, modifier = Modifier.fillMaxWidth(), ) + AttachmentsSection( + attachments = state.attachments, + onAttach = { attachmentPicker.launch(arrayOf("*/*")) }, + onRemove = viewModel::removeAttachment, + ) Spacer(Modifier.height(8.dp)) OutlinedTextField( value = state.body, @@ -197,6 +223,39 @@ private fun FromRow(accounts: List, selectedId: String?, onSelect: (Str } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AttachmentsSection( + attachments: List, + onAttach: () -> Unit, + onRemove: (String) -> Unit, +) { + Column(Modifier.fillMaxWidth()) { + TextButton(onClick = onAttach) { + Icon(Icons.Filled.Add, contentDescription = null) + Spacer(Modifier.width(4.dp)) + Text(stringResource(R.string.compose_attach)) + } + attachments.forEach { attachment -> + InputChip( + selected = false, + onClick = { onRemove(attachment.uri) }, + label = { Text(attachment.name, maxLines = 1) }, + trailingIcon = { + Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.compose_attachment_remove)) + }, + ) + } + } +} + +private fun queryFileName(context: Context, uri: Uri): String { + val name = context.contentResolver + .query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + return name ?: uri.lastPathSegment?.substringAfterLast('/') ?: "attachment" +} + @Composable private fun SuggestionList( suggestions: List, diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 4f7942b..1fa05f0 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -21,6 +21,7 @@ import org.libremail.contacts.ContactSuggestion import org.libremail.contacts.ContactsRepository 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 @@ -32,6 +33,7 @@ data class ComposeUiState( val subject: String = "", val body: String = "", val fromAccountId: String? = null, + val attachments: List = emptyList(), val suggestions: List = emptyList(), val contactsAllowed: Boolean = false, val sending: Boolean = false, @@ -94,6 +96,10 @@ class ComposeViewModel @Inject constructor( fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) } fun onBodyChange(value: String) = _state.update { it.copy(body = value) } fun selectFrom(accountId: String) = _state.update { it.copy(fromAccountId = accountId) } + fun addAttachments(items: List) = + _state.update { it.copy(attachments = it.attachments + items) } + fun removeAttachment(uri: String) = + _state.update { it.copy(attachments = it.attachments.filterNot { a -> a.uri == uri }) } fun consumeError() = _state.update { it.copy(error = null) } fun onContactsPermission(granted: Boolean) = _state.update { it.copy(contactsAllowed = granted) } @@ -153,7 +159,7 @@ class ComposeViewModel @Inject constructor( else -> viewModelScope.launch { _state.update { it.copy(sending = true, error = null) } mailRepository.sendMessage( - OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body), + OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body, s.attachments), ).fold( onSuccess = { draftId?.let { mailRepository.deleteDraft(it) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 36b0a82..e8cf5cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -40,6 +40,8 @@ Subject Message Add an account before composing + Attach file + Remove attachment Drafts diff --git a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt index 47f7a95..acadaf0 100644 --- a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt +++ b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt @@ -4,6 +4,7 @@ package org.libremail.mail import com.icegreen.greenmail.util.GreenMail import com.icegreen.greenmail.util.GreenMailUtil import com.icegreen.greenmail.util.ServerSetupTest +import java.io.File import kotlin.test.assertEquals import kotlin.test.assertTrue import kotlinx.coroutines.test.runTest @@ -59,4 +60,37 @@ class SmtpSenderTest { assertEquals("Hi Bob", received[0].subject) assertTrue(GreenMailUtil.getBody(received[0]).contains("Hello there")) } + + @Test + fun `send delivers a message with an attachment`() = runTest { + val file = File.createTempFile("libremail-report", ".txt").apply { writeText("quarterly numbers") } + 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 = "With file", + body = "See the attached report.", + ), + attachments = listOf(file), + ) + + greenMail.waitForIncomingEmail(1) + val received = greenMail.receivedMessages.single() + assertTrue(received.contentType.contains("multipart", ignoreCase = true)) + val raw = GreenMailUtil.getWholeMessage(received) + assertTrue(raw.contains("See the attached report.")) + assertTrue(raw.contains(file.name)) + file.delete() + } }