新增:发送通道`电子邮箱`支持`S/MIME`或`OpenPGP`加密 #417
parent
8cefd5fded
commit
75b356246c
@ -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
|
||||
}
|
||||
|
||||
}
|
@ -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>
|
Loading…
Reference in New Issue