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"))
+ }
+}