新增:发送通道`电子邮箱`支持`S/MIME`或`OpenPGP`加密 #417

pull/436/head
pppscn 2 months ago
parent 8cefd5fded
commit 75b356246c

@ -25,6 +25,17 @@ if (isNeedPackage.toBoolean() && isUseBooster.toBoolean()) {
}
android {
// API
configure(allprojects) {
gradle.projectsEvaluated {
tasks.withType(JavaCompile).tap {
configureEach {
options.compilerArgs << "-Xlint:-removal"
}
}
}
}
buildToolsVersion build_versions.build_tools
compileSdkVersion build_versions.target_sdk
@ -162,10 +173,15 @@ android {
exclude 'lib/x86/libgojni.so'
exclude 'lib/x86_64/libgojni.so'
}
jniLibs {
excludes += ["kotlin/**"]
}
resources {
merge 'META-INF/mailcap'
pickFirst 'META-INF/LICENSE.md'
pickFirst 'META-INF/NOTICE.md'
excludes += ['META-INF/DEPENDENCIES.txt', 'META-INF/LICENSE.txt', 'META-INF/NOTICE.txt', 'META-INF/NOTICE', 'META-INF/LICENSE', 'META-INF/DEPENDENCIES', 'META-INF/notice.txt', 'META-INF/license.txt', 'META-INF/dependencies.txt', 'META-INF/LGPL2.1']
excludes += ["META-INF/*.kotlin_module", "META-INF/*.version", "kotlin/**", "DebugProbesKt.bin"]
}
}
@ -208,21 +224,23 @@ android {
}
namespace 'com.idormy.sms.forwarder'
//
preBuild.dependsOn clean
//
gradle.buildFinished { buildResult ->
if (buildResult.failure == null) {
println "Build succeeded, cleaning text files..."
//delete rootProject.buildDir
FileTree rootTree = fileTree(dir: rootDir)
rootTree.each { File file ->
if ((file.toString().contains("ajcore") || file.toString().contains("mapping") || file.toString().contains("seeds") || file.toString().contains("unused")) && file.toString().endsWith(".txt")) {
delete file
if (isNeedClean.toBoolean()) {
//
preBuild.dependsOn clean
//
gradle.buildFinished { buildResult ->
if (buildResult.failure == null) {
println "Build succeeded, cleaning text files..."
//delete rootProject.buildDir
FileTree rootTree = fileTree(dir: rootDir)
rootTree.each { File file ->
if ((file.toString().contains("ajcore") || file.toString().contains("mapping") || file.toString().contains("seeds") || file.toString().contains("unused")) && file.toString().endsWith(".txt")) {
delete file
}
}
} else {
println "Build failed, cleanTxt not executed."
}
} else {
println "Build failed, cleanTxt not executed."
}
}
}
@ -305,8 +323,11 @@ dependencies {
//implementation 'com.github.tiagohm.MarkdownView:emoji:0.19.0'
def retrofit2_version = '2.9.0'
//noinspection GradleDependency
implementation "com.squareup.retrofit2:retrofit:$retrofit2_version"
//noinspection GradleDependency
implementation "com.squareup.retrofit2:converter-gson:$retrofit2_version"
//noinspection GradleDependency
implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit2_version"
def paging_version = "3.1.1"
@ -325,6 +346,18 @@ dependencies {
implementation "com.sun.mail:android-mail:$mail_version"
implementation "com.sun.mail:android-activation:$mail_version"
//SM4 JAVA(BC)
def bouncycastle_version = '1.77'
api "org.bouncycastle:bcprov-jdk18on:$bouncycastle_version"
// S/MIME
//implementation "org.spongycastle:bcmail-jdk18on:$bouncycastle_version" //Android
implementation "org.bouncycastle:bcpkix-jdk18on:$bouncycastle_version"
//implementation "org.bouncycastle:bctls-jdk18on:$bouncycastle_version"
// PGP
//implementation "org.bouncycastle:bcpg-jdk18on:$bouncycastle_version" //Thunderbird
//PGPainless: https://github.com/pgpainless/pgpainless
implementation 'org.pgpainless:pgpainless-core:1.6.7'
//Android Keep Alive()Cactus JobScheduleronePix()WorkManager
//https://github.com/gyf-dev/Cactus
implementation 'com.gyf.cactus:cactus:1.1.3-beta13'
@ -333,9 +366,6 @@ dependencies {
implementation 'cn.ppps.andserver:api:2.1.12'
kapt 'cn.ppps.andserver:processor:2.1.12'
//SM4 JAVA(BC)
api 'org.bouncycastle:bcprov-jdk15on:1.70'
//Location Android LocationManager https://github.com/jenly1314/Location
implementation 'com.github.pppscn:location:1.0.0'

@ -1,5 +1,6 @@
package com.idormy.sms.forwarder.entity.setting
import com.idormy.sms.forwarder.R
import java.io.Serializable
data class EmailSetting(
@ -11,6 +12,19 @@ data class EmailSetting(
var port: String? = "",
var ssl: Boolean? = false,
var startTls: Boolean? = false,
var toEmail: String? = "",
var title: String? = "",
) : Serializable
var recipients: MutableMap<String, Pair<String, String>> = mutableMapOf(),
var toEmail: String? = "",
var keystore: String? = "",
var password: String? = "",
var encryptionProtocol: String = "Plain", //加密协议: S/MIME、OpenPGP、Plain不传证书
) : Serializable {
fun getEncryptionProtocolCheckId(): Int {
return when (encryptionProtocol) {
"S/MIME" -> R.id.rb_encryption_protocol_smime
"OpenPGP" -> R.id.rb_encryption_protocol_openpgp
else -> R.id.rb_encryption_protocol_plain
}
}
}

@ -1,12 +1,20 @@
package com.idormy.sms.forwarder.fragment.senders
import android.annotation.SuppressLint
import android.os.Environment
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.EditText
import android.widget.ImageView
import android.widget.LinearLayout
import androidx.fragment.app.viewModels
import com.google.gson.Gson
import com.hjq.permissions.OnPermissionCallback
import com.hjq.permissions.Permission
import com.hjq.permissions.XXPermissions
import com.idormy.sms.forwarder.R
import com.idormy.sms.forwarder.core.BaseFragment
import com.idormy.sms.forwarder.core.Core
@ -41,6 +49,13 @@ import io.reactivex.SingleObserver
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.schedulers.Schedulers
import org.pgpainless.PGPainless
import org.pgpainless.key.info.KeyRingInfo
import java.io.File
import java.io.FileInputStream
import java.security.KeyStore
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.util.Date
@Page(name = "Email")
@ -52,6 +67,11 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
private val viewModel by viewModels<SenderViewModel> { BaseViewModelFactory(context) }
private var mCountDownHelper: CountDownButtonHelper? = null
private var mailType: String = getString(R.string.other_mail_type) //邮箱类型
private var recipientItemMap: MutableMap<Int, LinearLayout> = mutableMapOf()
private val downloadPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).path
//加密协议: S/MIME、OpenPGP、Plain不传证书
private var encryptionProtocol: String = "Plain"
@JvmField
@AutoWired(name = KEY_SENDER_ID)
@ -98,7 +118,6 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
})
val mailTypeArray = getStringArray(R.array.MailType)
Log.d(TAG, mailTypeArray.toString())
binding!!.spMailType.setOnItemSelectedListener { _: MaterialSpinner?, position: Int, _: Long, item: Any ->
mailType = item.toString()
//XToastUtils.warning(mailType)
@ -112,6 +131,39 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
binding!!.spMailType.selectedIndex = mailTypeArray.size - 1
binding!!.layoutServiceSetting.visibility = View.VISIBLE
binding!!.rgEncryptionProtocol.setOnCheckedChangeListener { _, checkedId ->
when (checkedId) {
R.id.rb_encryption_protocol_smime -> {
encryptionProtocol = "S/MIME"
binding!!.layoutSenderKeystore.visibility = View.VISIBLE
binding!!.tvSenderKeystore.text = getString(R.string.sender_smime_keystore)
binding!!.tvEmailTo.text = getString(R.string.email_to_smime)
binding!!.tvEmailToTips.text = getString(R.string.email_to_smime_tips)
}
R.id.rb_encryption_protocol_openpgp -> {
encryptionProtocol = "OpenPGP"
binding!!.layoutSenderKeystore.visibility = View.VISIBLE
binding!!.tvSenderKeystore.text = getString(R.string.sender_openpgp_keystore)
binding!!.tvEmailTo.text = getString(R.string.email_to_openpgp)
binding!!.tvEmailToTips.text = getString(R.string.email_to_openpgp_tips)
}
else -> {
encryptionProtocol = "Plain"
binding!!.layoutSenderKeystore.visibility = View.GONE
binding!!.tvEmailTo.text = getString(R.string.email_to)
binding!!.tvEmailToTips.text = getString(R.string.email_to_tips)
}
}
//遍历 layout_recipients 子元素,设置 layout_recipient_keystore 可见性
for (recipientItem in recipientItemMap.values) {
val layoutRecipientKeystore = recipientItem.findViewById<LinearLayout>(R.id.layout_recipient_keystore)
layoutRecipientKeystore.visibility = if (encryptionProtocol == "Plain") View.GONE else View.VISIBLE
}
}
//新增
if (senderId <= 0) {
titleBar?.setSubTitle(getString(R.string.add_sender))
@ -143,6 +195,10 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
if (settingVo != null) {
if (!TextUtils.isEmpty(settingVo.mailType)) {
mailType = settingVo.mailType.toString()
//TODO: 替换mailType为当前语言避免切换语言后失效历史包袱怎么替换比较优雅
if (mailType == "other" || mailType == "其他邮箱" || mailType == "其他郵箱") {
mailType = getString(R.string.other_mail_type)
}
binding!!.spMailType.setSelectedItem(mailType)
if (mailType != getString(R.string.other_mail_type)) {
binding!!.layoutServiceSetting.visibility = View.GONE
@ -155,8 +211,24 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
binding!!.etPort.setText(settingVo.port)
binding!!.sbSsl.isChecked = settingVo.ssl == true
binding!!.sbStartTls.isChecked = settingVo.startTls == true
binding!!.etToEmail.setText(settingVo.toEmail)
binding!!.etTitleTemplate.setText(settingVo.title)
encryptionProtocol = settingVo.encryptionProtocol
binding!!.rgEncryptionProtocol.check(settingVo.getEncryptionProtocolCheckId())
if (settingVo.recipients.isNotEmpty()) {
for ((email, cert) in settingVo.recipients) {
addRecipientItem(email, cert)
}
} else {
//兼容旧版本
val emails = settingVo.toEmail?.split(",")
if (!emails.isNullOrEmpty()) {
for (email in emails.toTypedArray()) {
addRecipientItem(email)
}
}
}
binding!!.etSenderKeystore.setText(settingVo.keystore)
binding!!.etSenderPassword.setText(settingVo.password)
}
}
})
@ -174,6 +246,12 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
binding!!.btnTest.setOnClickListener(this)
binding!!.btnDel.setOnClickListener(this)
binding!!.btnSave.setOnClickListener(this)
binding!!.btnAddRecipient.setOnClickListener {
addRecipientItem()
}
binding!!.btnSenderKeystorePicker.setOnClickListener {
pickCert(binding!!.etSenderKeystore)
}
LiveEventBus.get(KEY_SENDER_TEST, String::class.java).observe(this) { mCountDownHelper?.finish() }
}
@ -284,21 +362,256 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
private fun checkSetting(): EmailSetting {
val fromEmail = binding!!.etFromEmail.text.toString().trim()
val pwd = binding!!.etPwd.text.toString().trim()
val nickname = binding!!.etNickname.text.toString().trim()
val recipients = getRecipientsFromRecipientItemMap()
if (TextUtils.isEmpty(fromEmail) || TextUtils.isEmpty(pwd) || recipients.isEmpty()) {
throw Exception(getString(R.string.invalid_email))
}
for ((email, cert) in recipients) {
if (!CommonUtils.checkEmail(email)) {
throw Exception(String.format(getString(R.string.invalid_recipient_email), email))
}
Log.d(TAG, "email: $email, cert: $cert")
when (encryptionProtocol) {
"S/MIME" -> {
when {
cert.first.isNotEmpty() && cert.second.isNotEmpty() -> {
try {
// 判断是否有效的PKCS12私钥证书
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(FileInputStream(cert.first), cert.second.toCharArray())
val alias = keyStore.aliases().nextElement()
val recipientPublicKey = keyStore.getCertificate(alias) as X509Certificate
Log.d(TAG, "PKCS12 Certificate: $recipientPublicKey")
} catch (e: Exception) {
e.printStackTrace()
throw Exception(String.format(getString(R.string.invalid_pkcs12_certificate), email))
}
}
cert.first.isNotEmpty() && cert.second.isEmpty() -> {
try {
// 判断是否有效的X.509公钥证书
val certFactory = CertificateFactory.getInstance("X.509")
val fileInputStream = FileInputStream(cert.first)
val recipientPublicKey = certFactory.generateCertificate(fileInputStream) as X509Certificate
Log.d(TAG, "X.509 Certificate: $recipientPublicKey")
} catch (e: Exception) {
e.printStackTrace()
throw Exception(String.format(getString(R.string.invalid_x509_certificate), email))
}
}
}
}
"OpenPGP" -> {
when {
cert.first.isNotEmpty() && cert.second.isNotEmpty() -> {
try {
//从私钥证书文件提取公钥
val recipientPrivateKeyStream = FileInputStream(cert.first)
val recipientPGPSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(recipientPrivateKeyStream)
val recipientPGPPublicKeyRing = PGPainless.extractCertificate(recipientPGPSecretKeyRing!!)
val keyInfo = KeyRingInfo(recipientPGPPublicKeyRing)
Log.d(TAG, "recipientPGPPublicKeyRing: $keyInfo")
} catch (e: Exception) {
e.printStackTrace()
throw Exception(String.format(getString(R.string.invalid_x509_certificate), email))
}
}
cert.first.isNotEmpty() && cert.second.isEmpty() -> {
try {
//从证书文件提取公钥
val recipientPublicKeyStream = FileInputStream(cert.first)
val recipientPGPPublicKeyRing = PGPainless.readKeyRing().publicKeyRing(recipientPublicKeyStream)
val keyInfo = KeyRingInfo(recipientPGPPublicKeyRing!!)
Log.d(TAG, "recipientPGPPublicKeyRing: $keyInfo")
} catch (e: Exception) {
e.printStackTrace()
throw Exception(String.format(getString(R.string.invalid_x509_certificate), email))
}
}
}
}
}
}
val host = binding!!.etHost.text.toString().trim()
val port = binding!!.etPort.text.toString().trim()
if (mailType == getString(R.string.other_mail_type) && (TextUtils.isEmpty(host) || TextUtils.isEmpty(port))) {
throw Exception(getString(R.string.invalid_email_server))
}
val nickname = binding!!.etNickname.text.toString().trim()
val ssl = binding!!.sbSsl.isChecked
val startTls = binding!!.sbStartTls.isChecked
val toEmail = binding!!.etToEmail.text.toString().trim()
val title = binding!!.etTitleTemplate.text.toString().trim()
if (TextUtils.isEmpty(fromEmail) || TextUtils.isEmpty(pwd) || TextUtils.isEmpty(toEmail)) {
throw Exception(getString(R.string.invalid_email))
val keystore = binding!!.etSenderKeystore.text.toString().trim()
val password = binding!!.etSenderPassword.text.toString().trim()
if (keystore.isNotEmpty()) {
val senderPrivateKeyStream = FileInputStream(keystore)
if (senderPrivateKeyStream.available() <= 0) {
throw Exception(getString(R.string.invalid_sender_keystore))
}
when (encryptionProtocol) {
"S/MIME" -> {
try {
// 判断是否有效的PKCS12私钥证书
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(senderPrivateKeyStream, password.toCharArray())
val alias = keyStore.aliases().nextElement()
val certificate = keyStore.getCertificate(alias) as X509Certificate
Log.d(TAG, "PKCS12 Certificate: $certificate")
} catch (e: Exception) {
e.printStackTrace()
throw Exception(getString(R.string.invalid_sender_keystore))
}
}
"OpenPGP" -> {
try {
val senderPGPSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(senderPrivateKeyStream)
val keyInfo = KeyRingInfo(senderPGPSecretKeyRing!!)
Log.d(TAG, "senderPGPSecretKeyRing: $keyInfo")
} catch (e: Exception) {
e.printStackTrace()
throw Exception(getString(R.string.invalid_sender_keystore))
}
}
}
}
if (mailType == getString(R.string.other_mail_type) && (TextUtils.isEmpty(host) || TextUtils.isEmpty(port))) {
throw Exception(getString(R.string.invalid_email_server))
return EmailSetting(mailType, fromEmail, pwd, nickname, host, port, ssl, startTls, title, recipients, "", keystore, password, encryptionProtocol)
}
//recipient序号
private var recipientItemId = 0
/**
* 动态增删recipient
*
* @param email recipient的email
* @param cert recipient的cert为空则不设置
*/
private fun addRecipientItem(email: String = "", cert: Any? = null) {
val itemAddRecipient = View.inflate(requireContext(), R.layout.item_add_recipient, null) as LinearLayout
val etRecipientEmail = itemAddRecipient.findViewById<EditText>(R.id.et_recipient_email)
val etRecipientKeystore = itemAddRecipient.findViewById<EditText>(R.id.et_recipient_keystore)
val etRecipientPassword = itemAddRecipient.findViewById<EditText>(R.id.et_recipient_password)
etRecipientEmail.setText(email)
Log.d(TAG, "cert: $cert")
when (cert) {
is String -> etRecipientKeystore.setText(cert)
is Pair<*, *> -> {
Log.d(TAG, "cert.first: ${cert.first}")
Log.d(TAG, "cert.second: ${cert.second}")
etRecipientKeystore.setText(cert.first.toString())
etRecipientPassword.setText(cert.second.toString())
}
}
val ivDel = itemAddRecipient.findViewById<ImageView>(R.id.iv_del)
ivDel.tag = recipientItemId
ivDel.setOnClickListener {
val itemId = it.tag as Int
binding!!.layoutRecipients.removeView(recipientItemMap[itemId])
recipientItemMap.remove(itemId)
}
val btnFilePicker = itemAddRecipient.findViewById<Button>(R.id.btn_file_picker)
btnFilePicker.tag = recipientItemId
btnFilePicker.setOnClickListener {
val itemId = it.tag as Int
val etKeyStore = recipientItemMap[itemId]!!.findViewById<EditText>(R.id.et_recipient_keystore)
pickCert(etKeyStore)
}
val layoutRecipientKeystore = itemAddRecipient.findViewById<LinearLayout>(R.id.layout_recipient_keystore)
layoutRecipientKeystore.visibility = if (encryptionProtocol == "Plain") View.GONE else View.VISIBLE
binding!!.layoutRecipients.addView(itemAddRecipient)
recipientItemMap[recipientItemId] = itemAddRecipient
recipientItemId++
}
/**
* 从EditText控件中获取全部recipients
*
* @return 全部recipients
*/
private fun getRecipientsFromRecipientItemMap(): MutableMap<String, Pair<String, String>> {
val recipients: MutableMap<String, Pair<String, String>> = mutableMapOf()
for (recipientItem in recipientItemMap.values) {
val etRecipientEmail = recipientItem.findViewById<EditText>(R.id.et_recipient_email)
val etRecipientKeystore = recipientItem.findViewById<EditText>(R.id.et_recipient_keystore)
val etRecipientPassword = recipientItem.findViewById<EditText>(R.id.et_recipient_password)
val email = etRecipientEmail.text.toString().trim()
val keystore = etRecipientKeystore.text.toString().trim()
val password = etRecipientPassword.text.toString().trim()
recipients[email] = Pair(keystore, password)
}
Log.d(TAG, "recipients: $recipients")
return recipients
}
return EmailSetting(mailType, fromEmail, pwd, nickname, host, port, ssl, startTls, toEmail, title)
//选择证书文件
private fun pickCert(etKeyStore: EditText) {
XXPermissions.with(this)
.permission(Permission.MANAGE_EXTERNAL_STORAGE)
.request(object : OnPermissionCallback {
@SuppressLint("SetTextI18n")
override fun onGranted(permissions: List<String>, all: Boolean) {
val fileList = findSupportedFiles(downloadPath)
if (fileList.isEmpty()) {
XToastUtils.error(String.format(getString(R.string.download_certificate_first), downloadPath))
return
}
MaterialDialog.Builder(requireContext())
.title(getString(R.string.keystore_path))
.content(String.format(getString(R.string.root_directory), downloadPath))
.items(fileList)
.itemsCallbackSingleChoice(0) { _: MaterialDialog?, _: View?, _: Int, text: CharSequence ->
val webPath = "$downloadPath/$text"
etKeyStore.setText(webPath)
true // allow selection
}
.positiveText(R.string.select)
.negativeText(R.string.cancel)
.show()
}
override fun onDenied(permissions: List<String>, never: Boolean) {
if (never) {
XToastUtils.error(R.string.toast_denied_never)
// 如果是被永久拒绝就跳转到应用权限系统设置页面
XXPermissions.startPermissionActivity(requireContext(), permissions)
} else {
XToastUtils.error(R.string.toast_denied)
}
}
})
}
private fun findSupportedFiles(directoryPath: String): List<String> {
val audioFiles = mutableListOf<String>()
val directory = File(directoryPath)
if (directory.exists() && directory.isDirectory) {
directory.listFiles()?.let { files ->
files.filter { it.isFile && isSupportedFile(it) }.forEach { audioFiles.add(it.name) }
}
}
return audioFiles
}
private fun isSupportedFile(file: File): Boolean {
val supportedExtensions = if (encryptionProtocol == "OpenPGP") {
listOf("asc", "pgp")
} else {
listOf("pfx", "p12", "pem", "cer", "crt", "der")
}
return supportedExtensions.any { it.equals(file.extension, ignoreCase = true) }
}
override fun onDestroyView() {

@ -0,0 +1,174 @@
package com.idormy.sms.forwarder.utils.mail
import android.text.Html
import android.text.Spanned
import com.idormy.sms.forwarder.utils.Log
import org.bouncycastle.openpgp.PGPPublicKeyRing
import org.bouncycastle.openpgp.PGPSecretKeyRing
import java.io.File
import java.security.PrivateKey
import java.security.cert.X509Certificate
import java.util.Properties
import javax.mail.Authenticator
import javax.mail.PasswordAuthentication
@Suppress("PrivatePropertyName", "unused", "DEPRECATION")
class EmailSender(
// SMTP参数
private val host: String, // SMTP服务器地址
private val port: String, // SMTP服务器端口
private val from: String, // 发件人邮箱
private val password: String, // 发件人邮箱密码/授权码
// 邮件参数
private val nickname: String, // 发件人昵称
private val subject: String, // 邮件主题
private val body: CharSequence, // 邮件正文
private val attachFiles: MutableList<File> = mutableListOf(), // 附件
// 收件人参数
private val toAddress: MutableList<String> = mutableListOf(), // 收件人邮箱
private val ccAddress: MutableList<String> = mutableListOf(), // 抄送者邮箱
private val bccAddress: MutableList<String> = mutableListOf(), // 密送者邮箱
// 监听器
private val listener: EmailTaskListener? = null,
// 安全选项
private val openSSL: Boolean = false, //是否开启ssl验证 默认关闭
private val sslFactory: String = "javax.net.ssl.SSLSocketFactory", //SSL构建类名
private val startTls: Boolean = false, //是否开启starttls加密方式 默认关闭
// 邮件加密方式: S/MIME、OpenPGP、Plain不传证书
private val encryptionProtocol: String = "S/MIME",
// 邮件 S/MIME 加密和签名
private val recipientX509Cert: X509Certificate? = null, //收件人公钥(用于加密)
private val senderPrivateKey: PrivateKey? = null, //发件人私玥(用于签名)
private val senderX509Cert: X509Certificate? = null, //发件人公玥(用于签名)
//邮件 PGP 加密和签名
private var recipientPGPPublicKeyRing: PGPPublicKeyRing? = null, // 收件人公钥(用于加密)
private var senderPGPSecretKeyRing: PGPSecretKeyRing? = null, // 发件人私钥(用于签名)
private val senderPGPSecretKeyPassword: String = "", // 发件人私钥密码
) {
private val TAG: String = EmailSender::class.java.simpleName
private val properties: Properties = Properties().apply {
// 设置邮件服务器的主机名
put("mail.smtp.host", host)
// 设置邮件服务器的端口号
put("mail.smtp.port", port)
// 设置是否需要身份验证
put("mail.smtp.auth", "true")
// 设置是否启用 SSL 连接
if (openSSL) {
put("mail.smtp.ssl.enable", "true")
put("mail.smtp.socketFactory.class", sslFactory)
}
// 设置是否启用 TLS 连接
if (startTls) {
put("mail.smtp.starttls.enable", "true")
}
}
suspend fun sendEmail() {
try {
val authenticator = MailAuthenticator(from, password)
// 邮件正文
val html = try {
if (body is Spanned) Html.toHtml(body) else body.toString()
} catch (e: Exception) {
body.toString()
}
// 发送 S/MIME 邮件
when (encryptionProtocol) {
"S/MIME" -> {
val smimeUtils = SmimeUtils(
properties,
authenticator,
from,
nickname,
subject,
html,
attachFiles,
toAddress,
ccAddress,
bccAddress,
recipientX509Cert,
senderPrivateKey,
senderX509Cert,
)
val isEncrypt: Boolean = recipientX509Cert != null
val isSign: Boolean = senderX509Cert != null && senderPrivateKey != null
Log.d(TAG, "isEncrypt=$isEncrypt, isSign=$isSign")
val result = when {
isEncrypt && isSign -> smimeUtils.sendSignedAndEncryptedEmail()
isEncrypt -> smimeUtils.sendEncryptedEmail()
isSign -> smimeUtils.sendSignedEmail()
else -> smimeUtils.sendPlainEmail()
}
listener?.onEmailSent(result.first, result.second)
}
"OpenPGP" -> {
// 发送 PGP 邮件
val pgpEmail = PgpUtils(
properties,
authenticator,
from,
nickname,
subject,
html,
attachFiles,
toAddress,
ccAddress,
bccAddress,
recipientPGPPublicKeyRing,
senderPGPSecretKeyRing,
senderPGPSecretKeyPassword,
)
val isEncrypt: Boolean = recipientPGPPublicKeyRing != null
val isSign: Boolean = senderPGPSecretKeyRing != null
Log.d(TAG, "isEncrypt=$isEncrypt, isSign=$isSign")
val result = when {
isEncrypt && isSign -> pgpEmail.sendSignedAndEncryptedEmail()
isEncrypt -> pgpEmail.sendEncryptedEmail()
isSign -> pgpEmail.sendSignedEmail()
else -> pgpEmail.sendPlainEmail()
}
listener?.onEmailSent(result.first, result.second)
}
else -> {
// 发送普通邮件
val simpleEmail = SmimeUtils(
properties,
authenticator,
from,
nickname,
subject,
html,
attachFiles,
toAddress,
ccAddress,
bccAddress,
)
val result = simpleEmail.sendPlainEmail()
listener?.onEmailSent(result.first, result.second)
}
}
} catch (e: Exception) {
listener?.onEmailSent(false, "Error sending email: ${e.message}")
}
}
interface EmailTaskListener {
fun onEmailSent(success: Boolean, message: String)
}
/**
* 发件箱auth校验
*/
private class MailAuthenticator(username: String, private var password: String) : Authenticator() {
private var userName: String? = username
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(userName, password)
}
}
}

@ -1,28 +0,0 @@
package com.idormy.sms.forwarder.utils.mail
import java.io.File
/**
* desc: 邮件实体类
* time: 2019/8/1
* @author teprinciple
*/
data class Mail(
var mailServerHost: String = "", // 发件箱邮箱服务器地址
var mailServerPort: String = "", // 发件箱邮箱服务器端口
var fromAddress: String = "", // 发件箱
var fromNickname: String = "", // 发件人昵称
var password: String = "", // 发件箱授权码(密码)
var toAddress: List<String> = ArrayList(), // 直接收件人邮箱
var ccAddress: ArrayList<String> = ArrayList(), // 抄送者邮箱
var bccAddress: ArrayList<String> = ArrayList(), // 密送者邮箱
var subject: String = "", // 邮件主题
var content: CharSequence = "", // 邮件内容
var attachFiles: ArrayList<File> = ArrayList(), // 附件
var openSSL: Boolean = false, //是否开启ssl验证 默认关闭
var sslFactory: String = "javax.net.ssl.SSLSocketFactory", //SSL构建类名
var startTls: Boolean = false, //是否开启starttls加密方式 默认关闭
)

@ -1,49 +0,0 @@
package com.idormy.sms.forwarder.utils.mail
import com.idormy.sms.forwarder.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.async
import kotlinx.coroutines.launch
import javax.mail.Transport
/**
* 邮件发送器
*/
object MailSender {
/**
* 获取单例
*/
@JvmStatic
fun getInstance() = this
/**
* 发送邮件
*/
fun sendMail(mail: Mail, onMailSendListener: OnMailSendListener? = null) {
@Suppress("OPT_IN_USAGE")
val send = GlobalScope.async(Dispatchers.IO) {
Transport.send(MailUtil.createMailMessage(mail))
}
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.Main) {
runCatching {
send.await()
onMailSendListener?.onSuccess()
}.onFailure {
Log.e("MailSender", it.message.toString())
onMailSendListener?.onError(it)
}
}
}
/**
* 发送回调
*/
interface OnMailSendListener {
fun onSuccess()
fun onError(e: Throwable)
}
}

@ -1,136 +0,0 @@
package com.idormy.sms.forwarder.utils.mail
import android.text.Html
import android.text.Spanned
import com.idormy.sms.forwarder.utils.Log
import com.xuexiang.xrouter.utils.TextUtils
import java.io.UnsupportedEncodingException
import java.util.Properties
import javax.activation.DataHandler
import javax.activation.FileDataSource
import javax.mail.Authenticator
import javax.mail.Message
import javax.mail.PasswordAuthentication
import javax.mail.Session
import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeBodyPart
import javax.mail.internet.MimeMessage
import javax.mail.internet.MimeMultipart
import javax.mail.internet.MimeUtility
/**
* desc: 邮件帮助类
* time: 2019/8/1
* @author teprinciple
*/
@Suppress("DEPRECATION")
object MailUtil {
/**
* 创建邮件
*/
fun createMailMessage(mail: Mail): MimeMessage {
Log.e("createMailMessage", mail.toString())
val properties = Properties()
properties["mail.debug"] = "true"
properties["mail.smtp.host"] = mail.mailServerHost
properties["mail.smtp.port"] = mail.mailServerPort
properties["mail.smtp.auth"] = "true"
properties["mail.smtp.ssl.enable"] = mail.openSSL
if (mail.startTls) {
properties["mail.smtp.starttls.enable"] = true
}
if (mail.openSSL) {
properties["mail.smtp.socketFactory.class"] = mail.sslFactory
}
val authenticator = MailAuthenticator(mail.fromAddress, mail.password)
val session = Session.getInstance(properties, authenticator)
session.debug = true
Log.e("createMailMessage", session.toString())
return MimeMessage(session).apply {
// 设置发件箱
if (TextUtils.isEmpty(mail.fromNickname)) {
setFrom(InternetAddress(mail.fromAddress))
} else {
var nickname = mail.fromNickname.replace(":", "-").replace("\n", "-")
try {
Log.d("createMailMessage", "nickname = $nickname")
nickname = MimeUtility.encodeText(nickname)
} catch (e: UnsupportedEncodingException) {
e.printStackTrace()
Log.e("createMailMessage", "UnsupportedEncodingException = ${e.message}")
}
Log.d("createMailMessage", "nickname = $nickname")
setFrom(InternetAddress("$nickname <${mail.fromAddress}>"))
}
// 设置直接接收者收件箱
val toAddress = mail.toAddress.map {
InternetAddress(it)
}.toTypedArray()
setRecipients(Message.RecipientType.TO, toAddress)
// 设置抄送者收件箱
val ccAddress = mail.ccAddress.map {
InternetAddress(it)
}.toTypedArray()
setRecipients(Message.RecipientType.CC, ccAddress)
// 设置密送者收件箱
val bccAddress = mail.bccAddress.map {
InternetAddress(it)
}.toTypedArray()
setRecipients(Message.RecipientType.BCC, bccAddress)
// 邮件主题
subject = mail.subject.replace(":", "-").replace("\n", "-")
try {
subject = MimeUtility.encodeText(subject)
} catch (e: UnsupportedEncodingException) {
e.printStackTrace()
Log.e("createMailMessage", "UnsupportedEncodingException = ${e.message}")
}
// 邮件内容
val contentPart = MimeMultipart()
// 邮件正文
val textBodyPart = MimeBodyPart()
if (mail.content is Spanned) {
textBodyPart.setContent(
Html.toHtml(mail.content as Spanned),
"text/html;charset=UTF-8"
)
} else {
textBodyPart.setContent(mail.content, "text/html;charset=UTF-8")
}
contentPart.addBodyPart(textBodyPart)
// 邮件附件
mail.attachFiles.forEach {
val fileBodyPart = MimeBodyPart()
val ds = FileDataSource(it)
val dh = DataHandler(ds)
fileBodyPart.dataHandler = dh
fileBodyPart.fileName = MimeUtility.encodeText(dh.name)
contentPart.addBodyPart(fileBodyPart)
}
contentPart.setSubType("mixed")
setContent(contentPart)
saveChanges()
}
}
/**
* 发件箱auth校验
*/
class MailAuthenticator(username: String?, private var password: String?) : Authenticator() {
private var userName: String? = username
override fun getPasswordAuthentication(): PasswordAuthentication {
return PasswordAuthentication(userName, password)
}
}
}

@ -0,0 +1,295 @@
package com.idormy.sms.forwarder.utils.mail
import com.idormy.sms.forwarder.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.openpgp.PGPPublicKeyRing
import org.bouncycastle.openpgp.PGPSecretKeyRing
import org.bouncycastle.util.io.Streams
import org.pgpainless.PGPainless
import org.pgpainless.algorithm.DocumentSignatureType
import org.pgpainless.algorithm.HashAlgorithm
import org.pgpainless.encryption_signing.EncryptionOptions
import org.pgpainless.encryption_signing.ProducerOptions
import org.pgpainless.encryption_signing.SigningOptions
import org.pgpainless.key.protection.SecretKeyRingProtector
import org.pgpainless.util.Passphrase
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream
import java.security.Security
import java.util.Date
import java.util.Properties
import javax.activation.DataHandler
import javax.activation.FileDataSource
import javax.mail.Authenticator
import javax.mail.Message
import javax.mail.Session
import javax.mail.Transport
import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeBodyPart
import javax.mail.internet.MimeMessage
import javax.mail.internet.MimeMultipart
import javax.mail.internet.MimeUtility
import javax.mail.util.ByteArrayDataSource
@Suppress("PrivatePropertyName", "unused")
class PgpUtils(
private val properties: Properties,
private val authenticator: Authenticator,
// 邮件参数
private val from: String, // 发件人邮箱
private val nickname: String, // 发件人昵称
private val subject: String, // 邮件主题
private val body: String, // 邮件正文
private val attachFiles: MutableList<File> = mutableListOf(), // 附件
// 收件人参数
private val toAddress: MutableList<String> = mutableListOf(), // 收件人邮箱
private val ccAddress: MutableList<String> = mutableListOf(), // 抄送者邮箱
private val bccAddress: MutableList<String> = mutableListOf(), // 密送者邮箱
//邮件 PGP 加密和签名
private var recipientPGPPublicKeyRing: PGPPublicKeyRing? = null, // 收件人公钥(用于加密)
private var senderPGPSecretKeyRing: PGPSecretKeyRing? = null, // 发件人私钥(用于签名)
private val senderPGPSecretKeyPassword: String = "", // 发件人私钥密码
) {
private val TAG: String = PgpUtils::class.java.simpleName
init {
Security.addProvider(BouncyCastleProvider())
}
// 发送明文邮件
suspend fun sendPlainEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
Log.d(TAG, "sendPlainEmail")
try {
val originalMessage = getOriginalMessage()
Transport.send(originalMessage)
Pair(true, "Email sent successfully")
} catch (e: Exception) {
e.printStackTrace()
Pair(false, "Failed to send email: ${e.message}")
}
}
// 发送签名后的邮件
suspend fun sendSignedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
Log.d(TAG, "sendSignedEmail")
try {
val originalMessage = getOriginalMessage()
val signedMessage = getSignedMessage(originalMessage)
Transport.send(signedMessage)
Pair(true, "Email signed and sent successfully")
} catch (e: Exception) {
e.printStackTrace()
Pair(false, "Failed to sign and send email: ${e.message}")
}
}
// 发送加密邮件
suspend fun sendEncryptedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
Log.d(TAG, "sendEncryptedEmail")
try {
val originalMessage = getOriginalMessage()
val producerOptions = ProducerOptions.encrypt(
EncryptionOptions.encryptCommunications().addRecipient(recipientPGPPublicKeyRing!!)
).setAsciiArmor(true)
val encryptedMessage = getEncryptedMessage(originalMessage, producerOptions)
Transport.send(encryptedMessage)
Pair(true, "Encrypted email sent successfully")
} catch (e: Exception) {
e.printStackTrace()
Pair(false, "Failed to send encrypted email: ${e.message}")
}
}
// 发送签名加密邮件
suspend fun sendSignedAndEncryptedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
Log.d(TAG, "sendSignedAndEncryptedEmail")
try {
val originalMessage = getOriginalMessage()
val secretKeyDecryptor = SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword(senderPGPSecretKeyPassword))
val producerOptions = ProducerOptions.signAndEncrypt(
EncryptionOptions.encryptCommunications().addRecipient(recipientPGPPublicKeyRing!!),
SigningOptions()
.addInlineSignature(secretKeyDecryptor, senderPGPSecretKeyRing!!, DocumentSignatureType.CANONICAL_TEXT_DOCUMENT)
.overrideHashAlgorithm(HashAlgorithm.SHA256)
).setAsciiArmor(true)
val encryptedMessage = getEncryptedMessage(originalMessage, producerOptions)
Transport.send(encryptedMessage)
Pair(true, "Signed and encrypted email sent successfully")
} catch (e: Exception) {
e.printStackTrace()
Pair(false, "Failed to send signed and encrypted email: ${e.message}")
}
}
// 获取原始邮件
private fun getOriginalMessage(): MimeMessage {
val session = Session.getInstance(properties, authenticator)
session.debug = true
val message = MimeMessage(session)
// 设置直接接收者收件箱
val toAddress = toAddress.map { InternetAddress(it) }.toTypedArray()
message.setRecipients(Message.RecipientType.TO, toAddress)
// 设置抄送者收件箱
val ccAddress = ccAddress.map { InternetAddress(it) }.toTypedArray()
message.setRecipients(Message.RecipientType.CC, ccAddress)
// 设置密送者收件箱
val bccAddress = bccAddress.map { InternetAddress(it) }.toTypedArray()
message.setRecipients(Message.RecipientType.BCC, bccAddress)
// 设置发件箱
when {
nickname.isEmpty() -> message.setFrom(InternetAddress(from))
else -> try {
var name = nickname.replace(":", "-").replace("\n", "-")
name = MimeUtility.encodeText(name)
message.setFrom(InternetAddress("$name <$from>"))
} catch (e: Exception) {
e.printStackTrace()
message.setFrom(InternetAddress(from))
}
}
// 邮件主题
try {
message.subject = MimeUtility.encodeText(subject.replace(":", "-").replace("\n", "-"))
} catch (e: Exception) {
e.printStackTrace()
message.subject = subject
}
// 邮件内容
val contentPart = MimeMultipart("mixed")
// 邮件正文
val textBodyPart = MimeBodyPart()
textBodyPart.setContent(body, "text/html;charset=UTF-8")
contentPart.addBodyPart(textBodyPart)
// 邮件附件
attachFiles.forEach {
val fileBodyPart = MimeBodyPart()
val ds = FileDataSource(it)
val dh = DataHandler(ds)
fileBodyPart.dataHandler = dh
fileBodyPart.fileName = MimeUtility.encodeText(dh.name)
contentPart.addBodyPart(fileBodyPart)
}
message.setContent(contentPart)
message.sentDate = Date()
message.saveChanges()
return message
}
// 获取签名邮件: https://datatracker.ietf.org/doc/html/rfc3156#autoid-5
private fun getSignedMessage(originalMessage: MimeMessage): MimeMessage {
// 将原始邮件作为第一个部分添加到 multipart 中
val originalBodyPart = MimeBodyPart()
originalBodyPart.setContent(originalMessage.content, originalMessage.contentType)
// 将原始消息写入InputStream
val baos = ByteArrayOutputStream()
originalBodyPart.writeTo(baos)
val inputStream: InputStream = ByteArrayInputStream(baos.toByteArray())
// 签名数据
val secretKeyDecryptor = SecretKeyRingProtector.unlockAnyKeyWith(Passphrase.fromPassword(senderPGPSecretKeyPassword))
val outputStream = ByteArrayOutputStream()
val encryptionStream = PGPainless.encryptAndOrSign()
.onOutputStream(outputStream)
.withOptions(
ProducerOptions.sign(
SigningOptions()
.addDetachedSignature(secretKeyDecryptor, senderPGPSecretKeyRing!!, DocumentSignatureType.BINARY_DOCUMENT)
.overrideHashAlgorithm(HashAlgorithm.SHA256)
).setAsciiArmor(true)
)
Streams.pipeAll(inputStream, encryptionStream)
encryptionStream.close()
// 签名部分
val signaturePart = MimeBodyPart().apply {
//dataHandler = DataHandler(ByteArrayDataSource(outputStream.toString(), "application/pgp-signature"))
//fileName = "signature.asc"
setContent(outputStream.toString(), "application/pgp-signature")
//setHeader("Content-Type", "application/pgp-signature; name=\"signature.asc\"")
addHeader("Content-Description", "OpenPGP digital signature")
addHeader("Content-Disposition", "attachment; filename=\"signature.asc\"")
}
val signedMultiPart = MimeMultipart("signed; micalg=pgp-sha256; protocol=\"application/pgp-signature\"")
signedMultiPart.addBodyPart(originalBodyPart, 0)
signedMultiPart.addBodyPart(signaturePart, 1)
val signedMessage = MimeMessage(originalMessage.session)
signedMessage.setRecipients(Message.RecipientType.TO, originalMessage.getRecipients(Message.RecipientType.TO))
signedMessage.setRecipients(Message.RecipientType.CC, originalMessage.getRecipients(Message.RecipientType.CC))
signedMessage.setRecipients(Message.RecipientType.BCC, originalMessage.getRecipients(Message.RecipientType.BCC))
signedMessage.addFrom(originalMessage.from)
signedMessage.subject = originalMessage.subject
signedMessage.sentDate = originalMessage.sentDate
signedMessage.setContent(signedMultiPart)
signedMessage.saveChanges()
return signedMessage
}
// 获取加密邮件: https://datatracker.ietf.org/doc/html/rfc3156#section-4
private fun getEncryptedMessage(originalMessage: MimeMessage, producerOptions: ProducerOptions): MimeMessage {
// 将原始消息写入InputStream
val baos = ByteArrayOutputStream()
originalMessage.writeTo(baos)
val inputStream: InputStream = ByteArrayInputStream(baos.toByteArray())
// 加密数据
val outputStream = ByteArrayOutputStream()
val encryptionStream = PGPainless.encryptAndOrSign().onOutputStream(outputStream).withOptions(producerOptions)
Streams.pipeAll(inputStream, encryptionStream)
encryptionStream.close()
val result = encryptionStream.result
Log.d(TAG, result.toString())
// The first body part contains the control information necessary to
// decrypt the data in the second body part and is labeled according to
// the value of the protocol parameter.
val versionPart = MimeBodyPart().apply {
setText("Version: 1")
addHeader("Content-Type", "application/pgp-encrypted")
addHeader("Content-Description", "PGP/MIME version identification")
//addHeader("Content-Transfer-Encoding", "base64")
}
// The second body part contains the data which was encrypted
// and is always labeled application/octet-stream.
val encryptedPart = MimeBodyPart().apply {
dataHandler = DataHandler(ByteArrayDataSource(outputStream.toByteArray(), "application/octet-stream"))
fileName = "encrypted.asc"
addHeader("Content-Type", "application/octet-stream; name=\"encrypted.asc\"")
addHeader("Content-Description", "OpenPGP encrypted message")
addHeader("Content-Disposition", "inline; filename=\"encrypted.asc\"")
}
val encryptedMultiPart = MimeMultipart("encrypted; protocol=\"application/pgp-encrypted\"")
encryptedMultiPart.addBodyPart(versionPart, 0)
encryptedMultiPart.addBodyPart(encryptedPart, 1)
val encryptedMessage = MimeMessage(originalMessage.session)
encryptedMessage.setRecipients(Message.RecipientType.TO, originalMessage.getRecipients(Message.RecipientType.TO))
encryptedMessage.setRecipients(Message.RecipientType.CC, originalMessage.getRecipients(Message.RecipientType.CC))
encryptedMessage.setRecipients(Message.RecipientType.BCC, originalMessage.getRecipients(Message.RecipientType.BCC))
encryptedMessage.addFrom(originalMessage.from)
encryptedMessage.subject = originalMessage.subject
encryptedMessage.sentDate = originalMessage.sentDate
encryptedMessage.setContent(encryptedMultiPart)
encryptedMessage.saveChanges()
return encryptedMessage
}
}

