新增:发送通道`电子邮箱`支持`S/MIME`或`OpenPGP`加密 #417
This commit is contained in:
parent
8cefd5fded
commit
75b356246c
|
@ -25,6 +25,17 @@ if (isNeedPackage.toBoolean() && isUseBooster.toBoolean()) {
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
// 禁用过时 API 警告
|
||||||
|
configure(allprojects) {
|
||||||
|
gradle.projectsEvaluated {
|
||||||
|
tasks.withType(JavaCompile).tap {
|
||||||
|
configureEach {
|
||||||
|
options.compilerArgs << "-Xlint:-removal"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildToolsVersion build_versions.build_tools
|
buildToolsVersion build_versions.build_tools
|
||||||
compileSdkVersion build_versions.target_sdk
|
compileSdkVersion build_versions.target_sdk
|
||||||
|
|
||||||
|
@ -162,10 +173,15 @@ android {
|
||||||
exclude 'lib/x86/libgojni.so'
|
exclude 'lib/x86/libgojni.so'
|
||||||
exclude 'lib/x86_64/libgojni.so'
|
exclude 'lib/x86_64/libgojni.so'
|
||||||
}
|
}
|
||||||
|
jniLibs {
|
||||||
|
excludes += ["kotlin/**"]
|
||||||
|
}
|
||||||
resources {
|
resources {
|
||||||
|
merge 'META-INF/mailcap'
|
||||||
pickFirst 'META-INF/LICENSE.md'
|
pickFirst 'META-INF/LICENSE.md'
|
||||||
pickFirst 'META-INF/NOTICE.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/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,6 +224,7 @@ android {
|
||||||
}
|
}
|
||||||
namespace 'com.idormy.sms.forwarder'
|
namespace 'com.idormy.sms.forwarder'
|
||||||
|
|
||||||
|
if (isNeedClean.toBoolean()) {
|
||||||
//编译前清理项目缓存
|
//编译前清理项目缓存
|
||||||
preBuild.dependsOn clean
|
preBuild.dependsOn clean
|
||||||
//编译后清理垃圾文件
|
//编译后清理垃圾文件
|
||||||
|
@ -225,6 +242,7 @@ android {
|
||||||
println "Build failed, cleanTxt not executed."
|
println "Build failed, cleanTxt not executed."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
@ -305,8 +323,11 @@ dependencies {
|
||||||
//implementation 'com.github.tiagohm.MarkdownView:emoji:0.19.0'
|
//implementation 'com.github.tiagohm.MarkdownView:emoji:0.19.0'
|
||||||
|
|
||||||
def retrofit2_version = '2.9.0'
|
def retrofit2_version = '2.9.0'
|
||||||
|
//noinspection GradleDependency
|
||||||
implementation "com.squareup.retrofit2:retrofit:$retrofit2_version"
|
implementation "com.squareup.retrofit2:retrofit:$retrofit2_version"
|
||||||
|
//noinspection GradleDependency
|
||||||
implementation "com.squareup.retrofit2:converter-gson:$retrofit2_version"
|
implementation "com.squareup.retrofit2:converter-gson:$retrofit2_version"
|
||||||
|
//noinspection GradleDependency
|
||||||
implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit2_version"
|
implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofit2_version"
|
||||||
|
|
||||||
def paging_version = "3.1.1"
|
def paging_version = "3.1.1"
|
||||||
|
@ -325,6 +346,18 @@ dependencies {
|
||||||
implementation "com.sun.mail:android-mail:$mail_version"
|
implementation "com.sun.mail:android-mail:$mail_version"
|
||||||
implementation "com.sun.mail:android-activation:$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 集成双进程前台服务,JobScheduler,onePix(一像素),WorkManager,无声音乐
|
//Android Keep Alive(安卓保活),Cactus 集成双进程前台服务,JobScheduler,onePix(一像素),WorkManager,无声音乐
|
||||||
//https://github.com/gyf-dev/Cactus
|
//https://github.com/gyf-dev/Cactus
|
||||||
implementation 'com.gyf.cactus:cactus:1.1.3-beta13'
|
implementation 'com.gyf.cactus:cactus:1.1.3-beta13'
|
||||||
|
@ -333,9 +366,6 @@ dependencies {
|
||||||
implementation 'cn.ppps.andserver:api:2.1.12'
|
implementation 'cn.ppps.andserver:api:2.1.12'
|
||||||
kapt 'cn.ppps.andserver:processor: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
|
//Location 是一个通过 Android 自带的 LocationManager 来实现的定位功能:https://github.com/jenly1314/Location
|
||||||
implementation 'com.github.pppscn:location:1.0.0'
|
implementation 'com.github.pppscn:location:1.0.0'
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package com.idormy.sms.forwarder.entity.setting
|
package com.idormy.sms.forwarder.entity.setting
|
||||||
|
|
||||||
|
import com.idormy.sms.forwarder.R
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
|
|
||||||
data class EmailSetting(
|
data class EmailSetting(
|
||||||
|
@ -11,6 +12,19 @@ data class EmailSetting(
|
||||||
var port: String? = "",
|
var port: String? = "",
|
||||||
var ssl: Boolean? = false,
|
var ssl: Boolean? = false,
|
||||||
var startTls: Boolean? = false,
|
var startTls: Boolean? = false,
|
||||||
var toEmail: String? = "",
|
|
||||||
var title: 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
|
package com.idormy.sms.forwarder.fragment.senders
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.os.Environment
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
import android.widget.EditText
|
import android.widget.EditText
|
||||||
|
import android.widget.ImageView
|
||||||
|
import android.widget.LinearLayout
|
||||||
import androidx.fragment.app.viewModels
|
import androidx.fragment.app.viewModels
|
||||||
import com.google.gson.Gson
|
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.R
|
||||||
import com.idormy.sms.forwarder.core.BaseFragment
|
import com.idormy.sms.forwarder.core.BaseFragment
|
||||||
import com.idormy.sms.forwarder.core.Core
|
import com.idormy.sms.forwarder.core.Core
|
||||||
|
@ -41,6 +49,13 @@ import io.reactivex.SingleObserver
|
||||||
import io.reactivex.android.schedulers.AndroidSchedulers
|
import io.reactivex.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.disposables.Disposable
|
import io.reactivex.disposables.Disposable
|
||||||
import io.reactivex.schedulers.Schedulers
|
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
|
import java.util.Date
|
||||||
|
|
||||||
@Page(name = "Email")
|
@Page(name = "Email")
|
||||||
|
@ -52,6 +67,11 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
|
||||||
private val viewModel by viewModels<SenderViewModel> { BaseViewModelFactory(context) }
|
private val viewModel by viewModels<SenderViewModel> { BaseViewModelFactory(context) }
|
||||||
private var mCountDownHelper: CountDownButtonHelper? = null
|
private var mCountDownHelper: CountDownButtonHelper? = null
|
||||||
private var mailType: String = getString(R.string.other_mail_type) //邮箱类型
|
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
|
@JvmField
|
||||||
@AutoWired(name = KEY_SENDER_ID)
|
@AutoWired(name = KEY_SENDER_ID)
|
||||||
|
@ -98,7 +118,6 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
|
||||||
})
|
})
|
||||||
|
|
||||||
val mailTypeArray = getStringArray(R.array.MailType)
|
val mailTypeArray = getStringArray(R.array.MailType)
|
||||||
Log.d(TAG, mailTypeArray.toString())
|
|
||||||
binding!!.spMailType.setOnItemSelectedListener { _: MaterialSpinner?, position: Int, _: Long, item: Any ->
|
binding!!.spMailType.setOnItemSelectedListener { _: MaterialSpinner?, position: Int, _: Long, item: Any ->
|
||||||
mailType = item.toString()
|
mailType = item.toString()
|
||||||
//XToastUtils.warning(mailType)
|
//XToastUtils.warning(mailType)
|
||||||
|
@ -112,6 +131,39 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
|
||||||
binding!!.spMailType.selectedIndex = mailTypeArray.size - 1
|
binding!!.spMailType.selectedIndex = mailTypeArray.size - 1
|
||||||
binding!!.layoutServiceSetting.visibility = View.VISIBLE
|
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) {
|
if (senderId <= 0) {
|
||||||
titleBar?.setSubTitle(getString(R.string.add_sender))
|
titleBar?.setSubTitle(getString(R.string.add_sender))
|
||||||
|
@ -143,6 +195,10 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
|
||||||
if (settingVo != null) {
|
if (settingVo != null) {
|
||||||
if (!TextUtils.isEmpty(settingVo.mailType)) {
|
if (!TextUtils.isEmpty(settingVo.mailType)) {
|
||||||
mailType = settingVo.mailType.toString()
|
mailType = settingVo.mailType.toString()
|
||||||
|
//TODO: 替换mailType为当前语言,避免切换语言后失效,历史包袱怎么替换比较优雅?
|
||||||
|
if (mailType == "other" || mailType == "其他邮箱" || mailType == "其他郵箱") {
|
||||||
|
mailType = getString(R.string.other_mail_type)
|
||||||
|
}
|
||||||
binding!!.spMailType.setSelectedItem(mailType)
|
binding!!.spMailType.setSelectedItem(mailType)
|
||||||
if (mailType != getString(R.string.other_mail_type)) {
|
if (mailType != getString(R.string.other_mail_type)) {
|
||||||
binding!!.layoutServiceSetting.visibility = View.GONE
|
binding!!.layoutServiceSetting.visibility = View.GONE
|
||||||
|
@ -155,8 +211,24 @@ class EmailFragment : BaseFragment<FragmentSendersEmailBinding?>(), View.OnClick
|
||||||
binding!!.etPort.setText(settingVo.port)
|
binding!!.etPort.setText(settingVo.port)
|
||||||
binding!!.sbSsl.isChecked = settingVo.ssl == true
|
binding!!.sbSsl.isChecked = settingVo.ssl == true
|
||||||
binding!!.sbStartTls.isChecked = settingVo.startTls == true
|
binding!!.sbStartTls.isChecked = settingVo.startTls == true
|
||||||
binding!!.etToEmail.setText(settingVo.toEmail)
|
|
||||||
binding!!.etTitleTemplate.setText(settingVo.title)
|
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!!.btnTest.setOnClickListener(this)
|
||||||
binding!!.btnDel.setOnClickListener(this)
|
binding!!.btnDel.setOnClickListener(this)
|
||||||
binding!!.btnSave.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() }
|
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 {
|
private fun checkSetting(): EmailSetting {
|
||||||
val fromEmail = binding!!.etFromEmail.text.toString().trim()
|
val fromEmail = binding!!.etFromEmail.text.toString().trim()
|
||||||
val pwd = binding!!.etPwd.text.toString().trim()
|
val pwd = binding!!.etPwd.text.toString().trim()
|
||||||
val nickname = binding!!.etNickname.text.toString().trim()
|
val recipients = getRecipientsFromRecipientItemMap()
|
||||||
val host = binding!!.etHost.text.toString().trim()
|
if (TextUtils.isEmpty(fromEmail) || TextUtils.isEmpty(pwd) || recipients.isEmpty()) {
|
||||||
val port = binding!!.etPort.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))
|
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))) {
|
if (mailType == getString(R.string.other_mail_type) && (TextUtils.isEmpty(host) || TextUtils.isEmpty(port))) {
|
||||||
throw Exception(getString(R.string.invalid_email_server))
|
throw Exception(getString(R.string.invalid_email_server))
|
||||||
}
|
}
|
||||||
|
|
||||||
return EmailSetting(mailType, fromEmail, pwd, nickname, host, port, ssl, startTls, toEmail, title)
|
val nickname = binding!!.etNickname.text.toString().trim()
|
||||||
|
val ssl = binding!!.sbSsl.isChecked
|
||||||
|
val startTls = binding!!.sbStartTls.isChecked
|
||||||
|
val title = binding!!.etTitleTemplate.text.toString().trim()
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
//选择证书文件
|
||||||
|
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() {
|
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.Log
|
||||||
import com.idormy.sms.forwarder.utils.SendUtils
|
import com.idormy.sms.forwarder.utils.SendUtils
|
||||||
import com.idormy.sms.forwarder.utils.SettingUtils
|
import com.idormy.sms.forwarder.utils.SettingUtils
|
||||||
import com.idormy.sms.forwarder.utils.mail.Mail
|
import com.idormy.sms.forwarder.utils.mail.EmailSender
|
||||||
import com.idormy.sms.forwarder.utils.mail.MailSender
|
|
||||||
import com.xuexiang.xutil.resource.ResUtils.getString
|
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 {
|
class EmailUtils {
|
||||||
companion object {
|
companion object {
|
||||||
|
|
||||||
//private val TAG: String = EmailUtils::class.java.simpleName
|
private val TAG: String = EmailUtils::class.java.simpleName
|
||||||
|
|
||||||
fun sendMsg(
|
fun sendMsg(
|
||||||
setting: EmailSetting,
|
setting: EmailSetting,
|
||||||
|
@ -127,36 +138,191 @@ class EmailUtils {
|
||||||
else -> {}
|
else -> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
//收件地址
|
runBlocking {
|
||||||
val toAddressList = setting.toEmail.toString().replace("[,,;;]".toRegex(), ",").trim(',').split(',')
|
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
|
||||||
|
|
||||||
//创建邮箱
|
//发件人S/MIME私钥(用于签名)
|
||||||
val mail = Mail().apply {
|
var signingPrivateKey: PrivateKey? = null
|
||||||
mailServerHost = setting.host.toString()
|
var signingCertificate: X509Certificate? = null
|
||||||
mailServerPort = setting.port.toString()
|
//发件人OpenPGP私钥(用于签名)
|
||||||
fromAddress = setting.fromEmail.toString()
|
var senderPGPSecretKeyRing: PGPSecretKeyRing? = null
|
||||||
fromNickname = msgInfo.getTitleForSend(setting.nickname.toString())
|
var senderPGPSecretKeyPassword = ""
|
||||||
password = setting.pwd.toString()
|
|
||||||
toAddress = toAddressList
|
if (!setting.keystore.isNullOrEmpty() && !setting.password.isNullOrEmpty()) {
|
||||||
subject = title
|
val keystoreStream = FileInputStream(setting.keystore)
|
||||||
content = message.replace("\n", "<br>")
|
try {
|
||||||
openSSL = setting.ssl == true
|
when (setting.encryptionProtocol) {
|
||||||
startTls = setting.startTls == true
|
"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
|
||||||
}
|
}
|
||||||
|
|
||||||
MailSender.getInstance().sendMail(mail, object : MailSender.OnMailSendListener {
|
"OpenPGP" -> {
|
||||||
override fun onError(e: Throwable) {
|
senderPGPSecretKeyRing = PGPainless.readKeyRing().secretKeyRing(keystoreStream)
|
||||||
Log.e("MailSender", e.message.toString())
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//从证书文件提取公钥
|
||||||
|
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
|
val status = 0
|
||||||
SendUtils.updateLogs(logId, status, e.message.toString())
|
SendUtils.updateLogs(logId, status, e.message.toString())
|
||||||
SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId)
|
SendUtils.senderLogic(status, msgInfo, rule, senderIndex, msgId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onSuccess() {
|
|
||||||
SendUtils.updateLogs(logId, 2, getString(R.string.request_succeeded))
|
|
||||||
SendUtils.senderLogic(2, msgInfo, rule, senderIndex, msgId)
|
|
||||||
}
|
}
|
||||||
})
|
job.join() // 等待协程完成
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -27,7 +27,8 @@ import java.net.URLEncoder
|
||||||
import java.nio.charset.Charset
|
import java.nio.charset.Charset
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
import javax.crypto.Mac
|
import javax.crypto.Mac
|
||||||
import javax.crypto.spec.SecretKeySpec
|
import javax.crypto.spec.SecretKeySpec
|
||||||
|
|
||||||
|
@ -103,9 +104,9 @@ class SocketUtils {
|
||||||
} else if (setting.method == "MQTT") {
|
} else if (setting.method == "MQTT") {
|
||||||
// MQTT 连接参数
|
// MQTT 连接参数
|
||||||
val uriType = if (TextUtils.isEmpty(setting.uriType)) "tcp" else setting.uriType
|
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)) {
|
if (!TextUtils.isEmpty(setting.path)) {
|
||||||
brokerUrl.plus(setting.path)
|
brokerUrl += setting.path
|
||||||
}
|
}
|
||||||
Log.d(TAG, "MQTT brokerUrl: $brokerUrl")
|
Log.d(TAG, "MQTT brokerUrl: $brokerUrl")
|
||||||
val clientId = if (TextUtils.isEmpty(setting.clientId)) UUID.randomUUID().toString() else setting.clientId
|
val clientId = if (TextUtils.isEmpty(setting.clientId)) UUID.randomUUID().toString() else setting.clientId
|
||||||
|
|
|
@ -138,7 +138,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="5dp"
|
android:layout_marginStart="5dp"
|
||||||
android:text="@string/custom_template_tips"
|
android:text="@string/custom_template_tips"
|
||||||
android:textSize="@dimen/text_size_small" />
|
android:textSize="@dimen/text_size_mini" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
@ -302,30 +302,184 @@
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal">
|
android:orientation="horizontal">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/email_to"
|
android:text="@string/encryption_protocol"
|
||||||
android:textStyle="bold" />
|
android:textStyle="bold" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="5dp"
|
android:layout_marginStart="5dp"
|
||||||
android:text="@string/email_to_tips"
|
android:text="@string/encryption_protocol_tips"
|
||||||
android:textSize="@dimen/text_size_small" />
|
android:textSize="@dimen/text_size_mini" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
<com.xuexiang.xui.widget.edittext.materialedittext.MaterialEditText
|
<RadioGroup
|
||||||
android:id="@+id/et_to_email"
|
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_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
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: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:singleLine="true"
|
||||||
|
android:textSize="@dimen/text_size_small"
|
||||||
app:met_clearButton="true" />
|
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>
|
</LinearLayout>
|
||||||
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
|
@ -352,7 +506,7 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="5dp"
|
android:layout_marginStart="5dp"
|
||||||
android:text="@string/custom_template_tips"
|
android:text="@string/custom_template_tips"
|
||||||
android:textSize="@dimen/text_size_small" />
|
android:textSize="@dimen/text_size_mini" />
|
||||||
|
|
||||||
</LinearLayout>
|
</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_name">Channel name cannot be empty</string>
|
||||||
<string name="invalid_token">invalid token</string>
|
<string name="invalid_token">invalid token</string>
|
||||||
<string name="invalid_email">Email parameter is incomplete</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_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_icon">The bark-icon is not a valid URL</string>
|
||||||
<string name="invalid_bark_url">The bark-url 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_account">Account</string>
|
||||||
<string name="email_password">Password/Auth Code</string>
|
<string name="email_password">Password/Auth Code</string>
|
||||||
<string name="email_nickname">Nickname</string>
|
<string name="email_nickname">Nickname</string>
|
||||||
<string name="email_to">Send To</string>
|
<string name="email_to">Recipients</string>
|
||||||
<string name="email_to_tips">Tip: Separate multiple recipients with a ","</string>
|
<string name="email_to_tips">Tip: Click to add recipients.</string>
|
||||||
|
<string name="email_to_smime">Recipients & 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 & 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="email_title">Email Title</string>
|
||||||
<string name="feishu_webhook">Webhook</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_receive_id_type">Receive Id Type"</string>
|
||||||
<string name="feishu_msg_type">Msg Type</string>
|
<string name="feishu_msg_type">Msg Type</string>
|
||||||
<string name="feishu_msg_type_text">Text</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_send_key">SendKey</string>
|
||||||
<string name="server_chan_channel">Message Channel</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_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">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_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="TelegramApiToken">ApiToken or Custom Proxy Address (startwith http)</string>
|
||||||
<string name="TelegramChatId">ChatId</string>
|
<string name="TelegramChatId">ChatId</string>
|
||||||
<string name="Method" formatted="false">Method</string>
|
<string name="Method" formatted="false">Method</string>
|
||||||
|
@ -493,6 +509,11 @@
|
||||||
<string name="account">✱Account</string>
|
<string name="account">✱Account</string>
|
||||||
<string name="servers">✱Servers</string>
|
<string name="servers">✱Servers</string>
|
||||||
<string name="email_settings">✱Email</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="below"><![CDATA[≤]]></string>
|
||||||
<string name="percent">%</string>
|
<string name="percent">%</string>
|
||||||
<string name="above"><![CDATA[≥]]></string>
|
<string name="above"><![CDATA[≥]]></string>
|
||||||
|
@ -515,7 +536,7 @@
|
||||||
<string name="out_message_topic">Out Message Topic</string>
|
<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="out_message_topic_hint">Send a message on the corresponding topic</string>
|
||||||
<string name="uri_type">Uri Type</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">Path</string>
|
||||||
<string name="path_hint">Used to set the uri when communicating using ws</string>
|
<string name="path_hint">Used to set the uri when communicating using ws</string>
|
||||||
<string name="client_id">Client Id</string>
|
<string name="client_id">Client Id</string>
|
||||||
|
@ -571,8 +592,8 @@
|
||||||
<string name="outgoing_call_ended">Out Ended</string>
|
<string name="outgoing_call_ended">Out Ended</string>
|
||||||
<string name="missed_call">Missed</string>
|
<string name="missed_call">Missed</string>
|
||||||
<string name="unknown_call">Unknown</string>
|
<string name="unknown_call">Unknown</string>
|
||||||
<string name="optional_action">Optional: </string>
|
<string name="optional_action">Opt.: </string>
|
||||||
<string name="optional_type">Optional: </string>
|
<string name="optional_type">Opt.: </string>
|
||||||
<string name="active_request">Active request</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="active_request_tips">Obtain instructions through passive reception or active polling to operate the machine</string>
|
||||||
<string name="httpserver">Local HttpServer</string>
|
<string name="httpserver">Local HttpServer</string>
|
||||||
|
@ -708,7 +729,7 @@
|
||||||
<string name="one_pixel">One Pixel</string>
|
<string name="one_pixel">One Pixel</string>
|
||||||
<string name="one_pixel_activity">One Pixel Activity</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="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="TelegramChatIdTips">Follow the steps in the wiki to obtain it</string>
|
||||||
<string name="network_model">Network model</string>
|
<string name="network_model">Network model</string>
|
||||||
<string name="offline_mode">Offline mode</string>
|
<string name="offline_mode">Offline mode</string>
|
||||||
|
@ -762,15 +783,15 @@
|
||||||
<string name="bark_server_regex">^https?://[^/]+/[^/]+/$</string>
|
<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_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">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">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">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">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">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">Notification Level</string>
|
||||||
<string name="bark_level_active">Immediately display notifications</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>
|
<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="sim_slot">Sim Slot</string>
|
||||||
<string name="display_name">Display Name</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">Phone Numbers</string>
|
||||||
<string name="phone_numbers_hint">Required, separate multiple phone numbers with semicolons</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>
|
<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="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">Enable debug mode</string>
|
||||||
<string name="debug_mode_tips">Save Log.* to file for troubleshooting; export to download directory.</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="enable_cactus">Enable Cactus Keep Alive</string>
|
||||||
<string name="enabe_cactus_tips">Dual foreground service/JobScheduler/WorkManager/1px/silent music</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>
|
<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="restarting_httpserver">Restarting HttpServer</string>
|
||||||
<string name="download_first">Download and unzip to:\n%s</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_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="root_directory">Root Directory:\n%s</string>
|
||||||
<string name="select_web_client_directory">Select WebClient Directory</string>
|
<string name="select_web_client_directory">Select WebClient Directory</string>
|
||||||
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId cannot be empty</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="stop_alarm">Stop Alarm</string>
|
||||||
<string name="alarm_play_settings">Playback Settings</string>
|
<string name="alarm_play_settings">Playback Settings</string>
|
||||||
<string name="alarm_music">Specify Music</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_volume">Alarm Volume</string>
|
||||||
<string name="alarm_play_times">Play Times(0=Infinite)</string>
|
<string name="alarm_play_times">Play Times(0=Infinite)</string>
|
||||||
|
|
||||||
|
|
|
@ -294,6 +294,9 @@
|
||||||
<string name="invalid_name">通道名称不能为空</string>
|
<string name="invalid_name">通道名称不能为空</string>
|
||||||
<string name="invalid_token">token不合法</string>
|
<string name="invalid_token">token不合法</string>
|
||||||
<string name="invalid_email">发件邮箱/登录密码/收件地址不可为空</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_email_server">服务器信息的主机/端口不可为空</string>
|
||||||
<string name="invalid_bark_icon">消息图标不是有效URL</string>
|
<string name="invalid_bark_icon">消息图标不是有效URL</string>
|
||||||
<string name="invalid_bark_url">消息链接不是有效URL</string>
|
<string name="invalid_bark_url">消息链接不是有效URL</string>
|
||||||
|
@ -324,8 +327,21 @@
|
||||||
<string name="email_account">发件邮箱</string>
|
<string name="email_account">发件邮箱</string>
|
||||||
<string name="email_password">登录密码</string>
|
<string name="email_password">登录密码</string>
|
||||||
<string name="email_nickname">发件人昵称</string>
|
<string name="email_nickname">发件人昵称</string>
|
||||||
<string name="email_to">收件地址</string>
|
<string name="email_to">收件人邮箱</string>
|
||||||
<string name="email_to_tips">Tip:多个收件人以半角逗号,分隔</string>
|
<string name="email_to_tips">Tip: 点击按钮添加收件人邮箱</string>
|
||||||
|
<string name="email_to_smime">收件人邮箱 & S/MIME加密公钥</string>
|
||||||
|
<string name="email_to_smime_tips">Tip: 点击按钮添加收件人邮箱、S/MIME加密公钥(可选)</string>
|
||||||
|
<string name="email_to_openpgp">收件人邮箱 & 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="email_title">邮件主题</string>
|
||||||
<string name="feishu_webhook">Webhook 地址</string>
|
<string name="feishu_webhook">Webhook 地址</string>
|
||||||
<string name="feishu_secret">加签 Secret (没有可不填)</string>
|
<string name="feishu_secret">加签 Secret (没有可不填)</string>
|
||||||
|
@ -494,6 +510,11 @@
|
||||||
<string name="account">✱帐户管理</string>
|
<string name="account">✱帐户管理</string>
|
||||||
<string name="servers">✱服务器信息</string>
|
<string name="servers">✱服务器信息</string>
|
||||||
<string name="email_settings">✱邮件设置</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="below">低于</string>
|
||||||
<string name="percent">%</string>
|
<string name="percent">%</string>
|
||||||
<string name="above">高于</string>
|
<string name="above">高于</string>
|
||||||
|
@ -1079,6 +1100,7 @@
|
||||||
<string name="restarting_httpserver">正在重启HttpServer</string>
|
<string name="restarting_httpserver">正在重启HttpServer</string>
|
||||||
<string name="download_first">请先下载Web客户端并解压到:\n%s</string>
|
<string name="download_first">请先下载Web客户端并解压到:\n%s</string>
|
||||||
<string name="download_music_first">请先下载音乐文件到:\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="root_directory">根目录:\n%s</string>
|
||||||
<string name="select_web_client_directory">选择Web客户端目录</string>
|
<string name="select_web_client_directory">选择Web客户端目录</string>
|
||||||
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId都不能为空</string>
|
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId都不能为空</string>
|
||||||
|
|
|
@ -294,6 +294,9 @@
|
||||||
<string name="invalid_name">通道名稱不能為空</string>
|
<string name="invalid_name">通道名稱不能為空</string>
|
||||||
<string name="invalid_token">token不合法</string>
|
<string name="invalid_token">token不合法</string>
|
||||||
<string name="invalid_email">發件郵箱/登錄密碼/收件地址不可為空</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_email_server">服務器信息的主機/端口不可為空</string>
|
||||||
<string name="invalid_bark_icon">消息圖標不是有效URL</string>
|
<string name="invalid_bark_icon">消息圖標不是有效URL</string>
|
||||||
<string name="invalid_bark_url">消息鏈接不是有效URL</string>
|
<string name="invalid_bark_url">消息鏈接不是有效URL</string>
|
||||||
|
@ -324,8 +327,21 @@
|
||||||
<string name="email_account">發件郵箱</string>
|
<string name="email_account">發件郵箱</string>
|
||||||
<string name="email_password">登錄密碼</string>
|
<string name="email_password">登錄密碼</string>
|
||||||
<string name="email_nickname">發件人昵稱</string>
|
<string name="email_nickname">發件人昵稱</string>
|
||||||
<string name="email_to">收件地址</string>
|
<string name="email_to">收件人郵箱</string>
|
||||||
<string name="email_to_tips">Tip:多個收件人以半角逗號,分隔</string>
|
<string name="email_to_tips">提示:點擊按鈕添加收件人郵箱</string>
|
||||||
|
<string name="email_to_smime">收件人郵箱 & S/MIME加密公鑰</string>
|
||||||
|
<string name="email_to_smime_tips">提示:點擊按鈕添加收件人郵箱、S/MIME加密公鑰(可選)</string>
|
||||||
|
<string name="email_to_openpgp">收件人郵箱 & 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="email_title">郵件主題</string>
|
||||||
<string name="feishu_webhook">Webhook 地址</string>
|
<string name="feishu_webhook">Webhook 地址</string>
|
||||||
<string name="feishu_secret">加簽 Secret (沒有可不填)</string>
|
<string name="feishu_secret">加簽 Secret (沒有可不填)</string>
|
||||||
|
@ -494,6 +510,11 @@
|
||||||
<string name="account">✱帳戶管理</string>
|
<string name="account">✱帳戶管理</string>
|
||||||
<string name="servers">✱伺服器信息</string>
|
<string name="servers">✱伺服器信息</string>
|
||||||
<string name="email_settings">✱郵件設置</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="below">低於</string>
|
||||||
<string name="percent">%</string>
|
<string name="percent">%</string>
|
||||||
<string name="above">高於</string>
|
<string name="above">高於</string>
|
||||||
|
@ -1079,6 +1100,7 @@
|
||||||
<string name="restarting_httpserver">正在重啟HttpServer</string>
|
<string name="restarting_httpserver">正在重啟HttpServer</string>
|
||||||
<string name="download_first">請先下載Web客戶端並解壓到:\n%s</string>
|
<string name="download_first">請先下載Web客戶端並解壓到:\n%s</string>
|
||||||
<string name="download_music_first">請先下載音樂文件到:\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="root_directory">根目錄:\n%s</string>
|
||||||
<string name="select_web_client_directory">選擇Web客戶端目錄</string>
|
<string name="select_web_client_directory">選擇Web客戶端目錄</string>
|
||||||
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId都不能為空</string>
|
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId都不能為空</string>
|
||||||
|
|
|
@ -294,6 +294,9 @@
|
||||||
<string name="invalid_name">通道名称不能为空</string>
|
<string name="invalid_name">通道名称不能为空</string>
|
||||||
<string name="invalid_token">token不合法</string>
|
<string name="invalid_token">token不合法</string>
|
||||||
<string name="invalid_email">发件邮箱/登录密码/收件地址不可为空</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_email_server">服务器信息的主机/端口不可为空</string>
|
||||||
<string name="invalid_bark_icon">消息图标不是有效URL</string>
|
<string name="invalid_bark_icon">消息图标不是有效URL</string>
|
||||||
<string name="invalid_bark_url">消息链接不是有效URL</string>
|
<string name="invalid_bark_url">消息链接不是有效URL</string>
|
||||||
|
@ -324,8 +327,21 @@
|
||||||
<string name="email_account">发件邮箱</string>
|
<string name="email_account">发件邮箱</string>
|
||||||
<string name="email_password">登录密码</string>
|
<string name="email_password">登录密码</string>
|
||||||
<string name="email_nickname">发件人昵称</string>
|
<string name="email_nickname">发件人昵称</string>
|
||||||
<string name="email_to">收件地址</string>
|
<string name="email_to">收件人邮箱</string>
|
||||||
<string name="email_to_tips">Tip:多个收件人以半角逗号,分隔</string>
|
<string name="email_to_tips">Tip: 点击按钮添加收件人邮箱</string>
|
||||||
|
<string name="email_to_smime">收件人邮箱 & S/MIME加密公钥</string>
|
||||||
|
<string name="email_to_smime_tips">Tip: 点击按钮添加收件人邮箱、S/MIME加密公钥(可选)</string>
|
||||||
|
<string name="email_to_openpgp">收件人邮箱 & 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="email_title">邮件主题</string>
|
||||||
<string name="feishu_webhook">Webhook 地址</string>
|
<string name="feishu_webhook">Webhook 地址</string>
|
||||||
<string name="feishu_secret">加签 Secret (没有可不填)</string>
|
<string name="feishu_secret">加签 Secret (没有可不填)</string>
|
||||||
|
@ -494,6 +510,11 @@
|
||||||
<string name="account">✱帐户管理</string>
|
<string name="account">✱帐户管理</string>
|
||||||
<string name="servers">✱服务器信息</string>
|
<string name="servers">✱服务器信息</string>
|
||||||
<string name="email_settings">✱邮件设置</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="below">低于</string>
|
||||||
<string name="percent">%</string>
|
<string name="percent">%</string>
|
||||||
<string name="above">高于</string>
|
<string name="above">高于</string>
|
||||||
|
@ -1079,6 +1100,7 @@
|
||||||
<string name="restarting_httpserver">正在重启HttpServer</string>
|
<string name="restarting_httpserver">正在重启HttpServer</string>
|
||||||
<string name="download_first">请先下载Web客户端并解压到:\n%s</string>
|
<string name="download_first">请先下载Web客户端并解压到:\n%s</string>
|
||||||
<string name="download_music_first">请先下载音乐文件到:\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="root_directory">根目录:\n%s</string>
|
||||||
<string name="select_web_client_directory">选择Web客户端目录</string>
|
<string name="select_web_client_directory">选择Web客户端目录</string>
|
||||||
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId都不能为空</string>
|
<string name="invalid_feishu_app_parameter">AppId/AppSecret/UserId都不能为空</string>
|
||||||
|
|
Loading…
Reference in New Issue