feat: email forwarding via SMTPS

Parallel to Bark: when email forwarding is enabled and a recipient is set,
notifications are also sent via SMTP over SSL (port 465, SMTPS). Works
standalone — no Bark server required.

- core/EmailSender: SMTP over SSL (implicit TLS, port 465), AUTH LOGIN,
  UTF-8 subject/body via Base64/MIME; trySend() fire-and-forget helper
- SettingsStore: 7 email settings (enabled, host, port, user, password,
  from, to)
- NotifyListenerService + PhoneCallMonitor: call EmailSender.trySend in
  parallel with Bark forwarding; fires even if no Bark servers configured
- UI: new "电邮转发" SectionCard in settings (toggle + host/port/user/
  password/from/to fields + save button)
- version 1.6.0 -> 1.6.1 (code 10 -> 11); CLAUDE.md synced

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 16:17:10 +08:00
parent 6e09262538
commit d9308552a0
7 changed files with 241 additions and 5 deletions
+4 -2
View File
@@ -63,6 +63,7 @@ NotifyListenerService (系统通知入口)
旁路(不经通知监听器):
PhoneCallMonitor 轮询 getCallState() → 来电/未接构造 Decision 推送(绕过 MIUI 限制)
UpdateChecker 查 Gitea Releases → 新版本横幅 → 应用内下载 + 系统安装器
EmailSender 并联电邮转发(SMTPfire-and-forget
```
### 包结构 `com.a2i.forwarder`
@@ -77,6 +78,7 @@ NotifyListenerService (系统通知入口)
| `core/AppParsers.kt` | 微信/QQ/Telegram 的 `clickUrl` 映射 + 重要消息关键词(@我、验证码、通话等)。 |
| `core/BarkClient.kt` | OkHttp POST 到 `${server}/push`,超时 10/10/15sbody 用 `msg.copy(deviceKey=server.deviceKey)`;对 connection closed / 超时 / TLS 中断等瞬时 IOException 自动重试 3 次(退避 600/1200ms),HTTP 4xx/5xx 不重试。 |
| `core/BarkSender.kt` | 多服务器容错推送:按列表顺序依次 `BarkClient.push`,首个成功即止(`pushToFirst`);NotifyListenerService / PhoneCallMonitor / RetryWorker 共用。 |
| `core/EmailSender.kt` | SMTP 直连发送纯文本邮件(`send`)支持 465 SSL / 587 STARTTLS + AUTH LOGIN`trySend` 便捷方法读取 SettingsStore 异步发(fire-and-forget)。 |
| `core/RetryWorker.kt` | WorkManager 周期任务,每 15 分钟重发 `pending` 队列中的失败消息。 |
| `core/LogStore.kt` | 最近 300 条日志 + 失败重发队列(≤100)。DataStore 持久化。 |
| `core/IconExporter.kt` | 一键导出所有已安装 App 图标到用户选择的 SAF 目录(用于部署到 iOS Bark 图床)。 |
@@ -86,7 +88,7 @@ NotifyListenerService (系统通知入口)
| `core/CarrierBalanceQuery.kt` | 向运营商服务号发查询短信、解析回执余量、余额≤5 自动挂起。 |
| `core/SmsFallbackForwarder.kt` | 断网时把紧要通知(验证码/来电)用短信发到 iPhone,5 分钟限频 + 余额检查。 |
| `core/UpdateChecker.kt` | 查 Gitea Releases 最新版(`git.sunlunfan.com`,国内可达,24h 节流)→ 语义比较 → 下载 APK → FileProvider 调起系统安装器。 |
| `store/SettingsStore.kt` | Bark 服务器列表(有序,`BarkServer.enabled` 勾选参与转发)、全局开关、广告关键词、图标前缀等。`MutableStateFlow` + DataStore。`moveServer` 调序、`setServerEnabled` 启停;转发按序依次试(见 `BarkSender`)。图标前缀默认 github raw,国内不稳时可手填自建 Gitea。 |
| `store/SettingsStore.kt` | Bark 服务器列表(有序,`BarkServer.enabled` 勾选参与转发)、全局开关、广告关键词、图标前缀、电邮转发 SMTP 设置等。`MutableStateFlow` + DataStore。`moveServer` 调序、`setServerEnabled` 启停;转发按序依次试(见 `BarkSender`)。图标前缀默认 github raw,国内不稳时可手填自建 Gitea。 |
| `store/AppRulesStore.kt` | 黑/白名单模式 + 默认黑名单(系统 App + 输入法 + 三星剪贴板/键盘 + GMS + 录音机/相册/查找/密码管理 + 通话管理/AI通话);默认黑名单带版本号 `bl_version`,升级时把新增噪音包合并进老用户黑名单(只增不删)。 |
| `ui/nav/AppNav.kt` | 4 个底部 Tab + 日志页。 |
| `ui/theme/*` | 固定品牌配色(`Color.kt`)、自定义 `Typography``Type.kt`),`A2iTheme` 关闭动态取色、统一深浅色方案(`Theme.kt`)。 |
@@ -112,7 +114,7 @@ NotifyListenerService (系统通知入口)
## 版本与发布
- 当前版本:`versionCode = 10``versionName = "1.6.0"`(位于 `app/build.gradle.kts`)。**每次改动后同步递增 versionCode/versionName,并同步更新本节。**
- 当前版本:`versionCode = 11``versionName = "1.6.1"`(位于 `app/build.gradle.kts`)。**每次改动后同步递增 versionCode/versionName,并同步更新本节。**
- 远端仓库:`https://github.com/lsxf/a2i.git`,默认分支 `main`
- 提交策略:`.gitignore` 已排除 `app/build``.gradle``.kotlin``local.properties``.claude/``do.ps1``gh-token.txt`(发版用 token)、`*.token`、截图与临时文件,**不要**提交它们。
- 发版流程(github + gitea 双发;UpdateChecker 查 Gitea 源,故 Gitea 必发):
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.a2i.forwarder"
minSdk = 34
targetSdk = 36
versionCode = 10
versionName = "1.6.0"
versionCode = 11
versionName = "1.6.1"
}
signingConfigs {
@@ -0,0 +1,109 @@
package com.a2i.forwarder.core
import com.a2i.forwarder.A2iApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.util.Base64
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
/**
* SMTP 直连发送纯文本邮件(SMTPS/隐式 TLS,端口 465)。
* 在 [Dispatchers.IO] 上执行,不阻塞主线程。
*/
object EmailSender {
suspend fun send(
host: String, port: Int, user: String, password: String,
from: String, to: String, subject: String, body: String,
): Result<Unit> = withContext(Dispatchers.IO) {
runCatching {
val factory = SSLSocketFactory.getDefault()
// 隐式 TLS:直接通过 SSLSocketFactory 创建 SSLSocket 连接 SMTP 服务器(推荐端口 465)
val ssl = factory.createSocket(host, port) as SSLSocket
ssl.soTimeout = 15_000
ssl.startHandshake()
val reader: BufferedReader = ssl.inputStream.bufferedReader()
val writer: BufferedWriter = ssl.outputStream.bufferedWriter()
fun read(): String = reader.readLine() ?: error("连接关闭")
fun cmd(c: String) { writer.write("$c\r\n"); writer.flush() }
fun expectEhlo(): String {
cmd("EHLO a2i")
var last = ""
do { last = read() } while (last.startsWith("250-"))
if (!last.startsWith("250 ")) error("EHLO: $last")
return last
}
var r = read()
if (!r.startsWith("220")) error("SMTP: $r")
expectEhlo()
if (user.isNotBlank() && password.isNotBlank()) {
cmd("AUTH LOGIN")
r = read()
if (!r.startsWith("334")) error("AUTH: $r")
cmd(Base64.getEncoder().encodeToString(user.toByteArray()))
r = read()
if (!r.startsWith("334")) error("AUTH user: $r")
cmd(Base64.getEncoder().encodeToString(password.toByteArray()))
r = read()
if (!r.startsWith("235")) error("AUTH pass: $r")
}
cmd("MAIL FROM:<$from>")
r = read()
if (!r.startsWith("250")) error("FROM: $r")
cmd("RCPT TO:<$to>")
r = read()
if (!r.startsWith("250")) error("TO: $r")
cmd("DATA")
r = read()
if (!r.startsWith("354")) error("DATA: $r")
writer.write(buildMime(from, to, subject, body))
writer.write("\r\n.\r\n")
writer.flush()
r = read()
if (!r.startsWith("250")) error("SEND: $r")
cmd("QUIT")
runCatching { ssl.close() }
Unit
}
}
private fun buildMime(from: String, to: String, subject: String, body: String): String {
val subjB64 = Base64.getEncoder().encodeToString(subject.toByteArray(Charsets.UTF_8))
val bodyB64 = Base64.getEncoder().encodeToString(body.toByteArray(Charsets.UTF_8))
return buildString {
append("From: <$from>\r\n")
append("To: <$to>\r\n")
append("Subject: =?UTF-8?B?$subjB64?=\r\n")
append("MIME-Version: 1.0\r\n")
append("Content-Type: text/plain; charset=UTF-8\r\n")
append("Content-Transfer-Encoding: base64\r\n")
append("\r\n")
append(bodyB64)
}
}
/** 如果电邮转发已启用且收件人非空,异步发邮件(fire-and-forget,不阻塞主流程)。 */
fun trySend(app: A2iApp, title: String, body: String, scope: CoroutineScope) {
val s = app.settings
if (!s.emailForwardingEnabled.value || s.emailTo.value.isBlank()) return
scope.launch {
EmailSender.send(s.smtpHost.value, s.smtpPort.value, s.smtpUser.value, s.smtpPassword.value,
s.smtpFrom.value, s.emailTo.value, title, body)
}
}
}
@@ -124,7 +124,10 @@ class PhoneCallMonitor(private val context: Context) {
private fun pushToServers(msg: BarkMessage, app: A2iApp, logBody: String) {
scope.launch {
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) return@launch
if (servers.isEmpty()) {
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
return@launch
}
val result = BarkSender.pushToFirst(servers, msg)
val now = System.currentTimeMillis()
if (result.isSuccess) {
@@ -140,6 +143,7 @@ class PhoneCallMonitor(private val context: Context) {
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
)
}
EmailSender.trySend(app, msg.title ?: logBody, msg.body ?: "", scope)
}
}
}
@@ -4,6 +4,7 @@ import android.service.notification.NotificationListenerService
import android.service.notification.StatusBarNotification
import com.a2i.forwarder.A2iApp
import com.a2i.forwarder.core.BarkSender
import com.a2i.forwarder.core.EmailSender
import com.a2i.forwarder.core.ForwardLog
import com.a2i.forwarder.core.NotificationProcessor
import com.a2i.forwarder.core.PendingPush
@@ -61,6 +62,7 @@ class NotifyListenerService : NotificationListenerService() {
val servers = app.settings.servers.value.filter { it.enabled && it.deviceKey.isNotBlank() }
if (servers.isEmpty()) {
app.logStore.addLog(ForwardLog(now, sbn.packageName, d.appLabel, msg.title, msg.body, "failed", "未配置可用 Bark 服务器"))
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
return
}
@@ -74,5 +76,6 @@ class NotifyListenerService : NotificationListenerService() {
PendingPush(UUID.randomUUID().toString(), now, servers.first().id, json.encodeToString(msg))
)
}
EmailSender.trySend(app, msg.title ?: d.appLabel, msg.body, scope)
}
}
@@ -46,6 +46,14 @@ class SettingsStore(
val SMS_BALANCE = intPreferencesKey("sms_balance")
val SMS_SUSPENDED = booleanPreferencesKey("sms_suspended")
val SMS_LAST_SENT = longPreferencesKey("sms_last_sent")
// 电邮转发
val EMAIL_ENABLED = booleanPreferencesKey("email_enabled")
val EMAIL_HOST = stringPreferencesKey("email_smtp_host")
val EMAIL_PORT = intPreferencesKey("email_smtp_port")
val EMAIL_USER = stringPreferencesKey("email_smtp_user")
val EMAIL_PASS = stringPreferencesKey("email_smtp_pass")
val EMAIL_FROM = stringPreferencesKey("email_from")
val EMAIL_TO = stringPreferencesKey("email_to")
// 应用更新
val UPDATE_CHECK_AUTO = booleanPreferencesKey("update_check_auto")
val LAST_UPDATE_CHECK = longPreferencesKey("last_update_check")
@@ -81,6 +89,15 @@ class SettingsStore(
val lastUpdateCheckTime = MutableStateFlow(0L) // 上次检查时间戳
val ignoredVersion = MutableStateFlow("") // 用户忽略的版本号
// 电邮转发
val emailForwardingEnabled = MutableStateFlow(false)
val smtpHost = MutableStateFlow("")
val smtpPort = MutableStateFlow(587)
val smtpUser = MutableStateFlow("")
val smtpPassword = MutableStateFlow("")
val smtpFrom = MutableStateFlow("")
val emailTo = MutableStateFlow("")
init {
scope.launch {
ds.data.collect { p ->
@@ -109,6 +126,14 @@ class SettingsStore(
updateCheckEnabled.value = p[K.UPDATE_CHECK_AUTO] ?: true
lastUpdateCheckTime.value = p[K.LAST_UPDATE_CHECK] ?: 0L
ignoredVersion.value = p[K.IGNORED_VERSION] ?: ""
// 电邮转发
emailForwardingEnabled.value = p[K.EMAIL_ENABLED] ?: false
smtpHost.value = p[K.EMAIL_HOST] ?: ""
smtpPort.value = p[K.EMAIL_PORT] ?: 587
smtpUser.value = p[K.EMAIL_USER] ?: ""
smtpPassword.value = p[K.EMAIL_PASS] ?: ""
smtpFrom.value = p[K.EMAIL_FROM] ?: ""
emailTo.value = p[K.EMAIL_TO] ?: ""
}
}
}
@@ -172,6 +197,14 @@ class SettingsStore(
suspend fun setLastUpdateCheck(v: Long) { edit { it[K.LAST_UPDATE_CHECK] = v }; lastUpdateCheckTime.value = v }
suspend fun setIgnoredVersion(v: String) { edit { it[K.IGNORED_VERSION] = v }; ignoredVersion.value = v }
suspend fun setEmailForwarding(v: Boolean) { edit { it[K.EMAIL_ENABLED] = v }; emailForwardingEnabled.value = v }
suspend fun setSmtpHost(v: String) { edit { it[K.EMAIL_HOST] = v }; smtpHost.value = v }
suspend fun setSmtpPort(v: Int) { edit { it[K.EMAIL_PORT] = v }; smtpPort.value = v }
suspend fun setSmtpUser(v: String) { edit { it[K.EMAIL_USER] = v }; smtpUser.value = v }
suspend fun setSmtpPassword(v: String) { edit { it[K.EMAIL_PASS] = v }; smtpPassword.value = v }
suspend fun setSmtpFrom(v: String) { edit { it[K.EMAIL_FROM] = v }; smtpFrom.value = v }
suspend fun setEmailTo(v: String) { edit { it[K.EMAIL_TO] = v }; emailTo.value = v }
suspend fun snapshot() = ds.data.first()
companion object {
@@ -100,6 +100,21 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
var smsQuotaInput by remember(smsQuota) { mutableStateOf(if (smsQuota == 0) "" else smsQuota.toString()) }
val detectedCarrier = remember { com.a2i.forwarder.core.CarrierDetector.detect(context) }
// 电邮转发状态
val emailEnabled by app.settings.emailForwardingEnabled.collectAsState()
val smtpHost by app.settings.smtpHost.collectAsState()
val smtpPort by app.settings.smtpPort.collectAsState()
val smtpUser by app.settings.smtpUser.collectAsState()
val smtpPassword by app.settings.smtpPassword.collectAsState()
val smtpFrom by app.settings.smtpFrom.collectAsState()
val emailTo by app.settings.emailTo.collectAsState()
var smtpHostInput by remember(smtpHost) { mutableStateOf(smtpHost) }
var smtpPortInput by remember(smtpPort) { mutableStateOf(if (smtpPort == 0) "" else smtpPort.toString()) }
var smtpUserInput by remember(smtpUser) { mutableStateOf(smtpUser) }
var smtpPasswordInput by remember(smtpPassword) { mutableStateOf(smtpPassword) }
var smtpFromInput by remember(smtpFrom) { mutableStateOf(smtpFrom) }
var emailToInput by remember(emailTo) { mutableStateOf(emailTo) }
val smsPermLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { }
val hasSmsPerm = remember {
androidx.core.content.ContextCompat.checkSelfPermission(context, android.Manifest.permission.SEND_SMS) ==
@@ -268,6 +283,76 @@ fun SettingsScreen(onOpenLog: () -> Unit) {
}
}
item {
SectionCard("电邮转发", subtitle = "SMTPS 直发邮件(推荐端口 465);与 Bark 并行,互不依赖") {
com.a2i.forwarder.ui.SwitchRow(
title = "启用电邮转发",
subtitle = if (emailEnabled) "同时转发通知到邮箱" else "当前关闭",
checked = emailEnabled,
) { v -> app.appScope.launch { app.settings.setEmailForwarding(v) } }
if (emailEnabled) {
Spacer(Modifier.size(8.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = smtpHostInput, onValueChange = { smtpHostInput = it },
label = { Text("SMTP 主机") },
supportingText = { Text("如 smtp.qq.com") },
modifier = Modifier.weight(1.5f), singleLine = true,
)
OutlinedTextField(
value = smtpPortInput, onValueChange = { smtpPortInput = it.filter { c -> c.isDigit() } },
label = { Text("端口") },
supportingText = { Text("465") },
modifier = Modifier.weight(1f), singleLine = true,
)
}
Spacer(Modifier.size(4.dp))
OutlinedTextField(
value = smtpUserInput, onValueChange = { smtpUserInput = it },
label = { Text("账号") },
supportingText = { Text("SMTP 登录账号(通常即邮箱地址)") },
modifier = Modifier.fillMaxWidth(), singleLine = true,
)
Spacer(Modifier.size(4.dp))
OutlinedTextField(
value = smtpPasswordInput, onValueChange = { smtpPasswordInput = it },
label = { Text("授权码 / 密码") },
supportingText = { Text("QQ/163/Gmail 需用授权码,非邮箱密码;QQ邮箱需开启SMTPS服务") },
modifier = Modifier.fillMaxWidth(), singleLine = true,
)
Spacer(Modifier.size(4.dp))
OutlinedTextField(
value = smtpFromInput, onValueChange = { smtpFromInput = it },
label = { Text("发件人地址") },
supportingText = { Text("通常与账号相同") },
modifier = Modifier.fillMaxWidth(), singleLine = true,
)
Spacer(Modifier.size(4.dp))
OutlinedTextField(
value = emailToInput, onValueChange = { emailToInput = it },
label = { Text("收件人地址") },
supportingText = { Text("转发到的目标邮箱") },
modifier = Modifier.fillMaxWidth(), singleLine = true,
)
Spacer(Modifier.size(8.dp))
Button(
onClick = {
app.appScope.launch {
app.settings.setSmtpHost(smtpHostInput.trim())
app.settings.setSmtpPort(smtpPortInput.trim().toIntOrNull() ?: 587)
app.settings.setSmtpUser(smtpUserInput.trim())
app.settings.setSmtpPassword(smtpPasswordInput.trim())
app.settings.setSmtpFrom(smtpFromInput.trim())
app.settings.setEmailTo(emailToInput.trim())
}
},
modifier = Modifier.fillMaxWidth(),
) { Text("保存电邮设置") }
}
}
}
item {
SectionCard("关于与更新", subtitle = "版本检查(数据来自 GitHub Releases") {
com.a2i.forwarder.ui.SwitchRow(