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:
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user