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 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 16:54:39 -05:00
co-authored by Claude Opus 4.8
parent 5c98f3ba1c
commit f65226a4d2
9 changed files with 161 additions and 7 deletions
@@ -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<Unit> = 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<OutgoingAttachment>) {
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<List<Draft>> =
draftDao.observeAll().map { rows -> rows.map { it.toDomain() } }
@@ -120,7 +138,10 @@ class MailRepositoryImpl @Inject constructor(
override fun observeOutbox(): Flow<List<OutboxMessage>> =
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()
@@ -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
@@ -8,4 +8,11 @@ data class OutgoingMessage(
val cc: String = "",
val subject: String,
val body: String,
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,
)
@@ -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<File> = 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)
@@ -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<Account>, selectedId: String?, onSelect: (Str
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AttachmentsSection(
attachments: List<OutgoingAttachment>,
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<org.libremail.contacts.ContactSuggestion>,
@@ -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<OutgoingAttachment> = emptyList(),
val suggestions: List<ContactSuggestion> = 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<OutgoingAttachment>) =
_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) }
+2
View File
@@ -40,6 +40,8 @@
<string name="compose_subject">Subject</string>
<string name="compose_body">Message</string>
<string name="compose_no_account">Add an account before composing</string>
<string name="compose_attach">Attach file</string>
<string name="compose_attachment_remove">Remove attachment</string>
<!-- Drafts -->
<string name="drafts_title">Drafts</string>
@@ -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()
}
}