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