@ -0,0 +1,251 @@
package com.idormy.sms.forwarder.utils.mail
import com.idormy.sms.forwarder.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.bouncycastle.cert.jcajce.JcaCertStore
import org.bouncycastle.cert.jcajce.JcaX509CertificateHolder
import org.bouncycastle.cms.CMSAlgorithm
import org.bouncycastle.cms.CMSEnvelopedDataGenerator
import org.bouncycastle.cms.CMSProcessableByteArray
import org.bouncycastle.cms.CMSSignedDataGenerator
import org.bouncycastle.cms.jcajce.JcaSignerInfoGeneratorBuilder
import org.bouncycastle.cms.jcajce.JceCMSContentEncryptorBuilder
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientInfoGenerator
import org.bouncycastle.jce.provider.BouncyCastleProvider
import org.bouncycastle.operator.OutputEncryptor
import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder
import org.bouncycastle.operator.jcajce.JcaDigestCalculatorProviderBuilder
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.security.PrivateKey
import java.security.Security
import java.security.cert.X509Certificate
import java.util.Date
import java.util.Properties
import javax.activation.DataHandler
import javax.activation.FileDataSource
import javax.mail.Authenticator
import javax.mail.Message
import javax.mail.Session
import javax.mail.Transport
import javax.mail.internet.InternetAddress
import javax.mail.internet.MimeBodyPart
import javax.mail.internet.MimeMessage
import javax.mail.internet.MimeMultipart
import javax.mail.internet.MimeUtility
@Suppress("PrivatePropertyName", "unused")
class SmimeUtils(
private val properties: Properties,
private val authenticator: Authenticator,
// 邮件参数
private val from: String, // 发件人邮箱
private val nickname: String, // 发件人昵称
private val subject: String, // 邮件主题
private val body: String, // 邮件正文
private val attachFiles: MutableList<File> = mutableListOf(), // 附件
// 收件人参数
private val toAddress: MutableList<String> = mutableListOf(), // 收件人邮箱
private val ccAddress: MutableList<String> = mutableListOf(), // 抄送者邮箱
private val bccAddress: MutableList<String> = mutableListOf(), // 密送者邮箱
// 邮件 S/MIME 加密和签名
private val recipientX509Cert: X509Certificate? = null, //收件人公钥(用于加密)
private val senderPrivateKey: PrivateKey? = null, //发件人私玥(用于签名)
private val senderX509Cert: X509Certificate? = null, //发件人公玥(用于签名)
) {
private val TAG: String = SmimeUtils::class.java.simpleName
init {
Security.addProvider(BouncyCastleProvider())
}
// 发送明文邮件
suspend fun sendPlainEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
Log.d(TAG, "sendPlainEmail")
try {
val originalMessage = getOriginalMessage()
Transport.send(originalMessage)
Pair(true, "Email sent successfully")
} catch (e: Exception) {
e.printStackTrace()
Pair(false, "Failed to send email: ${e.message}")
}
}
// 发送签名后的邮件
suspend fun sendSignedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
Log.d(TAG, "sendSignedEmail")
try {
val originalMessage = getOriginalMessage()
val signedMessage = getSignedMessage(originalMessage)
Transport.send(signedMessage)
Pair(true, "Email signed and sent successfully")
} catch (e: Exception) {
e.printStackTrace()
Pair(false, "Failed to sign and send email: ${e.message}")
}
}
// 发送加密邮件
suspend fun sendEncryptedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
Log.d(TAG, "sendEncryptedEmail")
try {
val originalMessage = getOriginalMessage()
val encryptedMessage = getEncryptedMessage(originalMessage)
Transport.send(encryptedMessage)
Pair(true, "Encrypted email sent successfully")
} catch (e: Exception) {
e.printStackTrace()
Pair(false, "Failed to send encrypted email: ${e.message}")
}
}
// 发送签名加密邮件
suspend fun sendSignedAndEncryptedEmail(): Pair<Boolean, String> = withContext(Dispatchers.IO) {
Log.d(TAG, "sendSignedAndEncryptedEmail")
try {
val originalMessage = getOriginalMessage()
val signedMessage = getSignedMessage(originalMessage)
val encryptedMessage = getEncryptedMessage(signedMessage)
Transport.send(encryptedMessage)
Pair(true, "Signed and encrypted email sent successfully")
} catch (e: Exception) {
e.printStackTrace()
Pair(false, "Failed to send signed and encrypted email: ${e.message}")
}
}
// 获取原始邮件
private fun getOriginalMessage(): MimeMessage {
val session = Session.getInstance(properties, authenticator)
session.debug = true
val message = MimeMessage(session)
// 设置直接接收者收件箱
val toAddress = toAddress.map { InternetAddress(it) }.toTypedArray()
message.setRecipients(Message.RecipientType.TO, toAddress)
// 设置抄送者收件箱
val ccAddress = ccAddress.map { InternetAddress(it) }.toTypedArray()
message.setRecipients(Message.RecipientType.CC, ccAddress)
// 设置密送者收件箱
val bccAddress = bccAddress.map { InternetAddress(it) }.toTypedArray()
message.setRecipients(Message.RecipientType.BCC, bccAddress)
// 设置发件箱
when {
nickname.isEmpty() -> message.setFrom(InternetAddress(from))
else -> try {
var name = nickname.replace(":", "-").replace("\n", "-")
name = MimeUtility.encodeText(name)
message.setFrom(InternetAddress("$name <$from>"))
} catch (e: Exception) {
e.printStackTrace()
message.setFrom(InternetAddress(from))
}
}
// 邮件主题
try {
message.subject = MimeUtility.encodeText(subject.replace(":", "-").replace("\n", "-"))
} catch (e: Exception) {
e.printStackTrace()
message.subject = subject
}
// 邮件内容
val contentPart = MimeMultipart("mixed")
// 邮件正文
val textBodyPart = MimeBodyPart()
textBodyPart.setContent(body, "text/html;charset=UTF-8")
contentPart.addBodyPart(textBodyPart)
// 邮件附件
attachFiles.forEach {
val fileBodyPart = MimeBodyPart()
val ds = FileDataSource(it)
val dh = DataHandler(ds)
fileBodyPart.dataHandler = dh
fileBodyPart.fileName = MimeUtility.encodeText(dh.name)
contentPart.addBodyPart(fileBodyPart)
}
message.setContent(contentPart)
message.sentDate = Date()
message.saveChanges()
return message
}
// 获取签名邮件
private fun getSignedMessage(originalMessage: MimeMessage): MimeMessage {
// 创建签名者信息生成器
val contentSigner = JcaContentSignerBuilder("SHA256withRSA").build(senderPrivateKey)
val certificateHolder = JcaX509CertificateHolder(senderX509Cert)
val signerInfoGenerator = JcaSignerInfoGeneratorBuilder(
JcaDigestCalculatorProviderBuilder().setProvider(BouncyCastleProvider()).build()
).build(contentSigner, certificateHolder)
// 创建 CMSSignedDataGenerator 并添加签名者信息和证书
val generator = CMSSignedDataGenerator()
generator.addSignerInfoGenerator(signerInfoGenerator)
val certStore = JcaCertStore(listOf(senderX509Cert))
generator.addCertificates(certStore)
// 将邮件内容转换为 CMSSignedData
//val originalContent = originalMessage.content as MimeMultipart //TODO: Outlook 不显示正文
val outputStream = ByteArrayOutputStream()
//originalContent.writeTo(outputStream)
originalMessage.writeTo(outputStream) //TODO: Thunderbird 会重复现实发件人
val contentData = CMSProcessableByteArray(outputStream.toByteArray())
val signedData = generator.generate(contentData, true)
// 创建 MimeMessage 并设置签名后的内容
val signedMessage = MimeMessage(originalMessage.session, ByteArrayInputStream(signedData.encoded))
signedMessage.setRecipients(Message.RecipientType.TO, originalMessage.getRecipients(Message.RecipientType.TO))
signedMessage.setRecipients(Message.RecipientType.CC, originalMessage.getRecipients(Message.RecipientType.CC))
signedMessage.setRecipients(Message.RecipientType.BCC, originalMessage.getRecipients(Message.RecipientType.BCC))
signedMessage.addFrom(originalMessage.from)
signedMessage.subject = originalMessage.subject
signedMessage.sentDate = originalMessage.sentDate
signedMessage.setContent(signedData.encoded, "application/pkcs7-mime; name=smime.p7m; smime-type=signed-data")
signedMessage.saveChanges()
return signedMessage
}
// 获取加密邮件
private fun getEncryptedMessage(originalMessage: MimeMessage): MimeMessage {
// 使用收件人的证书进行加密
val cmsEnvelopedDataGenerator = CMSEnvelopedDataGenerator()
val recipientInfoGenerator = JceKeyTransRecipientInfoGenerator(recipientX509Cert)
cmsEnvelopedDataGenerator.addRecipientInfoGenerator(recipientInfoGenerator)
// 使用 3DES 加密
val outputEncryptor: OutputEncryptor = JceCMSContentEncryptorBuilder(CMSAlgorithm.DES_EDE3_CBC).build()
val originalContent = ByteArrayOutputStream()
originalMessage.writeTo(originalContent)
val inputStream = originalContent.toByteArray()
val cmsEnvelopedData = cmsEnvelopedDataGenerator.generate(
CMSProcessableByteArray(inputStream),
outputEncryptor
)
// 创建加密邮件
val encryptedMessage = MimeMessage(originalMessage.session)
encryptedMessage.setRecipients(Message.RecipientType.TO, originalMessage.getRecipients(Message.RecipientType.TO))
encryptedMessage.setRecipients(Message.RecipientType.CC, originalMessage.getRecipients(Message.RecipientType.CC))
encryptedMessage.setRecipients(Message.RecipientType.BCC, originalMessage.getRecipients(Message.RecipientType.BCC))
encryptedMessage.addFrom(originalMessage.from)
encryptedMessage.subject = originalMessage.subject
encryptedMessage.sentDate = originalMessage.sentDate
encryptedMessage.setContent(cmsEnvelopedData.encoded, "application/pkcs7-mime; name=smime.p7m; smime-type=enveloped-data")
encryptedMessage.setHeader("Content-Type", "application/pkcs7-mime; name=smime.p7m; smime-type=enveloped-data")
encryptedMessage.setHeader("Content-Disposition", "attachment; filename=smime.p7m")
encryptedMessage.setHeader("Content-Description", "S/MIME Encrypted Message")
encryptedMessage.addHeader("Content-Transfer-Encoding", "base64")
encryptedMessage.saveChanges()
return encryptedMessage
}
}

