Add composing and sending: SMTP send + contacts + reply

Increment 5 — send.

- SmtpSender (Angus Mail; password/XOAUTH2) builds a MimeMessage and sends over
  SMTP/SMTPS. New OutgoingMessage + SmtpParams.
- MailConnectionFactory now resolves both IMAP and SMTP params (shared credential
  and token refresh); MailRepository.sendMessage.
- Compose screen wired to send: From account (a selector when there are several),
  To with device-contacts autocomplete (ContactsContract, runtime READ_CONTACTS),
  Cc, Subject, Body, with progress and error handling.
- Reply from the reader prefills To and a "Re:" subject (compose route gains optional
  to/subject args).
- Tests: GreenMail SmtpSender unit test. assemble/test/lint green; verified end-to-end
  on the Android 17 emulator — composed a message, sent it over SMTP to a local
  GreenMail server, and it round-tripped back into the inbox on re-sync.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 19:58:18 -05:00
co-authored by Claude Opus 4.8
parent d81e94717b
commit 22ddc08a24
19 changed files with 532 additions and 55 deletions
+1
View File
@@ -4,6 +4,7 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<application
android:name=".LibreMailApplication"
@@ -0,0 +1,56 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.contacts
import android.content.Context
import android.provider.ContactsContract.CommonDataKinds.Email
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/** A device contact match for recipient autocomplete. */
data class ContactSuggestion(
val name: String,
val email: String,
)
/** Looks up device contacts (ContactsContract) for recipient autocomplete. */
@Singleton
class ContactsRepository @Inject constructor(
@ApplicationContext private val context: Context,
) {
/** Returns up to [LIMIT] contacts whose name or email matches [query]. Empty if no permission. */
suspend fun search(query: String): List<ContactSuggestion> = withContext(Dispatchers.IO) {
if (query.length < 2) return@withContext emptyList()
val projection = arrayOf(Email.ADDRESS, Email.DISPLAY_NAME_PRIMARY)
val selection = "${Email.ADDRESS} LIKE ? OR ${Email.DISPLAY_NAME_PRIMARY} LIKE ?"
val pattern = "%$query%"
val results = mutableListOf<ContactSuggestion>()
runCatching {
context.contentResolver.query(
Email.CONTENT_URI,
projection,
selection,
arrayOf(pattern, pattern),
"${Email.DISPLAY_NAME_PRIMARY} ASC",
)?.use { cursor ->
val addressIndex = cursor.getColumnIndexOrThrow(Email.ADDRESS)
val nameIndex = cursor.getColumnIndexOrThrow(Email.DISPLAY_NAME_PRIMARY)
val seen = HashSet<String>()
while (cursor.moveToNext() && results.size < LIMIT) {
val email = cursor.getString(addressIndex)?.trim().orEmpty()
if (email.isEmpty() || !seen.add(email.lowercase())) continue
val name = cursor.getString(nameIndex)?.takeIf { it.isNotBlank() } ?: email
results.add(ContactSuggestion(name, email))
}
}
}
results
}
private companion object {
const val LIMIT = 8
}
}
@@ -10,6 +10,7 @@ import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.Message
import org.libremail.domain.model.ServerConfig
import org.libremail.domain.model.SmtpParams
import org.libremail.mail.FetchedMessage
internal fun AccountEntity.toDomain(): Account = Account(
@@ -40,6 +41,16 @@ internal fun Account.toImapParams(secret: String, useXoauth2: Boolean): ImapConn
useXoauth2 = useXoauth2,
)
internal fun Account.toSmtpParams(secret: String, useXoauth2: Boolean): SmtpParams =
SmtpParams(
host = smtp.host,
port = smtp.port,
security = smtp.security,
username = email,
secret = secret,
useXoauth2 = useXoauth2,
)
internal fun MessageEntity.toDomain(): Message = Message(
id = id,
accountId = accountId,
@@ -12,14 +12,17 @@ import org.libremail.data.local.toDomain
import org.libremail.data.sync.MailConnectionFactory
import org.libremail.domain.model.Account
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.MailRepository
import org.libremail.mail.ImapClient
import org.libremail.mail.SmtpSender
@Singleton
class MailRepositoryImpl @Inject constructor(
private val messageDao: MessageDao,
private val accountDao: AccountDao,
private val imapClient: ImapClient,
private val smtpSender: SmtpSender,
private val connectionFactory: MailConnectionFactory,
) : MailRepository {
@@ -32,7 +35,7 @@ class MailRepositoryImpl @Inject constructor(
val entity = messageDao.getById(id) ?: error("Message not found")
val account = accountDao.getById(entity.accountId)?.toDomain()
if (account != null) {
val params = connectionFactory.paramsFor(account)
val params = connectionFactory.imapParamsFor(account)
if (entity.body.isBlank()) {
val content = imapClient.fetchBodyMarkingSeen(params, uidOf(id))
messageDao.updateBody(id, content.body, content.isHtml, snippetOf(content.body))
@@ -48,7 +51,7 @@ class MailRepositoryImpl @Inject constructor(
override suspend fun setStarred(id: String, starred: Boolean): Result<Unit> = runCatching {
messageDao.setStarred(id, starred) // optimistic; next sync reconciles on failure
accountFor(id)?.let { account ->
imapClient.setFlag(connectionFactory.paramsFor(account), uidOf(id), Flags.Flag.FLAGGED, starred)
imapClient.setFlag(connectionFactory.imapParamsFor(account), uidOf(id), Flags.Flag.FLAGGED, starred)
}
Unit
}
@@ -56,10 +59,15 @@ class MailRepositoryImpl @Inject constructor(
override suspend fun deleteMessage(id: String): Result<Unit> = runCatching {
val account = accountFor(id)
messageDao.deleteById(id) // optimistic; reappears on next sync if the server delete failed
account?.let { imapClient.deleteMessage(connectionFactory.paramsFor(it), uidOf(id)) }
account?.let { imapClient.deleteMessage(connectionFactory.imapParamsFor(it), uidOf(id)) }
Unit
}
override suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit> = runCatching {
val account = accountDao.getById(outgoing.accountId)?.toDomain() ?: error("Account not found")
smtpSender.send(connectionFactory.smtpParamsFor(account), from = account.email, message = outgoing)
}
private suspend fun accountFor(id: String): Account? {
val entity = messageDao.getById(id) ?: return null
return accountDao.getById(entity.accountId)?.toDomain()
@@ -5,21 +5,29 @@ import javax.inject.Inject
import javax.inject.Singleton
import org.libremail.auth.GmailAuthManager
import org.libremail.data.local.toImapParams
import org.libremail.data.local.toSmtpParams
import org.libremail.data.security.CredentialStore
import org.libremail.domain.model.Account
import org.libremail.domain.model.AuthType
import org.libremail.domain.model.ImapConnectionParams
import org.libremail.domain.model.SmtpParams
/** Resolves an account's stored credential (refreshing the Gmail token when needed) into IMAP params. */
/** Resolves an account's stored credential (refreshing the Gmail token when needed) into connection params. */
@Singleton
class MailConnectionFactory @Inject constructor(
private val credentialStore: CredentialStore,
private val gmailAuthManager: GmailAuthManager,
) {
suspend fun paramsFor(account: Account): ImapConnectionParams {
suspend fun imapParamsFor(account: Account): ImapConnectionParams =
account.toImapParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL)
suspend fun smtpParamsFor(account: Account): SmtpParams =
account.toSmtpParams(resolveSecret(account), account.authType == AuthType.OAUTH_GMAIL)
private suspend fun resolveSecret(account: Account): String {
val stored = credentialStore.loadSecret(account.id)
?: error("No stored credentials for ${account.email}")
val secret = when (account.authType) {
return when (account.authType) {
AuthType.PASSWORD_IMAP -> stored
AuthType.OAUTH_GMAIL -> {
val fresh = gmailAuthManager.freshAccessToken(stored)
@@ -29,6 +37,5 @@ class MailConnectionFactory @Inject constructor(
fresh.accessToken
}
}
return account.toImapParams(secret, useXoauth2 = account.authType == AuthType.OAUTH_GMAIL)
}
}
@@ -36,7 +36,7 @@ class MailSyncer @Inject constructor(
}
private suspend fun syncAccount(account: Account): Result<Int> = runCatching {
val params = connectionFactory.paramsFor(account)
val params = connectionFactory.imapParamsFor(account)
val fetched = imapClient.fetchRecentInbox(params, INBOX_LIMIT)
val entities = fetched.map { it.toEntity(account.id) }
if (entities.isEmpty()) {
@@ -0,0 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/** A message the user is sending. [to]/[cc] are comma-separated address lists. */
data class OutgoingMessage(
val accountId: String,
val to: String,
val cc: String = "",
val subject: String,
val body: String,
)
@@ -0,0 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.domain.model
/** Everything needed for a single SMTP send attempt (transient; not persisted). */
data class SmtpParams(
val host: String,
val port: Int,
val security: MailSecurity,
val username: String,
/** Password, app-password, or — when [useXoauth2] is true — an OAuth access token. */
val secret: String,
val useXoauth2: Boolean,
)
@@ -3,6 +3,7 @@ package org.libremail.domain.repository
import kotlinx.coroutines.flow.Flow
import org.libremail.domain.model.Message
import org.libremail.domain.model.OutgoingMessage
/**
* Abstraction over the local message cache (and, in later increments, network sync).
@@ -19,4 +20,6 @@ interface MailRepository {
suspend fun setStarred(id: String, starred: Boolean): Result<Unit>
suspend fun deleteMessage(id: String): Result<Unit>
suspend fun sendMessage(outgoing: OutgoingMessage): Result<Unit>
}
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import jakarta.mail.Message
import jakarta.mail.Session
import jakarta.mail.internet.InternetAddress
import jakarta.mail.internet.MimeMessage
import java.util.Date
import java.util.Properties
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.SmtpParams
/** Sends mail via SMTP over Jakarta/Angus Mail. Supports password and XOAUTH2 auth. */
@Singleton
class SmtpSender @Inject constructor() {
suspend fun send(params: SmtpParams, from: String, message: OutgoingMessage) =
withContext(Dispatchers.IO) {
val protocol = if (params.security == MailSecurity.SSL_TLS) "smtps" else "smtp"
val props = Properties().apply {
put("mail.transport.protocol", protocol)
put("mail.$protocol.host", params.host)
put("mail.$protocol.port", params.port.toString())
put("mail.$protocol.auth", "true")
put("mail.$protocol.connectiontimeout", TIMEOUT_MS)
put("mail.$protocol.timeout", TIMEOUT_MS)
put("mail.$protocol.writetimeout", TIMEOUT_MS)
if (params.security == MailSecurity.SSL_TLS) {
put("mail.$protocol.ssl.enable", "true")
}
if (params.security == MailSecurity.STARTTLS) {
put("mail.$protocol.starttls.enable", "true")
put("mail.$protocol.starttls.required", "true")
}
if (params.useXoauth2) {
put("mail.$protocol.auth.mechanisms", "XOAUTH2")
}
}
val session = Session.getInstance(props)
val mime = MimeMessage(session).apply {
setFrom(InternetAddress(from))
setRecipients(Message.RecipientType.TO, InternetAddress.parse(message.to))
if (message.cc.isNotBlank()) {
setRecipients(Message.RecipientType.CC, InternetAddress.parse(message.cc))
}
subject = message.subject
setText(message.body, "UTF-8")
sentDate = Date()
}
val transport = session.getTransport(protocol)
transport.connect(params.host, params.port, params.username, params.secret)
try {
transport.sendMessage(mime, mime.allRecipients)
} finally {
runCatching { transport.close() }
}
}
private companion object {
const val TIMEOUT_MS = "15000"
}
}
@@ -44,7 +44,7 @@ fun LibreMailApp() {
composable(Routes.MAILBOX) {
MailboxScreen(
onOpenMessage = { id -> navController.navigate(Routes.reader(id)) },
onCompose = { navController.navigate(Routes.COMPOSE) },
onCompose = { navController.navigate(Routes.compose()) },
onAddAccount = { navController.navigate(Routes.ACCOUNT_SETUP) },
onSelectTab = navController::navigateTab,
)
@@ -53,9 +53,18 @@ fun LibreMailApp() {
route = Routes.READER_PATTERN,
arguments = listOf(navArgument(Routes.READER_ARG_ID) { type = NavType.StringType }),
) {
ReaderScreen(onBack = navController::popBackStack)
ReaderScreen(
onBack = navController::popBackStack,
onReply = { to, subject -> navController.navigate(Routes.compose(to, subject)) },
)
}
composable(Routes.COMPOSE) {
composable(
route = Routes.COMPOSE_PATTERN,
arguments = listOf(
navArgument(Routes.COMPOSE_ARG_TO) { type = NavType.StringType; defaultValue = "" },
navArgument(Routes.COMPOSE_ARG_SUBJECT) { type = NavType.StringType; defaultValue = "" },
),
) {
ComposeScreen(onBack = navController::popBackStack)
}
composable(Routes.SETTINGS) {
@@ -1,35 +1,59 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.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.ArrowDropDown
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
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
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.foundation.text.KeyboardOptions
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch
import org.libremail.R
import org.libremail.domain.model.Account
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -38,9 +62,26 @@ fun ComposeScreen(
viewModel: ComposeViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
val notImplemented = stringResource(R.string.compose_not_implemented)
val context = LocalContext.current
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> viewModel.onContactsPermission(granted) }
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
if (granted) viewModel.onContactsPermission(true) else permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
LaunchedEffect(state.sent) { if (state.sent) onBack() }
LaunchedEffect(state.error) {
state.error?.let {
snackbarHostState.showSnackbar(it)
viewModel.consumeError()
}
}
Scaffold(
topBar = {
@@ -48,49 +89,134 @@ fun ComposeScreen(
title = { Text(stringResource(R.string.title_compose)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
IconButton(onClick = { scope.launch { snackbarHostState.showSnackbar(notImplemented) } }) {
Icon(
Icons.AutoMirrored.Filled.Send,
contentDescription = stringResource(R.string.action_send),
)
IconButton(
onClick = viewModel::send,
enabled = state.to.isNotBlank() && !state.sending,
) {
Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.action_send))
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier.fillMaxSize().padding(padding).padding(16.dp),
) {
OutlinedTextField(
value = state.to,
onValueChange = viewModel::onToChange,
label = { Text(stringResource(R.string.compose_to)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.subject,
onValueChange = viewModel::onSubjectChange,
label = { Text(stringResource(R.string.compose_subject)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = state.body,
onValueChange = viewModel::onBodyChange,
label = { Text(stringResource(R.string.compose_body)) },
modifier = Modifier.fillMaxWidth().weight(1f),
)
Box(Modifier.fillMaxSize().padding(padding)) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
FromRow(accounts = accounts, selectedId = state.fromAccountId, onSelect = viewModel::selectFrom)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.to,
onValueChange = viewModel::onToChange,
label = { Text(stringResource(R.string.compose_to)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
if (state.suggestions.isNotEmpty()) {
SuggestionList(state.suggestions, viewModel::pickSuggestion)
}
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.cc,
onValueChange = viewModel::onCcChange,
label = { Text(stringResource(R.string.compose_cc)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.subject,
onValueChange = viewModel::onSubjectChange,
label = { Text(stringResource(R.string.compose_subject)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = state.body,
onValueChange = viewModel::onBodyChange,
label = { Text(stringResource(R.string.compose_body)) },
modifier = Modifier.fillMaxWidth().weight(1f),
)
}
if (state.sending) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator()
}
}
}
}
}
@Composable
private fun FromRow(accounts: List<Account>, selectedId: String?, onSelect: (String) -> Unit) {
val from = accounts.firstOrNull { it.id == selectedId } ?: accounts.firstOrNull()
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(R.string.compose_from) + ": ",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (from == null) {
Text(stringResource(R.string.compose_no_account), style = MaterialTheme.typography.bodyMedium)
} else if (accounts.size <= 1) {
Text(from.email, style = MaterialTheme.typography.bodyMedium)
} else {
var open by remember { mutableStateOf(false) }
Box {
TextButton(onClick = { open = true }) {
Text(from.email)
Icon(Icons.Filled.ArrowDropDown, contentDescription = null)
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
accounts.forEach { account ->
DropdownMenuItem(
text = { Text(account.email) },
onClick = { onSelect(account.id); open = false },
)
}
}
}
}
}
}
@Composable
private fun SuggestionList(
suggestions: List<org.libremail.contacts.ContactSuggestion>,
onPick: (org.libremail.contacts.ContactSuggestion) -> Unit,
) {
Surface(tonalElevation = 2.dp, modifier = Modifier.fillMaxWidth()) {
Column {
suggestions.forEach { suggestion ->
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onPick(suggestion) }
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
Text(suggestion.name, style = MaterialTheme.typography.bodyMedium)
Text(
suggestion.email,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
HorizontalDivider()
}
}
}
}
@@ -1,26 +1,108 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.compose
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.contacts.ContactSuggestion
import org.libremail.contacts.ContactsRepository
import org.libremail.domain.model.Account
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.repository.AccountRepository
import org.libremail.domain.repository.MailRepository
import org.libremail.ui.navigation.Routes
data class ComposeUiState(
val to: String = "",
val cc: String = "",
val subject: String = "",
val body: String = "",
val fromAccountId: String? = null,
val suggestions: List<ContactSuggestion> = emptyList(),
val contactsAllowed: Boolean = false,
val sending: Boolean = false,
val sent: Boolean = false,
val error: String? = null,
)
@HiltViewModel
class ComposeViewModel @Inject constructor() : ViewModel() {
private val _state = MutableStateFlow(ComposeUiState())
class ComposeViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val mailRepository: MailRepository,
accountRepository: AccountRepository,
private val contactsRepository: ContactsRepository,
) : ViewModel() {
private val _state = MutableStateFlow(
ComposeUiState(
to = savedStateHandle.get<String>(Routes.COMPOSE_ARG_TO).orEmpty(),
subject = savedStateHandle.get<String>(Routes.COMPOSE_ARG_SUBJECT).orEmpty(),
),
)
val state: StateFlow<ComposeUiState> = _state.asStateFlow()
fun onToChange(value: String) = _state.update { it.copy(to = value) }
val accounts: StateFlow<List<Account>> = accountRepository.observeAccounts()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
private var searchJob: Job? = null
fun onToChange(value: String) {
_state.update { it.copy(to = value) }
searchContacts(value)
}
fun onCcChange(value: String) = _state.update { it.copy(cc = value) }
fun onSubjectChange(value: String) = _state.update { it.copy(subject = value) }
fun onBodyChange(value: String) = _state.update { it.copy(body = value) }
fun selectFrom(accountId: String) = _state.update { it.copy(fromAccountId = accountId) }
fun consumeError() = _state.update { it.copy(error = null) }
fun onContactsPermission(granted: Boolean) = _state.update { it.copy(contactsAllowed = granted) }
fun pickSuggestion(suggestion: ContactSuggestion) {
val current = _state.value.to
val prefix = if (current.contains(',')) current.substringBeforeLast(',') + ", " else ""
_state.update { it.copy(to = prefix + suggestion.email, suggestions = emptyList()) }
}
private fun searchContacts(value: String) {
searchJob?.cancel()
if (!_state.value.contactsAllowed) return
val token = value.substringAfterLast(',').trim()
if (token.length < 2) {
_state.update { it.copy(suggestions = emptyList()) }
return
}
searchJob = viewModelScope.launch {
val results = contactsRepository.search(token)
_state.update { it.copy(suggestions = results) }
}
}
fun send() {
val s = _state.value
val account = accounts.value.firstOrNull { it.id == s.fromAccountId } ?: accounts.value.firstOrNull()
when {
account == null -> _state.update { it.copy(error = "Add an account first") }
s.to.isBlank() -> _state.update { it.copy(error = "Add a recipient") }
else -> viewModelScope.launch {
_state.update { it.copy(sending = true, error = null) }
mailRepository.sendMessage(
OutgoingMessage(account.id, s.to, s.cc, s.subject, s.body),
).fold(
onSuccess = { _state.update { it.copy(sending = false, sent = true) } },
onFailure = { e -> _state.update { it.copy(sending = false, error = e.message ?: "Could not send") } },
)
}
}
}
}
@@ -1,15 +1,22 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.navigation
import android.net.Uri
/** Navigation routes. String-based to avoid extra serialization setup for the MVP. */
object Routes {
const val MAILBOX = "mailbox"
const val SETTINGS = "settings"
const val COMPOSE = "compose"
const val ACCOUNT_SETUP = "account_setup"
const val MANUAL_SETUP = "manual_setup"
const val READER_ARG_ID = "messageId"
const val READER_PATTERN = "reader/{$READER_ARG_ID}"
fun reader(messageId: String) = "reader/$messageId"
const val COMPOSE_ARG_TO = "to"
const val COMPOSE_ARG_SUBJECT = "subject"
const val COMPOSE_PATTERN = "compose?to={$COMPOSE_ARG_TO}&subject={$COMPOSE_ARG_SUBJECT}"
fun compose(to: String = "", subject: String = ""): String =
"compose?to=${Uri.encode(to)}&subject=${Uri.encode(subject)}"
}
@@ -48,6 +48,7 @@ import org.libremail.domain.model.Message
@Composable
fun ReaderScreen(
onBack: () -> Unit,
onReply: (to: String, subject: String) -> Unit,
viewModel: ReaderViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
@@ -68,6 +69,9 @@ fun ReaderScreen(
actions = {
val message = state.message
if (message != null) {
TextButton(onClick = { onReply(message.senderEmail, "Re: ${message.subject}") }) {
Text(stringResource(R.string.reader_reply))
}
IconButton(onClick = viewModel::toggleStar) {
Icon(
Icons.Filled.Star,
+6 -1
View File
@@ -29,10 +29,15 @@
<string name="mailbox_welcome_subtitle">Add an account to start reading your mail.</string>
<!-- Compose -->
<string name="compose_from">From</string>
<string name="compose_to">To</string>
<string name="compose_cc">Cc</string>
<string name="compose_subject">Subject</string>
<string name="compose_body">Message</string>
<string name="compose_not_implemented">Sending is not available yet</string>
<string name="compose_no_account">Add an account before composing</string>
<!-- Reader -->
<string name="reader_reply">Reply</string>
<!-- Account setup -->
<string name="account_setup_gmail">Sign in with Google</string>
@@ -19,6 +19,7 @@ class MailRepositoryImplTest {
messageDao = messageDao,
accountDao = mockk(),
imapClient = mockk(),
smtpSender = mockk(),
connectionFactory = mockk(),
)
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.mail
import com.icegreen.greenmail.util.GreenMail
import com.icegreen.greenmail.util.GreenMailUtil
import com.icegreen.greenmail.util.ServerSetupTest
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.domain.model.MailSecurity
import org.libremail.domain.model.OutgoingMessage
import org.libremail.domain.model.SmtpParams
class SmtpSenderTest {
private lateinit var greenMail: GreenMail
private val sender = SmtpSender()
@Before
fun setUp() {
greenMail = GreenMail(ServerSetupTest.SMTP)
greenMail.start()
greenMail.setUser("sender@example.org", "secret")
}
@After
fun tearDown() {
greenMail.stop()
}
@Test
fun `send delivers the message to the SMTP server`() = runTest {
val params = SmtpParams(
host = "127.0.0.1",
port = greenMail.smtp.port,
security = MailSecurity.NONE,
username = "sender@example.org",
secret = "secret",
useXoauth2 = false,
)
sender.send(
params = params,
from = "sender@example.org",
message = OutgoingMessage(
accountId = "x",
to = "bob@example.org",
subject = "Hi Bob",
body = "Hello there from LibreMail.",
),
)
greenMail.waitForIncomingEmail(1)
val received = greenMail.receivedMessages
assertEquals(1, received.size)
assertEquals("Hi Bob", received[0].subject)
assertTrue(GreenMailUtil.getBody(received[0]).contains("Hello there"))
}
}