diff --git a/README.md b/README.md index cd473f9..c5056a6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ experience with power-user features tucked under an **Advanced Settings** group. > encrypted credentials); **IMAP receive** — background sync (WorkManager) into a local > Room cache with pull-to-refresh; and **reading** — message bodies fetched on open and > rendered in a hardened WebView (JavaScript off, remote images blocked by default), -> with mark-read, star, and delete. Composing and sending land next. +> with mark-read, star, and delete; and **composing** — a compose screen with device- +> contacts autocomplete that sends over SMTP, plus reply. Outlook Graph send, push +> notifications, and multi-account polish are upcoming. ## Features (target MVP) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7c4d4cb..37685d8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ xmlns:tools="http://schemas.android.com/tools"> + = 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() + 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() + 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 + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt index ac5b470..8fe6cf7 100644 --- a/app/src/main/kotlin/org/libremail/data/local/Mappers.kt +++ b/app/src/main/kotlin/org/libremail/data/local/Mappers.kt @@ -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, diff --git a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt index 8e44445..c6a2ded 100644 --- a/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt +++ b/app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt @@ -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 = 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 = 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 = 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() diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt index 88e11f0..e7bd652 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt @@ -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) } } diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt index bab1158..158bde5 100644 --- a/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt +++ b/app/src/main/kotlin/org/libremail/data/sync/MailSyncer.kt @@ -36,7 +36,7 @@ class MailSyncer @Inject constructor( } private suspend fun syncAccount(account: Account): Result = 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()) { diff --git a/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt new file mode 100644 index 0000000..7fd397e --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/OutgoingMessage.kt @@ -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, +) diff --git a/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt b/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt new file mode 100644 index 0000000..dfade3f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/SmtpParams.kt @@ -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, +) diff --git a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt index 307a1f0..f7fcb15 100644 --- a/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt +++ b/app/src/main/kotlin/org/libremail/domain/repository/MailRepository.kt @@ -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 suspend fun deleteMessage(id: String): Result + + suspend fun sendMessage(outgoing: OutgoingMessage): Result } diff --git a/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt new file mode 100644 index 0000000..5048452 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/mail/SmtpSender.kt @@ -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" + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 700f91d..06aac4b 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -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) { diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt index b40cf64..06caf29 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeScreen.kt @@ -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, 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, + 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() + } } } } diff --git a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt index 8f44f2b..764f1a7 100644 --- a/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/compose/ComposeViewModel.kt @@ -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 = 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(Routes.COMPOSE_ARG_TO).orEmpty(), + subject = savedStateHandle.get(Routes.COMPOSE_ARG_SUBJECT).orEmpty(), + ), + ) val state: StateFlow = _state.asStateFlow() - fun onToChange(value: String) = _state.update { it.copy(to = value) } + val accounts: StateFlow> = 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") } }, + ) + } + } + } } diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index cf5fd90..ccef1f5 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -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)}" } diff --git a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt index 4ea016e..7b8da9b 100644 --- a/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/reader/ReaderScreen.kt @@ -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, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2e46847..bd851a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -29,10 +29,15 @@ Add an account to start reading your mail. + From To + Cc Subject Message - Sending is not available yet + Add an account before composing + + + Reply Sign in with Google diff --git a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt index 6852f29..433f720 100644 --- a/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt +++ b/app/src/test/kotlin/org/libremail/data/repository/MailRepositoryImplTest.kt @@ -19,6 +19,7 @@ class MailRepositoryImplTest { messageDao = messageDao, accountDao = mockk(), imapClient = mockk(), + smtpSender = mockk(), connectionFactory = mockk(), ) diff --git a/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt new file mode 100644 index 0000000..47f7a95 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/mail/SmtpSenderTest.kt @@ -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")) + } +}