@ -7,14 +7,25 @@ import com.idormy.sms.forwarder.entity.setting.EmailSetting
import com.idormy.sms.forwarder.utils.Log
import com.idormy.sms.forwarder.utils.SendUtils
import com.idormy.sms.forwarder.utils.SettingUtils
import com.idormy.sms.forwarder.utils.mail.Mail
import com.idormy.sms.forwarder.utils.mail.MailSender
import com.idormy.sms.forwarder.utils.mail.EmailSender
import com.xuexiang.xutil.resource.ResUtils.getString
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.bouncycastle.openpgp.PGPPublicKeyRing
import org.bouncycastle.openpgp.PGPSecretKeyRing
import org.pgpainless.PGPainless
import org.pgpainless.key.info.KeyRingInfo
import java.io.FileInputStream
import java.security.KeyStore
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
class EmailUtils {
companion object {
//private val TAG: String = EmailUtils::class.java.simpleName
private val TAG: String = EmailUtils::class.java.simpleName
fun sendMsg(
setting: EmailSetting,
@ -127,36 +138,191 @@ class EmailUtils {
else -> {}
}
//收件地址
val toAddressList = setting.toEmail.toString().replace("[,;]".toRegex(), ",").trim(',').split(',')
//创建邮箱
val mail = Mail().apply {
mailServerHost = setting.host.toString()
mailServerPort = setting.port.toString()
fromAddress = setting.fromEmail.toString()
fromNickname = msgInfo.getTitleForSend(setting.nickname.toString())
password = setting.pwd.toString()
toAddress = toAddressList
subject = title
content = message.replace("\n", "<br>")
openSSL = setting.ssl == true
startTls = setting.startTls == true
}
runBlocking {
val job = launch(Dispatchers.IO) {
try {
// 设置邮件参数
val host = setting.host.toString()
val port = setting.port.toString()
val from = setting.fromEmail.toString()
val password = setting.pwd.toString()
val nickname = msgInfo.getTitleForSend(setting.nickname.toString())
setting.recipients.ifEmpty {
//兼容旧的设置
val emails = setting.toEmail.toString().replace("[,;]".toRegex(), ",").trim(',').split(',')
emails.forEach {
setting.recipients[it] = Pair("", "")
}
}
val content = message.replace("\n", "<br>")
val openSSL = setting.ssl == true
val startTls = setting.startTls == true
MailSender.getInstance().sendMail(mail, object : MailSender.OnMailSendListener {
override fun onError(e: Throwable) {
Log.e("MailSender", e.message.toString())
val status = 0
SendUtils.updateLogs(logId, status, e.message.toString())
SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId)
}
//发件人S/MIME私钥用于签名
var signingPrivateKey: PrivateKey? = null
var signingCertificate: X509Certificate? = null
//发件人OpenPGP私钥用于签名
var senderPGPSecretKeyRing: PGPSecretKeyRing? = null
var senderPGPSecretKeyPassword = ""
if (!setting.keystore.isNullOrEmpty() && !setting.password.isNullOrEmpty()) {
val keystoreStream = FileInputStream(setting.keystore)
try {
when (setting.encryptionProtocol) {
"S/MIME" -> {
val keystorePassword = setting.password.toString()
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(keystoreStream, keystorePassword.toCharArray())
val privateKeyAlias = keyStore.aliases().toList().first { keyStore.isKeyEntry(it) }
signingPrivateKey = keyStore.getKey(privateKeyAlias, keystorePassword.toCharArray()) as PrivateKey
signingCertificate = keyStore.getCertificate(privateKeyAlias) as X509Certificate
}
"OpenPGP" -> {
senderPGPSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(keystoreStream)
senderPGPSecretKeyPassword = setting.password.toString()
}
}
} catch (e: Exception) {
e.printStackTrace()
Log.w(TAG, "Failed to load keystore: ${e.message}")
}
}
// 发送结果监听器
val listener = object : EmailSender.EmailTaskListener {
override fun onEmailSent(success: Boolean, message: String) {
if (success) {
SendUtils.updateLogs(logId, 2, getString(R.string.request_succeeded) + ": " + message)
SendUtils.senderLogic(2, msgInfo, rule, senderIndex, msgId)
} else {
val status = 0
SendUtils.updateLogs(logId, status, message)
SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId)
}
}
}
//逐一发送加密邮件
val recipientsWithoutCert = mutableListOf<String>()
setting.recipients.forEach { (email, cert) ->
val keystorePath = cert.first
val keystorePassword = cert.second
var recipientX509Cert: X509Certificate? = null
var recipientPGPPublicKeyRing: PGPPublicKeyRing? = null
try {
when {
//从私钥证书文件提取公钥
keystorePath.isNotEmpty() && keystorePassword.isNotEmpty() -> {
val keystoreStream = FileInputStream(keystorePath)
when (setting.encryptionProtocol) {
"S/MIME" -> {
val keyStore = KeyStore.getInstance("PKCS12")
keyStore.load(keystoreStream, keystorePassword.toCharArray())
val alias = keyStore.aliases().nextElement()
recipientX509Cert = keyStore.getCertificate(alias) as X509Certificate
}
"OpenPGP" -> {
val recipientPGPSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(keystoreStream)
recipientPGPPublicKeyRing = recipientPGPSecretKeyRing?.let { PGPainless.extractCertificate(it) }
if (recipientPGPPublicKeyRing != null) {
val keyInfo = KeyRingInfo(recipientPGPPublicKeyRing)
Log.d(TAG, "Recipient key info: $keyInfo")
}
}
}
}
override fun onSuccess() {
SendUtils.updateLogs(logId, 2, getString(R.string.request_succeeded))
SendUtils.senderLogic(2, msgInfo, rule, senderIndex, msgId)
//从证书文件提取公钥
keystorePath.isNotEmpty() && keystorePassword.isEmpty() -> {
val keystoreStream = FileInputStream(keystorePath)
when (setting.encryptionProtocol) {
"S/MIME" -> {
val certFactory = CertificateFactory.getInstance("X.509")
recipientX509Cert = certFactory.generateCertificate(FileInputStream(keystorePath)) as X509Certificate
}
"OpenPGP" -> {
recipientPGPPublicKeyRing = PGPainless.readKeyRing().publicKeyRing(keystoreStream)
if (recipientPGPPublicKeyRing != null) {
val keyInfo = KeyRingInfo(recipientPGPPublicKeyRing)
Log.d(TAG, "Recipient key info: $keyInfo")
}
}
}
}
else -> {
recipientsWithoutCert.add(email)
}
}
} catch (e: Exception) {
e.printStackTrace()
Log.w(TAG, "Failed to load recipient($email) keystore($cert): ${e.message}")
//无法加载证书时,发送明文邮件
recipientsWithoutCert.add(email)
}
if (recipientX509Cert != null || recipientPGPPublicKeyRing != null) {
val senderWithRecipientCert = EmailSender(
host,
port,
from,
password,
nickname,
title,
content,
toAddress = mutableListOf(email),
listener = listener,
openSSL = openSSL,
startTls = startTls,
encryptionProtocol = setting.encryptionProtocol,
recipientX509Cert = recipientX509Cert,
senderPrivateKey = signingPrivateKey,
senderX509Cert = signingCertificate,
recipientPGPPublicKeyRing = recipientPGPPublicKeyRing,
senderPGPSecretKeyRing = senderPGPSecretKeyRing,
senderPGPSecretKeyPassword = senderPGPSecretKeyPassword,
)
senderWithRecipientCert.sendEmail()
}
}
//批量发送明文邮件
if (recipientsWithoutCert.isNotEmpty()) {
val senderWithoutRecipientCert = EmailSender(
host,
port,
from,
password,
nickname,
title,
content,
toAddress = recipientsWithoutCert,
listener = listener,
openSSL = openSSL,
startTls = startTls,
encryptionProtocol = setting.encryptionProtocol,
senderPrivateKey = signingPrivateKey,
senderX509Cert = signingCertificate,
//TODO: OpenPGP 只签名不加密时,提示无效的数字签名,暂未解决
senderPGPSecretKeyRing = senderPGPSecretKeyRing,
senderPGPSecretKeyPassword = senderPGPSecretKeyPassword,
)
senderWithoutRecipientCert.sendEmail()
}
} catch (e: Exception) {
e.printStackTrace()
Log.e(TAG, e.message.toString())
val status = 0
SendUtils.updateLogs(logId, status, e.message.toString())
SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId)
}
}
})
job.join() // 等待协程完成
}
}

@ -27,7 +27,8 @@ import java.net.URLEncoder
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
import java.text.SimpleDateFormat
import java.util.*
import java.util.Date
import java.util.UUID
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
@ -103,9 +104,9 @@ class SocketUtils {
} else if (setting.method == "MQTT") {
// MQTT 连接参数
val uriType = if (TextUtils.isEmpty(setting.uriType)) "tcp" else setting.uriType
val brokerUrl = "${uriType}://${setting.address}:${setting.port}"
var brokerUrl = "${uriType}://${setting.address}:${setting.port}"
if (!TextUtils.isEmpty(setting.path)) {
brokerUrl.plus(setting.path)
brokerUrl += setting.path
}
Log.d(TAG, "MQTT brokerUrl: $brokerUrl")
val clientId = if (TextUtils.isEmpty(setting.clientId)) UUID.randomUUID().toString() else setting.clientId

@ -138,7 +138,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:text="@string/custom_template_tips"
android:textSize="@dimen/text_size_small" />
android:textSize="@dimen/text_size_mini" />
</LinearLayout>
@ -302,29 +302,183 @@
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/email_to"
android:text="@string/encryption_protocol"
android:textStyle="bold" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:text="@string/email_to_tips"
android:textSize="@dimen/text_size_small" />
android:text="@string/encryption_protocol_tips"
android:textSize="@dimen/text_size_mini" />
</LinearLayout>
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_to_email"
<RadioGroup
android:id="@+id/rg_encryption_protocol"
style="@style/rg_style"
android:orientation="horizontal">
<RadioButton
android:id="@+id/rb_encryption_protocol_plain"
style="@style/rg_rb_style"
android:checked="true"
android:text="@string/plain" />
<RadioButton
android:id="@+id/rb_encryption_protocol_smime"
style="@style/rg_rb_style"
android:text="@string/smime" />
<RadioButton
android:id="@+id/rb_encryption_protocol_openpgp"
style="@style/rg_rb_style"
android:text="@string/openpgp" />
</RadioGroup>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_sender_keystore"
style="@style/BarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<TextView
android:id="@+id/tv_sender_keystore"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:singleLine="true"
app:met_clearButton="true" />
android:text="@string/sender_smime_keystore"
android:textStyle="bold" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/keystore_path"
android:textSize="@dimen/text_size_small"
android:textStyle="bold" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_sender_keystore"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:hint="@string/keystore_path_tips"
android:importantForAutofill="no"
android:singleLine="true"
android:textSize="@dimen/text_size_small"
app:met_clearButton="true" />
<com.xuexiang.xui.widget.button.shadowbutton.RippleShadowShadowButton
android:id="@+id/btn_sender_keystore_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:gravity="center"
android:padding="5dp"
android:text="@string/select_file"
android:textColor="@color/white"
android:textSize="@dimen/text_size_mini"
app:sb_color_unpressed="@color/colorBlueGrey"
app:sb_ripple_color="@color/white"
app:sb_ripple_duration="500"
app:sb_shape_type="rectangle"
tools:ignore="SmallSp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/keystore_password"
android:textSize="@dimen/text_size_small"
android:textStyle="bold" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_sender_password"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:hint="@string/keystore_password_tips"
android:importantForAutofill="no"
android:singleLine="true"
android:textSize="@dimen/text_size_small"
app:met_passWordButton="true" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_recipients"
style="@style/BarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal"
tools:ignore="UseCompoundDrawables">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
tools:ignore="UseCompoundDrawables">
<TextView
android:id="@+id/tv_email_to"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/email_to"
android:textStyle="bold" />
<TextView
android:id="@+id/tv_email_to_tips"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/email_to_tips"
android:textSize="@dimen/text_size_mini" />
</LinearLayout>
<ImageView
android:id="@+id/btn_add_recipient"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginStart="@dimen/config_margin_5dp"
android:background="@color/colorBlueGrey"
android:src="@drawable/icon_api_contact_add"
app:tint="#FFFFFF"
tools:ignore="ContentDescription,ImageContrastCheck" />
</LinearLayout>
</LinearLayout>
@ -352,7 +506,7 @@
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:text="@string/custom_template_tips"
android:textSize="@dimen/text_size_small" />
android:textSize="@dimen/text_size_mini" />
</LinearLayout>

@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="vertical">
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_marginTop="@dimen/config_margin_5dp"
android:background="?attr/xui_config_color_separator_light" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/recipient_email"
android:textSize="@dimen/text_size_small"
android:textStyle="bold" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_recipient_email"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:gravity="center_vertical"
android:singleLine="true"
android:textSize="@dimen/text_size_small"
app:met_clearButton="true" />
<ImageView
android:id="@+id/iv_del"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="5dp"
android:contentDescription="@string/del"
android:src="@drawable/ic_delete"
app:tint="#F15C58" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_recipient_keystore"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:visibility="gone">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/keystore_path"
android:textSize="@dimen/text_size_small"
android:textStyle="bold" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_recipient_keystore"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:hint="@string/keystore_path_tips"
android:importantForAutofill="no"
android:singleLine="true"
android:textSize="@dimen/text_size_small"
app:met_clearButton="true" />
<com.xuexiang.xui.widget.button.shadowbutton.RippleShadowShadowButton
android:id="@+id/btn_file_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:gravity="center"
android:padding="5dp"
android:text="@string/select_file"
android:textColor="@color/white"
android:textSize="@dimen/text_size_mini"
app:sb_color_unpressed="@color/colorBlueGrey"
app:sb_ripple_color="@color/white"
app:sb_ripple_duration="500"
app:sb_shape_type="rectangle"
tools:ignore="SmallSp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="horizontal">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/keystore_password"
android:textSize="@dimen/text_size_small"
android:textStyle="bold" />
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
android:id="@+id/et_recipient_password"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
android:layout_weight="1"
android:hint="@string/keystore_password_tips"
android:importantForAutofill="no"
android:singleLine="true"
android:textSize="@dimen/text_size_small"
app:met_passWordButton="true" />
</LinearLayout>
</LinearLayout>
</LinearLayout>

@ -293,6 +293,9 @@
<string name="invalid_name">Channel name cannot be empty</string>
<string name="invalid_token">invalid token</string>
<string name="invalid_email">Email parameter is incomplete</string>
<string name="invalid_recipient_email">Invalid recipient email address: %s</string>
<string name="invalid_x509_certificate">The X.509 public key certificate for the recipient (%s) is invalid.</string>
<string name="invalid_pkcs12_certificate">The PKCS12 private key certificate for the recipient (%s) is invalid.</string>
<string name="invalid_email_server">Email Server parameter is incomplete</string>
<string name="invalid_bark_icon">The bark-icon is not a valid URL</string>
<string name="invalid_bark_url">The bark-url is not a valid URL</string>
@ -323,11 +326,24 @@
<string name="email_account">Account</string>
<string name="email_password">Password/Auth Code</string>
<string name="email_nickname">Nickname</string>
<string name="email_to">Send To</string>
<string name="email_to_tips">Tip: Separate multiple recipients with a ","</string>
<string name="email_to">Recipients</string>
<string name="email_to_tips">Tip: Click to add recipients.</string>
<string name="email_to_smime">Recipients &amp; S/MIME Encryption Cert.</string>
<string name="email_to_smime_tips">Tip: Click to add recipients and S/MIME encryption public keys (opt.).</string>
<string name="email_to_openpgp">Recipients &amp; OpenPGP Public Cert.</string>
<string name="email_to_openpgp_tips">Tip: Click to add recipients and OpenPGP public keys (opt.).</string>
<string name="sender_smime_keystore">Sender S/MIME Cert. (Opt.)</string>
<string name="sender_openpgp_keystore">Sender OpenPGP Cert. (Opt.)</string>
<string name="invalid_sender_keystore">Invalid Sender Signing Private Key</string>
<string name="smime_public_keys_tips">Add public keys for S/MIME encryption, leave blank otherwise.</string>
<string name="recipient_email">Recipient</string>
<string name="keystore_path">Cert. Path</string>
<string name="keystore_path_tips">Opt., Copy keystore to the Download dir</string>
<string name="keystore_password">Cert. Pwd.</string>
<string name="keystore_password_tips">Import password for `Private key`</string>
<string name="email_title">Email Title</string>
<string name="feishu_webhook">Webhook</string>
<string name="feishu_secret">Secret (optional)</string>
<string name="feishu_secret">Secret (opt.)</string>
<string name="feishu_receive_id_type">Receive Id Type"</string>
<string name="feishu_msg_type">Msg Type</string>
<string name="feishu_msg_type_text">Text</string>
@ -353,10 +369,10 @@
<string name="server_chan_send_key">SendKey</string>
<string name="server_chan_channel">Message Channel</string>
<string name="server_chan_channel_tips">Tip: Dynamically specified, supports up to two channels, separated by a vertical bar |</string>
<string name="server_chan_channel_hint">Optional, e.g. to send service number and enterprise WeChat application, then fill in 9|66</string>
<string name="server_chan_channel_hint">Opt., e.g. to send service number and enterprise WeChat application, then fill in 9|66</string>
<string name="server_chan_openid">CC OpenID</string>
<string name="server_chan_openid_tips">Tip: Only test accounts and Wework application message channels are supported</string>
<string name="server_chan_openid_hint">Optional, multiple openids are separated by commas</string>
<string name="server_chan_openid_hint">Opt., multiple openids are separated by commas</string>
<string name="TelegramApiToken">ApiToken or Custom Proxy Address (startwith http)</string>
<string name="TelegramChatId">ChatId</string>
<string name="Method" formatted="false">Method</string>
@ -493,6 +509,11 @@
<string name="account">✱Account</string>
<string name="servers">✱Servers</string>
<string name="email_settings">✱Email</string>
<string name="encryption_protocol">E2EE</string>
<string name="encryption_protocol_tips">Tip: To encrypt or sign emails, specify OpenPGP or S/MIME cert.</string>
<string name="plain">Plain</string>
<string name="smime">S/MIME</string>
<string name="openpgp">OpenPGP</string>
<string name="below"><![CDATA[≤]]></string>
<string name="percent">%</string>
<string name="above"><![CDATA[≥]]></string>
@ -515,7 +536,7 @@
<string name="out_message_topic">Out Message Topic</string>
<string name="out_message_topic_hint">Send a message on the corresponding topic</string>
<string name="uri_type">Uri Type</string>
<string name="uri_type_hint">Optional, default is tcp</string>
<string name="uri_type_hint">Opt., default is tcp</string>
<string name="path">Path</string>
<string name="path_hint">Used to set the uri when communicating using ws</string>
<string name="client_id">Client Id</string>
@ -571,8 +592,8 @@
<string name="outgoing_call_ended">Out Ended</string>
<string name="missed_call">Missed</string>
<string name="unknown_call">Unknown</string>
<string name="optional_action">Optional: </string>
<string name="optional_type">Optional: </string>
<string name="optional_action">Opt.: </string>
<string name="optional_type">Opt.: </string>
<string name="active_request">Active request</string>
<string name="active_request_tips">Obtain instructions through passive reception or active polling to operate the machine</string>
<string name="httpserver">Local HttpServer</string>
@ -708,7 +729,7 @@
<string name="one_pixel">One Pixel</string>
<string name="one_pixel_activity">One Pixel Activity</string>
<string name="one_pixel_activity_tips">This can change the process priority from 4 to 1</string>
<string name="optional">Optional</string>
<string name="optional">Opt.</string>
<string name="TelegramChatIdTips">Follow the steps in the wiki to obtain it</string>
<string name="network_model">Network model</string>
<string name="offline_mode">Offline mode</string>
@ -762,15 +783,15 @@
<string name="bark_server_regex">^https?://[^/]+/[^/]+/$</string>
<string name="bark_server_error">The Url format is wrong, e.g. https://api.day.app/XXXXXXXX/</string>
<string name="bark_group">Group Name</string>
<string name="bark_group_tips">Optional, e.g. SmsForwarder</string>
<string name="bark_group_tips">Opt., e.g. SmsForwarder</string>
<string name="bark_icon">Message Icon</string>
<string name="bark_icon_tips">Optional, fill in Url, the picture should not be too big</string>
<string name="bark_icon_tips">Opt., fill in Url, the picture should not be too big</string>
<string name="bark_sound">Message Sound</string>
<string name="bark_sound_tips">Optional, e.g. minuet.caf</string>
<string name="bark_sound_tips">Opt., e.g. minuet.caf</string>
<string name="bark_badge">Message Badge</string>
<string name="bark_badge_tips">Optional, e.g. 888</string>
<string name="bark_badge_tips">Opt., e.g. 888</string>
<string name="bark_url">Message Link</string>
<string name="bark_url_tips">Optional, e.g. https://github.com/pppscn/SmsForwarder</string>
<string name="bark_url_tips">Opt., e.g. https://github.com/pppscn/SmsForwarder</string>
<string name="bark_level">Notification Level</string>
<string name="bark_level_active">Immediately display notifications</string>
<string name="bark_level_timeSensitive">Time-sensitive notifications that can be displayed in a focused state</string>
@ -941,7 +962,7 @@
<string name="sim_slot">Sim Slot</string>
<string name="display_name">Display Name</string>
<string name="display_name_hint">Optional, address book display name</string>
<string name="display_name_hint">Opt., address book display name</string>
<string name="phone_numbers">Phone Numbers</string>
<string name="phone_numbers_hint">Required, separate multiple phone numbers with semicolons</string>
<string name="phone_numbers_error">Invalid Phone Numbers, eg. 15888888888;19999999999</string>
@ -1042,7 +1063,7 @@
<string name="enabling_pure_task_mode">Do you want to quit the app immediately and start it manually to take effect in pure task mode?</string>
<string name="debug_mode">Enable debug mode</string>
<string name="debug_mode_tips">Save Log.* to file for troubleshooting; export to download directory.</string>
<string name="optional_components">Optional:</string>
<string name="optional_components">Opt.:</string>
<string name="enable_cactus">Enable Cactus Keep Alive</string>
<string name="enabe_cactus_tips">Dual foreground service/JobScheduler/WorkManager/1px/silent music</string>
<string name="load_app_list">Get installed app info async at startup</string>
@ -1078,6 +1099,7 @@
<string name="restarting_httpserver">Restarting HttpServer</string>
<string name="download_first">Download and unzip to:\n%s</string>
<string name="download_music_first">Download music file to:\n%s</string>
<string name="download_certificate_first">Download certificate file to:\n%s</string>
<string name="root_directory">Root Directory:\n%s</string>
<string name="select_web_client_directory">Select WebClient Directory</string>
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId cannot be empty</string>
@ -1401,7 +1423,7 @@
<string name="stop_alarm">Stop Alarm</string>
<string name="alarm_play_settings">Playback Settings</string>
<string name="alarm_music">Specify Music</string>
<string name="alarm_music_tips">Optional, download mp3/ogg/wav to the Download directory.</string>
<string name="alarm_music_tips">Opt., download mp3/ogg/wav to the Download directory.</string>
<string name="alarm_volume">Alarm Volume</string>
<string name="alarm_play_times">Play Times(0=Infinite)</string>

@ -294,6 +294,9 @@
<string name="invalid_name">通道名称不能为空</string>
<string name="invalid_token">token不合法</string>
<string name="invalid_email">发件邮箱/登录密码/收件地址不可为空</string>
<string name="invalid_recipient_email">收件地址(%s)不合法</string>
<string name="invalid_x509_certificate">收件地址(%s)的X.509公钥证书无效</string>
<string name="invalid_pkcs12_certificate">收件地址(%s)的PKCS12私钥证书无效</string>
<string name="invalid_email_server">服务器信息的主机/端口不可为空</string>
<string name="invalid_bark_icon">消息图标不是有效URL</string>
<string name="invalid_bark_url">消息链接不是有效URL</string>
@ -324,8 +327,21 @@
<string name="email_account">发件邮箱</string>
<string name="email_password">登录密码</string>
<string name="email_nickname">发件人昵称</string>
<string name="email_to">收件地址</string>
<string name="email_to_tips">Tip多个收件人以半角逗号,分隔</string>
<string name="email_to">收件人邮箱</string>
<string name="email_to_tips">Tip: 点击按钮添加收件人邮箱</string>
<string name="email_to_smime">收件人邮箱 &amp; S/MIME加密公钥</string>
<string name="email_to_smime_tips">Tip: 点击按钮添加收件人邮箱、S/MIME加密公钥可选</string>
<string name="email_to_openpgp">收件人邮箱 &amp; OpenPGP加密公钥</string>
<string name="email_to_openpgp_tips">Tip: 点击按钮添加收件人邮箱、OpenPGP加密公钥可选</string>
<string name="sender_smime_keystore">发件人S/MIME签名私钥可选</string>
<string name="sender_openpgp_keystore">发件人OpenPGP签名私钥可选</string>
<string name="invalid_sender_keystore">发件人签名私钥无效</string>
<string name="smime_public_keys_tips">对邮件进行S/MIME签名加密则添加公钥否则留空</string>
<string name="recipient_email">收件人邮箱</string>
<string name="keystore_path">证书路径</string>
<string name="keystore_path_tips">可选,下载证书文件到 Download 目录</string>
<string name="keystore_password">证书密码</string>
<string name="keystore_password_tips">`私钥证书`对应的导入密钥</string>
<string name="email_title">邮件主题</string>
<string name="feishu_webhook">Webhook 地址</string>
<string name="feishu_secret">加签 Secret (没有可不填)</string>
@ -494,6 +510,11 @@
<string name="account">✱帐户管理</string>
<string name="servers">✱服务器信息</string>
<string name="email_settings">✱邮件设置</string>
<string name="encryption_protocol">端对端加密</string>
<string name="encryption_protocol_tips">Tip若要加密或签名邮件需指定OpenPGP或S/MIME证书</string>
<string name="plain">明文</string>
<string name="smime">S/MIME</string>
<string name="openpgp">OpenPGP</string>
<string name="below">低于</string>
<string name="percent">%</string>
<string name="above">高于</string>
@ -1079,6 +1100,7 @@
<string name="restarting_httpserver">正在重启HttpServer</string>
<string name="download_first">请先下载Web客户端并解压到\n%s</string>
<string name="download_music_first">请先下载音乐文件到:\n%s</string>
<string name="download_certificate_first">请先下载证书文件到:\n%s</string>
<string name="root_directory">根目录:\n%s</string>
<string name="select_web_client_directory">选择Web客户端目录</string>
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId都不能为空</string>

@ -294,6 +294,9 @@
<string name="invalid_name">通道名稱不能為空</string>
<string name="invalid_token">token不合法</string>
<string name="invalid_email">發件郵箱/登錄密碼/收件地址不可為空</string>
<string name="invalid_recipient_email">收件地址(%s)無效</string>
<string name="invalid_x509_certificate">收件地址(%s)的X.509公鑰憑證無效</string>
<string name="invalid_pkcs12_certificate">收件地址(%s)的PKCS12私鑰憑證無效</string>
<string name="invalid_email_server">服務器信息的主機/端口不可為空</string>
<string name="invalid_bark_icon">消息圖標不是有效URL</string>
<string name="invalid_bark_url">消息鏈接不是有效URL</string>
@ -324,8 +327,21 @@
<string name="email_account">發件郵箱</string>
<string name="email_password">登錄密碼</string>
<string name="email_nickname">發件人昵稱</string>
<string name="email_to">收件地址</string>
<string name="email_to_tips">Tip多個收件人以半角逗號,分隔</string>
<string name="email_to">收件人郵箱</string>
<string name="email_to_tips">提示:點擊按鈕添加收件人郵箱</string>
<string name="email_to_smime">收件人郵箱 &amp; S/MIME加密公鑰</string>
<string name="email_to_smime_tips">提示點擊按鈕添加收件人郵箱、S/MIME加密公鑰可選</string>
<string name="email_to_openpgp">收件人郵箱 &amp; OpenPGP加密公鑰</string>
<string name="email_to_openpgp_tips">提示點擊按鈕添加收件人郵箱、OpenPGP加密公鑰可選</string>
<string name="sender_smime_keystore">發件人S/MIME簽名私鑰可選</string>
<string name="sender_openpgp_keystore">發件人OpenPGP簽名私鑰可選</string>
<string name="invalid_sender_keystore">發件人簽名私鑰無效</string>
<string name="smime_public_keys_tips">對郵件進行 S/MIME 簽名加密,則添加公鑰,否則留空</string>
<string name="recipient_email">收件人郵箱</string>
<string name="keystore_path">證書路徑</string>
<string name="keystore_path_tips">可選,下載證書文件到 Download 目錄</string>
<string name="keystore_password">證書密碼</string>
<string name="keystore_password_tips">「私鑰證書」相對應的導入密碼</string>
<string name="email_title">郵件主題</string>
<string name="feishu_webhook">Webhook 地址</string>
<string name="feishu_secret">加簽 Secret (沒有可不填)</string>
@ -494,6 +510,11 @@
<string name="account">✱帳戶管理</string>
<string name="servers">✱伺服器信息</string>
<string name="email_settings">✱郵件設置</string>
<string name="encryption_protocol">端對端加密</string>
<string name="encryption_protocol_tips">Tip若要加密或簽名郵件需指定OpenPGP或S/MIME證書</string>
<string name="plain">明文</string>
<string name="smime">S/MIME</string>
<string name="openpgp">OpenPGP</string>
<string name="below">低於</string>
<string name="percent">%</string>
<string name="above">高於</string>
@ -1079,6 +1100,7 @@
<string name="restarting_httpserver">正在重啟HttpServer</string>
<string name="download_first">請先下載Web客戶端並解壓到\n%s</string>
<string name="download_music_first">請先下載音樂文件到:\n%s</string>
<string name="download_certificate_first">請先下載證書文件到:\n%s</string>
<string name="root_directory">根目錄:\n%s</string>
<string name="select_web_client_directory">選擇Web客戶端目錄</string>
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId都不能為空</string>

@ -294,6 +294,9 @@
<string name="invalid_name">通道名称不能为空</string>
<string name="invalid_token">token不合法</string>
<string name="invalid_email">发件邮箱/登录密码/收件地址不可为空</string>
<string name="invalid_recipient_email">收件地址(%s)不合法</string>
<string name="invalid_x509_certificate">收件地址(%s)的X.509公钥证书无效</string>
<string name="invalid_pkcs12_certificate">收件地址(%s)的PKCS12私钥证书无效</string>
<string name="invalid_email_server">服务器信息的主机/端口不可为空</string>
<string name="invalid_bark_icon">消息图标不是有效URL</string>
<string name="invalid_bark_url">消息链接不是有效URL</string>
@ -324,8 +327,21 @@
<string name="email_account">发件邮箱</string>
<string name="email_password">登录密码</string>
<string name="email_nickname">发件人昵称</string>
<string name="email_to">收件地址</string>
<string name="email_to_tips">Tip多个收件人以半角逗号,分隔</string>
<string name="email_to">收件人邮箱</string>
<string name="email_to_tips">Tip: 点击按钮添加收件人邮箱</string>
<string name="email_to_smime">收件人邮箱 &amp; S/MIME加密公钥</string>
<string name="email_to_smime_tips">Tip: 点击按钮添加收件人邮箱、S/MIME加密公钥可选</string>
<string name="email_to_openpgp">收件人邮箱 &amp; OpenPGP加密公钥</string>
<string name="email_to_openpgp_tips">Tip: 点击按钮添加收件人邮箱、OpenPGP加密公钥可选</string>
<string name="sender_smime_keystore">发件人S/MIME签名私钥可选</string>
<string name="sender_openpgp_keystore">发件人OpenPGP签名私钥可选</string>
<string name="invalid_sender_keystore">发件人签名私钥无效</string>
<string name="smime_public_keys_tips">对邮件进行S/MIME签名加密则添加公钥否则留空</string>
<string name="recipient_email">收件人邮箱</string>
<string name="keystore_path">证书路径</string>
<string name="keystore_path_tips">可选,下载证书文件到 Download 目录</string>
<string name="keystore_password">证书密码</string>
<string name="keystore_password_tips">`私钥证书`对应的导入密钥</string>
<string name="email_title">邮件主题</string>
<string name="feishu_webhook">Webhook 地址</string>
<string name="feishu_secret">加签 Secret (没有可不填)</string>
@ -494,6 +510,11 @@
<string name="account">✱帐户管理</string>
<string name="servers">✱服务器信息</string>
<string name="email_settings">✱邮件设置</string>
<string name="encryption_protocol">端对端加密</string>
<string name="encryption_protocol_tips">Tip若要加密或签名邮件需指定OpenPGP或S/MIME证书</string>
<string name="plain">明文</string>
<string name="smime">S/MIME</string>
<string name="openpgp">OpenPGP</string>
<string name="below">低于</string>
<string name="percent">%</string>
<string name="above">高于</string>
@ -1079,6 +1100,7 @@
<string name="restarting_httpserver">正在重启HttpServer</string>
<string name="download_first">请先下载Web客户端并解压到\n%s</string>
<string name="download_music_first">请先下载音乐文件到:\n%s</string>
<string name="download_certificate_first">请先下载证书文件到:\n%s</string>
<string name="root_directory">根目录:\n%s</string>
<string name="select_web_client_directory">选择Web客户端目录</string>
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId都不能为空</string>

Loading…
Cancel
Save