react-native-email-imap-smtp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/EmailImapSmtp.podspec +29 -0
- package/LICENSE +20 -0
- package/README.md +121 -0
- package/android/CMakeLists.txt +24 -0
- package/android/build.gradle +117 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/cpp/cpp-adapter.cpp +11 -0
- package/android/src/main/java/com/margelo/nitro/emailimapsmtp/EmailImapSmtp.kt +1533 -0
- package/android/src/main/java/com/margelo/nitro/emailimapsmtp/EmailImapSmtpPackage.kt +22 -0
- package/ios/EmailImapSmtp.swift +5 -0
- package/lib/module/EmailImapSmtp.nitro.js +4 -0
- package/lib/module/EmailImapSmtp.nitro.js.map +1 -0
- package/lib/module/NativeEmailImapSmtp.js +14 -0
- package/lib/module/NativeEmailImapSmtp.js.map +1 -0
- package/lib/module/index.js +64 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/native.js +227 -0
- package/lib/module/native.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/module/web.js +107 -0
- package/lib/module/web.js.map +1 -0
- package/lib/module/windows.js +156 -0
- package/lib/module/windows.js.map +1 -0
- package/lib/typescript/package.json +1 -0
- package/lib/typescript/src/EmailImapSmtp.nitro.d.ts +268 -0
- package/lib/typescript/src/EmailImapSmtp.nitro.d.ts.map +1 -0
- package/lib/typescript/src/NativeEmailImapSmtp.d.ts +36 -0
- package/lib/typescript/src/NativeEmailImapSmtp.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +33 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/native.d.ts +32 -0
- package/lib/typescript/src/native.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +261 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/lib/typescript/src/web.d.ts +32 -0
- package/lib/typescript/src/web.d.ts.map +1 -0
- package/lib/typescript/src/windows.d.ts +32 -0
- package/lib/typescript/src/windows.d.ts.map +1 -0
- package/nitro.json +23 -0
- package/nitrogen/generated/android/c++/JAppendResult.hpp +65 -0
- package/nitrogen/generated/android/c++/JAttachmentInfo.hpp +96 -0
- package/nitrogen/generated/android/c++/JConnectionConfig.hpp +94 -0
- package/nitrogen/generated/android/c++/JConnectionResult.hpp +73 -0
- package/nitrogen/generated/android/c++/JDeleteResult.hpp +87 -0
- package/nitrogen/generated/android/c++/JEmailAddress.hpp +61 -0
- package/nitrogen/generated/android/c++/JEmailBody.hpp +84 -0
- package/nitrogen/generated/android/c++/JEmailMessage.hpp +273 -0
- package/nitrogen/generated/android/c++/JFetchEmailsOptions.hpp +110 -0
- package/nitrogen/generated/android/c++/JFolderInfo.hpp +126 -0
- package/nitrogen/generated/android/c++/JFunc_void_IdleNotification.hpp +79 -0
- package/nitrogen/generated/android/c++/JHybridEmailImapSmtpSpec.cpp +697 -0
- package/nitrogen/generated/android/c++/JHybridEmailImapSmtpSpec.hpp +92 -0
- package/nitrogen/generated/android/c++/JIdleNotification.hpp +78 -0
- package/nitrogen/generated/android/c++/JImapServerInfo.hpp +84 -0
- package/nitrogen/generated/android/c++/JMailboxInfo.hpp +134 -0
- package/nitrogen/generated/android/c++/JMailboxListItem.hpp +92 -0
- package/nitrogen/generated/android/c++/JQuotaInfo.hpp +78 -0
- package/nitrogen/generated/android/c++/JSearchCriteria.hpp +187 -0
- package/nitrogen/generated/android/c++/JSearchHeader.hpp +61 -0
- package/nitrogen/generated/android/c++/JSendMailOptions.hpp +229 -0
- package/nitrogen/generated/android/c++/JSendMailResult.hpp +88 -0
- package/nitrogen/generated/android/c++/JSmtpAttachment.hpp +80 -0
- package/nitrogen/generated/android/c++/JSmtpConnectionConfig.hpp +98 -0
- package/nitrogen/generated/android/c++/JSmtpServerInfo.hpp +102 -0
- package/nitrogen/generated/android/c++/JVariant_ImapServerInfo_SmtpServerInfo.cpp +26 -0
- package/nitrogen/generated/android/c++/JVariant_ImapServerInfo_SmtpServerInfo.hpp +73 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_AttachmentInfo.cpp +26 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_AttachmentInfo.hpp +75 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_EmailMessage.cpp +26 -0
- package/nitrogen/generated/android/c++/JVariant_NullType_EmailMessage.hpp +83 -0
- package/nitrogen/generated/android/emailimapsmtp+autolinking.cmake +84 -0
- package/nitrogen/generated/android/emailimapsmtp+autolinking.gradle +27 -0
- package/nitrogen/generated/android/emailimapsmtpOnLoad.cpp +56 -0
- package/nitrogen/generated/android/emailimapsmtpOnLoad.hpp +34 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/AppendResult.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/AttachmentInfo.kt +96 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/ConnectionConfig.kt +96 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/ConnectionResult.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/DeleteResult.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/EmailAddress.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/EmailBody.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/EmailMessage.kt +156 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/FetchEmailsOptions.kt +116 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/FolderInfo.kt +91 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/Func_void_IdleNotification.kt +80 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/HybridEmailImapSmtpSpec.kt +178 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/IdleNotification.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/ImapServerInfo.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/MailboxInfo.kt +101 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/MailboxListItem.kt +71 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/QuotaInfo.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SearchCriteria.kt +196 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SearchHeader.kt +56 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SendMailOptions.kt +126 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SendMailResult.kt +86 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SmtpAttachment.kt +76 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SmtpConnectionConfig.kt +101 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SmtpServerInfo.kt +61 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/Variant_ImapServerInfo_SmtpServerInfo.kt +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/Variant_NullType_AttachmentInfo.kt +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/Variant_NullType_EmailMessage.kt +62 -0
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/emailimapsmtpOnLoad.kt +35 -0
- package/nitrogen/generated/ios/EmailImapSmtp+autolinking.rb +62 -0
- package/nitrogen/generated/ios/EmailImapSmtp-Swift-Cxx-Bridge.cpp +161 -0
- package/nitrogen/generated/ios/EmailImapSmtp-Swift-Cxx-Bridge.hpp +1132 -0
- package/nitrogen/generated/ios/EmailImapSmtp-Swift-Cxx-Umbrella.hpp +118 -0
- package/nitrogen/generated/ios/EmailImapSmtpAutolinking.mm +33 -0
- package/nitrogen/generated/ios/EmailImapSmtpAutolinking.swift +26 -0
- package/nitrogen/generated/ios/c++/HybridEmailImapSmtpSpecSwift.cpp +11 -0
- package/nitrogen/generated/ios/c++/HybridEmailImapSmtpSpecSwift.hpp +390 -0
- package/nitrogen/generated/ios/swift/AppendResult.swift +39 -0
- package/nitrogen/generated/ios/swift/AttachmentInfo.swift +113 -0
- package/nitrogen/generated/ios/swift/ConnectionConfig.swift +87 -0
- package/nitrogen/generated/ios/swift/ConnectionResult.swift +84 -0
- package/nitrogen/generated/ios/swift/DeleteResult.swift +64 -0
- package/nitrogen/generated/ios/swift/EmailAddress.swift +34 -0
- package/nitrogen/generated/ios/swift/EmailBody.swift +45 -0
- package/nitrogen/generated/ios/swift/EmailMessage.swift +184 -0
- package/nitrogen/generated/ios/swift/FetchEmailsOptions.swift +198 -0
- package/nitrogen/generated/ios/swift/FolderInfo.swift +81 -0
- package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_AppendResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_ConnectionResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_DeleteResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_IdleNotification.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_MailboxInfo.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_QuotaInfo.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_SendMailResult.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__AttachmentInfo_.swift +58 -0
- package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__EmailMessage_.swift +58 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_AttachmentInfo_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_EmailMessage_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_FolderInfo_.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__vector_MailboxListItem_.swift +46 -0
- package/nitrogen/generated/ios/swift/HybridEmailImapSmtpSpec.swift +84 -0
- package/nitrogen/generated/ios/swift/HybridEmailImapSmtpSpec_cxx.swift +759 -0
- package/nitrogen/generated/ios/swift/IdleNotification.swift +93 -0
- package/nitrogen/generated/ios/swift/ImapServerInfo.swift +45 -0
- package/nitrogen/generated/ios/swift/MailboxInfo.swift +91 -0
- package/nitrogen/generated/ios/swift/MailboxListItem.swift +55 -0
- package/nitrogen/generated/ios/swift/QuotaInfo.swift +80 -0
- package/nitrogen/generated/ios/swift/SearchCriteria.swift +485 -0
- package/nitrogen/generated/ios/swift/SearchHeader.swift +34 -0
- package/nitrogen/generated/ios/swift/SendMailOptions.swift +310 -0
- package/nitrogen/generated/ios/swift/SendMailResult.swift +155 -0
- package/nitrogen/generated/ios/swift/SmtpAttachment.swift +80 -0
- package/nitrogen/generated/ios/swift/SmtpConnectionConfig.swift +105 -0
- package/nitrogen/generated/ios/swift/SmtpServerInfo.swift +51 -0
- package/nitrogen/generated/ios/swift/Variant_ImapServerInfo_SmtpServerInfo.swift +30 -0
- package/nitrogen/generated/ios/swift/Variant_NullType_AttachmentInfo.swift +30 -0
- package/nitrogen/generated/ios/swift/Variant_NullType_EmailMessage.swift +30 -0
- package/nitrogen/generated/shared/c++/AppendResult.hpp +91 -0
- package/nitrogen/generated/shared/c++/AttachmentInfo.hpp +121 -0
- package/nitrogen/generated/shared/c++/ConnectionConfig.hpp +120 -0
- package/nitrogen/generated/shared/c++/ConnectionResult.hpp +98 -0
- package/nitrogen/generated/shared/c++/DeleteResult.hpp +93 -0
- package/nitrogen/generated/shared/c++/EmailAddress.hpp +87 -0
- package/nitrogen/generated/shared/c++/EmailBody.hpp +92 -0
- package/nitrogen/generated/shared/c++/EmailMessage.hpp +177 -0
- package/nitrogen/generated/shared/c++/FetchEmailsOptions.hpp +136 -0
- package/nitrogen/generated/shared/c++/FolderInfo.hpp +116 -0
- package/nitrogen/generated/shared/c++/HybridEmailImapSmtpSpec.cpp +50 -0
- package/nitrogen/generated/shared/c++/HybridEmailImapSmtpSpec.hpp +145 -0
- package/nitrogen/generated/shared/c++/IdleNotification.hpp +104 -0
- package/nitrogen/generated/shared/c++/ImapServerInfo.hpp +92 -0
- package/nitrogen/generated/shared/c++/MailboxInfo.hpp +124 -0
- package/nitrogen/generated/shared/c++/MailboxListItem.hpp +100 -0
- package/nitrogen/generated/shared/c++/QuotaInfo.hpp +104 -0
- package/nitrogen/generated/shared/c++/SearchCriteria.hpp +203 -0
- package/nitrogen/generated/shared/c++/SearchHeader.hpp +87 -0
- package/nitrogen/generated/shared/c++/SendMailOptions.hpp +151 -0
- package/nitrogen/generated/shared/c++/SendMailResult.hpp +113 -0
- package/nitrogen/generated/shared/c++/SmtpAttachment.hpp +105 -0
- package/nitrogen/generated/shared/c++/SmtpConnectionConfig.hpp +124 -0
- package/nitrogen/generated/shared/c++/SmtpServerInfo.hpp +92 -0
- package/package.json +196 -0
- package/react-native.config.js +27 -0
- package/src/EmailImapSmtp.nitro.ts +333 -0
- package/src/NativeEmailImapSmtp.ts +100 -0
- package/src/index.tsx +166 -0
- package/src/native.ts +305 -0
- package/src/types.ts +328 -0
- package/src/web.ts +196 -0
- package/src/windows.ts +304 -0
- package/windows/EmailImapSmtp.cpp +735 -0
- package/windows/EmailImapSmtp.h +137 -0
- package/windows/EmailImapSmtpModule.cpp +10 -0
- package/windows/EmailImapSmtpModule.h +139 -0
- package/windows/EmailImapSmtpPackage.cpp +15 -0
- package/windows/EmailImapSmtpPackage.h +13 -0
- package/windows/ImapProtocol.cpp +1382 -0
- package/windows/ImapProtocol.h +97 -0
- package/windows/ProtocolDefs.h +300 -0
- package/windows/SmtpProtocol.cpp +334 -0
- package/windows/SmtpProtocol.h +36 -0
- package/windows/TlsSocket.cpp +818 -0
- package/windows/TlsSocket.h +87 -0
|
@@ -0,0 +1,1533 @@
|
|
|
1
|
+
package com.margelo.nitro.emailimapsmtp
|
|
2
|
+
|
|
3
|
+
import android.util.Log
|
|
4
|
+
import com.facebook.proguard.annotations.DoNotStrip
|
|
5
|
+
import com.margelo.nitro.core.ArrayBuffer
|
|
6
|
+
import com.margelo.nitro.core.Promise
|
|
7
|
+
import com.sun.mail.imap.IMAPFolder
|
|
8
|
+
import com.sun.mail.imap.IMAPStore
|
|
9
|
+
import com.sun.mail.smtp.SMTPTransport
|
|
10
|
+
import jakarta.mail.*
|
|
11
|
+
import jakarta.mail.internet.*
|
|
12
|
+
import jakarta.mail.search.*
|
|
13
|
+
import java.io.ByteArrayOutputStream
|
|
14
|
+
import java.io.File
|
|
15
|
+
import java.io.FileOutputStream
|
|
16
|
+
import java.security.cert.X509Certificate
|
|
17
|
+
import java.time.ZoneId
|
|
18
|
+
import java.time.ZonedDateTime
|
|
19
|
+
import java.time.format.DateTimeFormatter
|
|
20
|
+
import java.util.*
|
|
21
|
+
import java.util.concurrent.Executors
|
|
22
|
+
import javax.net.ssl.SSLContext
|
|
23
|
+
import javax.net.ssl.SSLSocketFactory
|
|
24
|
+
import javax.net.ssl.X509TrustManager
|
|
25
|
+
import kotlinx.coroutines.CoroutineScope
|
|
26
|
+
import kotlinx.coroutines.SupervisorJob
|
|
27
|
+
import kotlinx.coroutines.asCoroutineDispatcher
|
|
28
|
+
|
|
29
|
+
private const val TAG = "EmailImapSmtp"
|
|
30
|
+
|
|
31
|
+
/** 单个附件最大 10MB,超过则截断 */
|
|
32
|
+
private const val MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024L
|
|
33
|
+
|
|
34
|
+
/** 单次 FETCH 获取邮件最大数量 */
|
|
35
|
+
private const val MAX_FETCH_COUNT = 200
|
|
36
|
+
|
|
37
|
+
/** 附件缓存目录(系统临时目录) */
|
|
38
|
+
private val ATTACHMENT_CACHE_DIR: File by lazy {
|
|
39
|
+
File(System.getProperty("java.io.tmpdir"), "email_attachments").also { it.mkdirs() }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
@DoNotStrip
|
|
43
|
+
class EmailImapSmtp : HybridEmailImapSmtpSpec() {
|
|
44
|
+
private var imapStore: IMAPStore? = null
|
|
45
|
+
private var imapFolder: IMAPFolder? = null
|
|
46
|
+
private var smtpTransport: SMTPTransport? = null
|
|
47
|
+
private var smtpSessionCache: Session? = null
|
|
48
|
+
private var smtpConfig: SmtpConnectionConfig? = null
|
|
49
|
+
private var selectedMailbox: String? = null
|
|
50
|
+
private var imapConfig: ConnectionConfig? = null
|
|
51
|
+
|
|
52
|
+
@Volatile
|
|
53
|
+
private var isIdling = false
|
|
54
|
+
|
|
55
|
+
// ─── helpers ──────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/** 创建信任所有证书的 SocketFactory(用于 checkCertificate=false 或 163邮箱等特殊服务器) */
|
|
58
|
+
private val trustAllSocketFactory: SSLSocketFactory by lazy {
|
|
59
|
+
val ctx = SSLContext.getInstance("TLS")
|
|
60
|
+
ctx.init(null, arrayOf(object : X509TrustManager {
|
|
61
|
+
override fun checkClientTrusted(p0: Array<X509Certificate?>, p1: String?) {}
|
|
62
|
+
override fun checkServerTrusted(p0: Array<X509Certificate?>, p1: String?) {}
|
|
63
|
+
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
|
64
|
+
}), null)
|
|
65
|
+
ctx.socketFactory
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private fun imapSession(config: ConnectionConfig): Session {
|
|
69
|
+
val props = Properties().apply {
|
|
70
|
+
put("mail.imap.host", config.host)
|
|
71
|
+
put("mail.imap.port", config.port.toInt().toString())
|
|
72
|
+
put("mail.imap.connectiontimeout", config.timeout.toInt().toString())
|
|
73
|
+
put("mail.imap.timeout", config.timeout.toInt().toString())
|
|
74
|
+
put("mail.imap.writetimeout", config.timeout.toInt().toString())
|
|
75
|
+
put("mail.store.protocol", "imap")
|
|
76
|
+
put("mail.imap.fetchsize", "1048576")
|
|
77
|
+
put("mail.imap.partialfetch", "true")
|
|
78
|
+
// 增加连接保活相关设置
|
|
79
|
+
// 注意: 不设置 separatestoreconnection,让 store 和 folder 共用同一连接
|
|
80
|
+
// 这样 connect 阶段发送的 ID 命令才能在 folder.open() 的 SELECT 时生效
|
|
81
|
+
put("mail.imap.folder.noselect", "false")
|
|
82
|
+
put("mail.imap.connectionpooltimeout", config.timeout.toInt().toString())
|
|
83
|
+
|
|
84
|
+
// ── SSL/TLS 配置 ──────────────────────────────────────
|
|
85
|
+
// TLS 启用:对于 993 端口必须开启
|
|
86
|
+
if (config.tls) {
|
|
87
|
+
put("mail.imap.ssl.enable", "true")
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 若 checkCertificate=false,使用信任所有证书的 SocketFactory
|
|
91
|
+
// 163/188 等国内邮箱的自签名或非标准证书需要此设置
|
|
92
|
+
if (!config.checkCertificate) {
|
|
93
|
+
put("mail.imap.ssl.socketFactory", trustAllSocketFactory)
|
|
94
|
+
put("mail.imap.ssl.socketFactory.fallback", "false")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (config.debug) put("mail.debug", "true")
|
|
98
|
+
}
|
|
99
|
+
return Session.getInstance(props)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private fun smtpSession(config: SmtpConnectionConfig): Session {
|
|
103
|
+
val props = Properties().apply {
|
|
104
|
+
put("mail.smtp.host", config.host)
|
|
105
|
+
put("mail.smtp.port", config.port.toInt().toString())
|
|
106
|
+
put("mail.smtp.auth", "true")
|
|
107
|
+
put("mail.smtp.connectiontimeout", config.timeout.toInt().toString())
|
|
108
|
+
put("mail.smtp.timeout", config.timeout.toInt().toString())
|
|
109
|
+
put("mail.smtp.writetimeout", config.timeout.toInt().toString())
|
|
110
|
+
put("mail.transport.protocol", "smtp")
|
|
111
|
+
put("mail.smtp.sendpartial", "true")
|
|
112
|
+
|
|
113
|
+
// ── SSL/TLS 配置 ──────────────────────────────────────
|
|
114
|
+
if (config.tls) {
|
|
115
|
+
put("mail.smtp.ssl.enable", "true")
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 若 checkCertificate=false,使用信任所有证书的 SocketFactory
|
|
119
|
+
if (!config.checkCertificate) {
|
|
120
|
+
put("mail.smtp.ssl.socketFactory", trustAllSocketFactory)
|
|
121
|
+
put("mail.smtp.ssl.socketFactory.fallback", "false")
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (config.debug) put("mail.debug", "true")
|
|
125
|
+
}
|
|
126
|
+
return Session.getInstance(props)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 确保文件夹已打开,若连接断开则自动重连 store 并重开文件夹 */
|
|
130
|
+
private fun ensureFolderOpen(): IMAPFolder {
|
|
131
|
+
ensureStoreConnected() // 先确保底层 Store 连接存活
|
|
132
|
+
val folder = imapFolder ?: throw Exception("No mailbox selected. Call imapSelectMailbox first.")
|
|
133
|
+
if (!folder.isOpen()) {
|
|
134
|
+
Log.w(TAG, "Folder was closed by server, re-opening: $selectedMailbox")
|
|
135
|
+
folder.open(Folder.READ_WRITE)
|
|
136
|
+
Log.d(TAG, "Folder re-opened: $selectedMailbox")
|
|
137
|
+
}
|
|
138
|
+
return folder
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private fun ensureImapStore(): IMAPStore {
|
|
142
|
+
return imapStore ?: throw Exception("Not connected to IMAP server.")
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** 确保 IMAP Store 连接存活,若已断开则自动使用保存的配置重连 */
|
|
146
|
+
private fun ensureStoreConnected(): IMAPStore {
|
|
147
|
+
val store = ensureImapStore()
|
|
148
|
+
if (!store.isConnected()) {
|
|
149
|
+
val config = imapConfig ?: throw Exception("IMAP store disconnected and no config available for reconnection")
|
|
150
|
+
Log.w(TAG, "IMAP store connection lost, reconnecting to ${config.host}...")
|
|
151
|
+
store.connect(config.username, config.password)
|
|
152
|
+
// 重连后重新发送 ID 命令(网易邮箱要求)
|
|
153
|
+
try {
|
|
154
|
+
store.id(buildIdMap(config.username))
|
|
155
|
+
Log.d(TAG, "Reconnect: ID command sent")
|
|
156
|
+
} catch (_: Exception) {
|
|
157
|
+
Log.d(TAG, "Reconnect: ID command not supported")
|
|
158
|
+
}
|
|
159
|
+
// 重连后之前的 folder 引用已失效,清除以触发重新选择
|
|
160
|
+
if (imapFolder != null) {
|
|
161
|
+
Log.w(TAG, "Store reconnected, previously selected mailbox ($selectedMailbox) folder is stale")
|
|
162
|
+
imapFolder = null
|
|
163
|
+
selectedMailbox = null
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return store
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ─── 保活(Keep-Alive)────────────────────────────────────
|
|
170
|
+
// 定期发送 NOOP 命令防止 IMAP 服务器因空闲超时断开连接
|
|
171
|
+
|
|
172
|
+
@Volatile
|
|
173
|
+
private var keepAliveRunning = false
|
|
174
|
+
private var keepAliveThread: Thread? = null
|
|
175
|
+
|
|
176
|
+
private fun startKeepAlive() {
|
|
177
|
+
stopKeepAlive()
|
|
178
|
+
keepAliveRunning = true
|
|
179
|
+
val t = object : Thread("imap-keepalive") {
|
|
180
|
+
override fun run() {
|
|
181
|
+
while (keepAliveRunning) {
|
|
182
|
+
try {
|
|
183
|
+
Thread.sleep(60_000) // 每 60 秒发送一次 NOOP
|
|
184
|
+
if (!keepAliveRunning) break
|
|
185
|
+
val folder = imapFolder
|
|
186
|
+
if (folder != null && folder.isOpen()) {
|
|
187
|
+
folder.doCommand { protocol ->
|
|
188
|
+
protocol.simpleCommand("NOOP", null)
|
|
189
|
+
null
|
|
190
|
+
}
|
|
191
|
+
Log.v(TAG, "Keep-alive NOOP sent")
|
|
192
|
+
}
|
|
193
|
+
} catch (_: InterruptedException) { break }
|
|
194
|
+
catch (_: Exception) { /* NOOP 失败忽略,连接断开后由 ensureStoreConnected 处理 */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
t.isDaemon = true
|
|
199
|
+
t.start()
|
|
200
|
+
keepAliveThread = t
|
|
201
|
+
Log.d(TAG, "Keep-alive started (60s interval)")
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private fun stopKeepAlive() {
|
|
205
|
+
keepAliveRunning = false
|
|
206
|
+
keepAliveThread?.interrupt()
|
|
207
|
+
keepAliveThread = null
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── 邮件解析 ─────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
private fun emailToEmailMessage(msg: Message, folder: IMAPFolder): EmailMessage {
|
|
213
|
+
val mime = msg as? MimeMessage ?: return EmailMessage(
|
|
214
|
+
uid = 0.0, sequenceNumber = msg.messageNumber.toDouble(),
|
|
215
|
+
subject = "", from = EmailAddress("", ""), to = emptyArray(),
|
|
216
|
+
cc = emptyArray(), bcc = emptyArray(), replyTo = emptyArray(),
|
|
217
|
+
date = "", receivedDate = "",
|
|
218
|
+
body = EmailBody("", "", emptyArray()), attachments = emptyArray(),
|
|
219
|
+
flags = flagsToArray(msg.flags), headers = emptyMap(),
|
|
220
|
+
size = msg.size.toDouble(), messageId = "",
|
|
221
|
+
inReplyTo = "", references = "", priority = "normal",
|
|
222
|
+
isEncrypted = false, mimeType = "", charset = "UTF-8"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
val uid = try { folder.getUID(msg).toDouble() } catch (_: Exception) { 0.0 }
|
|
226
|
+
val from = addressToEmailAddress(msg.from?.firstOrNull())
|
|
227
|
+
val to = parseAddressList(msg.getRecipients(Message.RecipientType.TO))
|
|
228
|
+
val cc = parseAddressList(msg.getRecipients(Message.RecipientType.CC))
|
|
229
|
+
val bcc = parseAddressList(msg.getRecipients(Message.RecipientType.BCC))
|
|
230
|
+
val replyTo = parseAddressList(msg.replyTo)
|
|
231
|
+
|
|
232
|
+
val sentDate = msg.sentDate?.toISO8601() ?: ""
|
|
233
|
+
val receivedDate = msg.receivedDate?.toISO8601() ?: ""
|
|
234
|
+
|
|
235
|
+
var bodyText = ""
|
|
236
|
+
var bodyHtml = ""
|
|
237
|
+
val embeddedAttachments = mutableListOf<String>()
|
|
238
|
+
val attachments = mutableListOf<AttachmentInfo>()
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
parseBodyParts(mime,
|
|
242
|
+
onText = { bodyText = it },
|
|
243
|
+
onHtml = { bodyHtml = it },
|
|
244
|
+
onEmbedded = { embeddedAttachments.add(it) },
|
|
245
|
+
onAttachment = { attachments.add(it) })
|
|
246
|
+
} catch (_: Exception) {
|
|
247
|
+
bodyText = mime.content?.toString() ?: ""
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
val headers = mutableMapOf<String, String>()
|
|
251
|
+
try {
|
|
252
|
+
mime.allHeaders?.toList()?.forEach { header ->
|
|
253
|
+
if (headers.size < 64) headers[header.name] = header.value
|
|
254
|
+
}
|
|
255
|
+
} catch (_: Exception) {}
|
|
256
|
+
|
|
257
|
+
return EmailMessage(
|
|
258
|
+
uid = uid,
|
|
259
|
+
sequenceNumber = msg.messageNumber.toDouble(),
|
|
260
|
+
subject = mime.subject ?: "",
|
|
261
|
+
from = from,
|
|
262
|
+
to = to, cc = cc, bcc = bcc, replyTo = replyTo,
|
|
263
|
+
date = sentDate,
|
|
264
|
+
receivedDate = receivedDate,
|
|
265
|
+
body = EmailBody(text = bodyText, html = bodyHtml, embeddedAttachments = embeddedAttachments.toTypedArray()),
|
|
266
|
+
attachments = attachments.toTypedArray(),
|
|
267
|
+
flags = flagsToArray(msg.flags),
|
|
268
|
+
headers = headers,
|
|
269
|
+
size = msg.size.toDouble(),
|
|
270
|
+
messageId = mime.messageID ?: "",
|
|
271
|
+
inReplyTo = mime.getHeader("In-Reply-To", null) ?: "",
|
|
272
|
+
references = mime.getHeader("References", null) ?: "",
|
|
273
|
+
priority = extractPriority(mime),
|
|
274
|
+
isEncrypted = false,
|
|
275
|
+
mimeType = mime.contentType ?: "",
|
|
276
|
+
charset = extractCharset(mime)
|
|
277
|
+
)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private fun parseBodyParts(
|
|
281
|
+
part: Part,
|
|
282
|
+
onText: (String) -> Unit,
|
|
283
|
+
onHtml: (String) -> Unit,
|
|
284
|
+
onEmbedded: (String) -> Unit,
|
|
285
|
+
onAttachment: (AttachmentInfo) -> Unit,
|
|
286
|
+
) {
|
|
287
|
+
if (part.isMimeType("text/plain")) {
|
|
288
|
+
onText(part.content?.toString() ?: "")
|
|
289
|
+
} else if (part.isMimeType("text/html")) {
|
|
290
|
+
onHtml(part.content?.toString() ?: "")
|
|
291
|
+
} else if (part.isMimeType("multipart/alternative")) {
|
|
292
|
+
val mp = part.content as? Multipart ?: return
|
|
293
|
+
var foundHtml = false
|
|
294
|
+
var foundText = false
|
|
295
|
+
for (i in 0 until mp.count) {
|
|
296
|
+
val bp = mp.getBodyPart(i)
|
|
297
|
+
if (bp.isMimeType("text/html") && !foundHtml) {
|
|
298
|
+
onHtml(bp.content?.toString() ?: ""); foundHtml = true
|
|
299
|
+
} else if (bp.isMimeType("text/plain") && !foundText) {
|
|
300
|
+
onText(bp.content?.toString() ?: ""); foundText = true
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
} else if (part.isMimeType("multipart/*")) {
|
|
304
|
+
val mp = part.content as? Multipart ?: return
|
|
305
|
+
for (i in 0 until mp.count) {
|
|
306
|
+
parseBodyParts(mp.getBodyPart(i), onText, onHtml, onEmbedded, onAttachment)
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
val disp = part.disposition ?: ""
|
|
310
|
+
val isAttachment = disp.equals(Part.ATTACHMENT, ignoreCase = true)
|
|
311
|
+
val isInline = disp.equals(Part.INLINE, ignoreCase = true)
|
|
312
|
+
val contentId = (part as? MimeBodyPart)?.contentID
|
|
313
|
+
val fileName = part.fileName ?: contentId ?: "unnamed"
|
|
314
|
+
|
|
315
|
+
if (isAttachment || isInline || contentId != null) {
|
|
316
|
+
val info = readAttachmentData(part as BodyPart, fileName, contentId, isInline, disp)
|
|
317
|
+
if (isInline && contentId != null) {
|
|
318
|
+
onEmbedded(fileName)
|
|
319
|
+
}
|
|
320
|
+
if (isAttachment || (isInline && contentId == null)) {
|
|
321
|
+
onAttachment(info)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
private fun readAttachmentData(
|
|
328
|
+
part: BodyPart, fileName: String, contentId: String?,
|
|
329
|
+
isInline: Boolean, disp: String
|
|
330
|
+
): AttachmentInfo {
|
|
331
|
+
// 先保存到缓存文件,避免 OOM
|
|
332
|
+
var localPath: String? = null
|
|
333
|
+
val baos = ByteArrayOutputStream(minOf(MAX_ATTACHMENT_SIZE, 64 * 1024).toInt())
|
|
334
|
+
val input = part.inputStream
|
|
335
|
+
if (input == null) {
|
|
336
|
+
return AttachmentInfo(
|
|
337
|
+
filename = fileName,
|
|
338
|
+
mimeType = part.contentType ?: "application/octet-stream",
|
|
339
|
+
partId = contentId ?: fileName,
|
|
340
|
+
size = 0.0,
|
|
341
|
+
data = ArrayBuffer.copy(ByteArray(0)),
|
|
342
|
+
contentId = contentId,
|
|
343
|
+
inline = isInline,
|
|
344
|
+
charset = part.contentType?.let { extractCharsetFromMimeType(it) },
|
|
345
|
+
contentDisposition = disp,
|
|
346
|
+
localPath = null
|
|
347
|
+
)
|
|
348
|
+
}
|
|
349
|
+
try {
|
|
350
|
+
input.use { stream ->
|
|
351
|
+
// 生成唯一的缓存文件名
|
|
352
|
+
val cacheFile = File(ATTACHMENT_CACHE_DIR, "${UUID.randomUUID()}_${fileName.sanitizeFileName()}")
|
|
353
|
+
try {
|
|
354
|
+
FileOutputStream(cacheFile).use { fileOut ->
|
|
355
|
+
val buf = ByteArray(8192)
|
|
356
|
+
var total = 0L
|
|
357
|
+
var n: Int
|
|
358
|
+
while (stream.read(buf).also { n = it } != -1) {
|
|
359
|
+
if (total + n > MAX_ATTACHMENT_SIZE) {
|
|
360
|
+
val remaining = (MAX_ATTACHMENT_SIZE - total).toInt()
|
|
361
|
+
baos.write(buf, 0, remaining)
|
|
362
|
+
fileOut.write(buf, 0, remaining)
|
|
363
|
+
total = MAX_ATTACHMENT_SIZE
|
|
364
|
+
break
|
|
365
|
+
}
|
|
366
|
+
baos.write(buf, 0, n)
|
|
367
|
+
fileOut.write(buf, 0, n)
|
|
368
|
+
total += n
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
localPath = cacheFile.absolutePath
|
|
372
|
+
} catch (e: Exception) {
|
|
373
|
+
cacheFile.delete()
|
|
374
|
+
throw e
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
} catch (_: Exception) {}
|
|
378
|
+
val byteArray = try {
|
|
379
|
+
baos.toByteArray()
|
|
380
|
+
} catch (e: OutOfMemoryError) {
|
|
381
|
+
Log.e(TAG, "Attachment too large for memory: $fileName, using cache file")
|
|
382
|
+
// OOM 时至少返回缓存文件路径,前端可通过 localPath 读取
|
|
383
|
+
ByteArray(0)
|
|
384
|
+
}
|
|
385
|
+
return AttachmentInfo(
|
|
386
|
+
filename = fileName,
|
|
387
|
+
mimeType = part.contentType ?: "application/octet-stream",
|
|
388
|
+
partId = contentId ?: fileName,
|
|
389
|
+
size = baos.size().toDouble(),
|
|
390
|
+
data = ArrayBuffer.copy(byteArray),
|
|
391
|
+
contentId = contentId,
|
|
392
|
+
inline = isInline,
|
|
393
|
+
charset = part.contentType?.let { extractCharsetFromMimeType(it) },
|
|
394
|
+
contentDisposition = disp,
|
|
395
|
+
localPath = localPath
|
|
396
|
+
)
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** 清理文件名中的非法字符 */
|
|
400
|
+
private fun String.sanitizeFileName(): String {
|
|
401
|
+
return replace(Regex("""[<>:"/\\|?*]"""), "_")
|
|
402
|
+
.take(128)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private fun extractPriority(mime: MimeMessage): String {
|
|
406
|
+
val importance = mime.getHeader("Importance", null)
|
|
407
|
+
val priority = mime.getHeader("Priority", null) ?: mime.getHeader("X-Priority", null)
|
|
408
|
+
return when {
|
|
409
|
+
importance?.equals("high", ignoreCase = true) == true -> "high"
|
|
410
|
+
importance?.equals("low", ignoreCase = true) == true -> "low"
|
|
411
|
+
priority?.startsWith("1") == true || priority?.startsWith("2") == true -> "high"
|
|
412
|
+
priority?.startsWith("4") == true || priority?.startsWith("5") == true -> "low"
|
|
413
|
+
else -> "normal"
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
private fun extractCharset(mime: MimeMessage): String {
|
|
418
|
+
val ct = mime.contentType ?: return "UTF-8"
|
|
419
|
+
return extractCharsetFromMimeType(ct) ?: "UTF-8"
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
private fun extractCharsetFromMimeType(mimeType: String): String? {
|
|
423
|
+
val regex = """charset\s*=\s*([^\s;"]+)""".toRegex(RegexOption.IGNORE_CASE)
|
|
424
|
+
return regex.find(mimeType)?.groupValues?.getOrNull(1)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
private fun addressToEmailAddress(addr: Address?): EmailAddress {
|
|
428
|
+
if (addr is InternetAddress) {
|
|
429
|
+
val personal = addr.personal ?: ""
|
|
430
|
+
return try {
|
|
431
|
+
EmailAddress(name = MimeUtility.decodeText(personal), email = addr.address)
|
|
432
|
+
} catch (_: Exception) {
|
|
433
|
+
EmailAddress(name = personal, email = addr.address)
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return EmailAddress(name = "", email = addr?.toString() ?: "")
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
private fun flagsToArray(flags: Flags?): Array<String> {
|
|
440
|
+
if (flags == null) return emptyArray()
|
|
441
|
+
val result = mutableListOf<String>()
|
|
442
|
+
if (flags.contains(Flags.Flag.SEEN)) result.add("seen")
|
|
443
|
+
if (flags.contains(Flags.Flag.ANSWERED)) result.add("answered")
|
|
444
|
+
if (flags.contains(Flags.Flag.FLAGGED)) result.add("flagged")
|
|
445
|
+
if (flags.contains(Flags.Flag.DELETED)) result.add("deleted")
|
|
446
|
+
if (flags.contains(Flags.Flag.DRAFT)) result.add("draft")
|
|
447
|
+
if (flags.contains(Flags.Flag.RECENT)) result.add("recent")
|
|
448
|
+
flags.getUserFlags().forEach { result.add(it) }
|
|
449
|
+
return result.toTypedArray()
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
private fun parseAddressList(recipients: Array<out Address>?): Array<EmailAddress> {
|
|
453
|
+
return recipients?.filterIsInstance<InternetAddress>()
|
|
454
|
+
?.map { addressToEmailAddress(it) }
|
|
455
|
+
?.toTypedArray() ?: emptyArray()
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** 构造 IMAP ID 命令参数 (RFC 2971),用于网易邮箱等要求 ID 命令的服务器 */
|
|
459
|
+
private fun buildIdMap(username: String?): java.util.HashMap<String, String> {
|
|
460
|
+
return java.util.HashMap<String, String>().apply {
|
|
461
|
+
put("name", "react-native-email-imap-smtp")
|
|
462
|
+
put("version", "1.0.0")
|
|
463
|
+
put("vendor", "react-native-email-imap-smtp")
|
|
464
|
+
put("support-email", username ?: "")
|
|
465
|
+
put("os", "Android")
|
|
466
|
+
put("os-version", System.getProperty("os.version") ?: "unknown")
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** 解析 "dd-MMM-yyyy" 格式的日期字符串(如 "01-Jan-2026") */
|
|
471
|
+
private fun parseDateString(dateStr: String?): Date? {
|
|
472
|
+
if (dateStr == null) return null
|
|
473
|
+
return try {
|
|
474
|
+
java.text.SimpleDateFormat("dd-MMM-yyyy", java.util.Locale.ENGLISH).parse(dateStr)
|
|
475
|
+
} catch (_: Exception) { null }
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// 日期格式化(DateTimeFormatter 是线程安全的,无需 ThreadLocal)
|
|
479
|
+
private val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ssZ")
|
|
480
|
+
|
|
481
|
+
private fun Date.toISO8601(): String {
|
|
482
|
+
return ZonedDateTime.ofInstant(this.toInstant(), ZoneId.systemDefault())
|
|
483
|
+
.format(dateFormatter)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ═══ IMAP — Connection ═════════════════════════════════════
|
|
487
|
+
|
|
488
|
+
override fun imapConnect(config: ConnectionConfig): Promise<ConnectionResult> {
|
|
489
|
+
return Promise.async {
|
|
490
|
+
try {
|
|
491
|
+
val session = imapSession(config)
|
|
492
|
+
val store = session.store as? IMAPStore
|
|
493
|
+
?: return@async ConnectionResult(success = false, serverInfo = null, error = "Failed to create IMAP store")
|
|
494
|
+
store.connect(config.username, config.password)
|
|
495
|
+
|
|
496
|
+
// ── 发送 IMAP ID 命令 (RFC 2971) ─────────────────────
|
|
497
|
+
// 网易邮箱(163/188)要求客户端在认证后发送 ID 命令标识自身,
|
|
498
|
+
// 否则后续 SELECT 等操作会被拒绝并返回 "Unsafe Login"
|
|
499
|
+
try {
|
|
500
|
+
store.id(buildIdMap(config.username))
|
|
501
|
+
Log.d(TAG, "IMAP ID command sent successfully")
|
|
502
|
+
} catch (e: Exception) {
|
|
503
|
+
// 部分服务器(如 Gmail)不支持 ID 命令,此错误可安全忽略
|
|
504
|
+
Log.d(TAG, "IMAP ID command not supported by server: ${e.message}")
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
imapStore = store
|
|
508
|
+
imapConfig = config
|
|
509
|
+
ConnectionResult(
|
|
510
|
+
success = true,
|
|
511
|
+
serverInfo = Variant_ImapServerInfo_SmtpServerInfo.create(
|
|
512
|
+
ImapServerInfo(
|
|
513
|
+
serverName = config.host,
|
|
514
|
+
capabilities = emptyArray(),
|
|
515
|
+
isCompressed = false
|
|
516
|
+
)
|
|
517
|
+
),
|
|
518
|
+
error = null
|
|
519
|
+
)
|
|
520
|
+
} catch (e: Exception) {
|
|
521
|
+
Log.e(TAG, "IMAP connect failed", e)
|
|
522
|
+
ConnectionResult(success = false, serverInfo = null, error = e.message)
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
override fun imapDisconnect(): Promise<Unit> {
|
|
528
|
+
return Promise.async {
|
|
529
|
+
stopKeepAlive() // 停止保活
|
|
530
|
+
try { imapFolder?.close(false) } catch (_: Exception) {}
|
|
531
|
+
try { imapStore?.close() } catch (_: Exception) {}
|
|
532
|
+
imapFolder = null; imapStore = null; selectedMailbox = null; imapConfig = null
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// ═══ IMAP — Mailbox ════════════════════════════════════════
|
|
537
|
+
|
|
538
|
+
override fun imapListMailboxes(): Promise<Array<MailboxListItem>> {
|
|
539
|
+
return Promise.async {
|
|
540
|
+
val store = ensureStoreConnected()
|
|
541
|
+
val result = mutableListOf<MailboxListItem>()
|
|
542
|
+
try {
|
|
543
|
+
store.defaultFolder.list("*").forEach { f ->
|
|
544
|
+
result.add(
|
|
545
|
+
MailboxListItem(
|
|
546
|
+
name = f.name ?: "", path = f.fullName ?: "",
|
|
547
|
+
delimiter = f.separator.toString(), flags = emptyArray(),
|
|
548
|
+
subscribed = f.isSubscribed
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
}
|
|
552
|
+
} catch (e: Exception) {
|
|
553
|
+
Log.e(TAG, "List mailboxes failed", e)
|
|
554
|
+
}
|
|
555
|
+
result.toTypedArray()
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
override fun imapSelectMailbox(mailbox: String, readOnly: Boolean): Promise<MailboxInfo> {
|
|
560
|
+
return Promise.async {
|
|
561
|
+
val store = ensureStoreConnected() // 先确保 Store 连接存活
|
|
562
|
+
val raw = store.getFolder(mailbox) ?: throw Exception("Mailbox not found: $mailbox")
|
|
563
|
+
val folder = raw as? IMAPFolder ?: throw Exception("Mailbox is not an IMAP folder: $mailbox")
|
|
564
|
+
|
|
565
|
+
// ── 在 folder 连接上发送 ID 命令 ─────────────────────
|
|
566
|
+
// 对于网易邮箱等要求 ID 命令的服务器,必须在同一个连接上发送 ID 命令
|
|
567
|
+
try {
|
|
568
|
+
folder.doCommand { protocol ->
|
|
569
|
+
protocol.id(buildIdMap(imapConfig?.username))
|
|
570
|
+
null
|
|
571
|
+
}
|
|
572
|
+
Log.d(TAG, "ID command sent on folder connection")
|
|
573
|
+
} catch (e: Exception) {
|
|
574
|
+
Log.d(TAG, "ID command on folder not supported: ${e.message}")
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
folder.open(if (readOnly) Folder.READ_ONLY else Folder.READ_WRITE)
|
|
579
|
+
} catch (e: jakarta.mail.MessagingException) {
|
|
580
|
+
val msg = e.message ?: ""
|
|
581
|
+
// ── 网易邮箱 "Unsafe Login" 特殊处理 ─────────────────
|
|
582
|
+
// 如果 SELECT 仍然被拒,给出明确指引
|
|
583
|
+
if (msg.contains("Unsafe Login", ignoreCase = true) || msg.contains("NO SELECT", ignoreCase = true)) {
|
|
584
|
+
throw Exception("IMAP 登录被服务器拒绝(Unsafe Login)。请确保:\n" +
|
|
585
|
+
"1. 已在网页端开启 IMAP/SMTP 服务\n" +
|
|
586
|
+
"2. 使用「授权码」而非登录密码\n" +
|
|
587
|
+
"3. 若仍未解决,请登录 ${imapConfig?.host ?: "网易邮箱"} 网页版完成一次安全验证后重试")
|
|
588
|
+
} else {
|
|
589
|
+
// 非 "Unsafe Login" 错误,直接抛出
|
|
590
|
+
throw Exception("选择邮箱 '$mailbox' 失败: ${e.message}", e)
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
imapFolder = folder; selectedMailbox = mailbox
|
|
594
|
+
startKeepAlive() // 选择邮箱成功后启动保活
|
|
595
|
+
|
|
596
|
+
// 计算第一封未读邮件的 UID
|
|
597
|
+
val firstUnseenUid = try {
|
|
598
|
+
val unseen = folder.search(FlagTerm(Flags().apply { add(Flags.Flag.SEEN) }, false))
|
|
599
|
+
if (unseen.isNotEmpty()) folder.getUID(unseen[0]).toDouble() else 0.0
|
|
600
|
+
} catch (_: Exception) { 0.0 }
|
|
601
|
+
|
|
602
|
+
MailboxInfo(
|
|
603
|
+
name = folder.name ?: mailbox, path = folder.fullName ?: mailbox,
|
|
604
|
+
uidNext = folder.uidNext.toDouble(),
|
|
605
|
+
uidValidity = folder.uidValidity.toDouble(),
|
|
606
|
+
messageCount = folder.messageCount.toDouble(),
|
|
607
|
+
recentCount = folder.newMessageCount.toDouble(),
|
|
608
|
+
unseenCount = folder.unreadMessageCount.toDouble(),
|
|
609
|
+
firstUnseenUid = firstUnseenUid, highestModSeq = 0.0,
|
|
610
|
+
flags = emptyArray(), permanentFlags = emptyArray()
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
override fun imapRefreshMailbox(): Promise<MailboxInfo> {
|
|
616
|
+
return Promise.async {
|
|
617
|
+
val f = ensureFolderOpen()
|
|
618
|
+
val firstUnseenUid = try {
|
|
619
|
+
val unseen = f.search(FlagTerm(Flags().apply { add(Flags.Flag.SEEN) }, false))
|
|
620
|
+
if (unseen.isNotEmpty()) f.getUID(unseen[0]).toDouble() else 0.0
|
|
621
|
+
} catch (_: Exception) { 0.0 }
|
|
622
|
+
MailboxInfo(
|
|
623
|
+
name = f.name ?: "", path = f.fullName ?: "",
|
|
624
|
+
uidNext = f.uidNext.toDouble(),
|
|
625
|
+
uidValidity = f.uidValidity.toDouble(),
|
|
626
|
+
messageCount = f.messageCount.toDouble(),
|
|
627
|
+
recentCount = f.newMessageCount.toDouble(),
|
|
628
|
+
unseenCount = f.unreadMessageCount.toDouble(),
|
|
629
|
+
firstUnseenUid = firstUnseenUid, highestModSeq = 0.0,
|
|
630
|
+
flags = emptyArray(), permanentFlags = emptyArray()
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// ═══ IMAP — Folders ════════════════════════════════════════
|
|
636
|
+
|
|
637
|
+
override fun imapFetchFolders(): Promise<Array<FolderInfo>> {
|
|
638
|
+
return Promise.async {
|
|
639
|
+
val store = ensureStoreConnected()
|
|
640
|
+
val result = mutableListOf<FolderInfo>()
|
|
641
|
+
try {
|
|
642
|
+
store.defaultFolder.list("*").forEach { f ->
|
|
643
|
+
result.add(
|
|
644
|
+
FolderInfo(
|
|
645
|
+
name = f.name ?: "", path = f.fullName ?: "",
|
|
646
|
+
delimiter = f.separator.toString(),
|
|
647
|
+
parent = f.parent?.fullName ?: "",
|
|
648
|
+
children = emptyArray(), flags = emptyArray(),
|
|
649
|
+
subscribed = f.isSubscribed,
|
|
650
|
+
messageCount = 0.0, unseenCount = 0.0
|
|
651
|
+
)
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
} catch (e: Exception) {
|
|
655
|
+
Log.e(TAG, "Fetch folders failed", e)
|
|
656
|
+
}
|
|
657
|
+
result.toTypedArray()
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
override fun imapCreateFolder(folderName: String): Promise<Unit> {
|
|
662
|
+
return Promise.async {
|
|
663
|
+
val folder = ensureStoreConnected().getFolder(folderName)
|
|
664
|
+
if (folder.exists()) {
|
|
665
|
+
Log.w(TAG, "Folder already exists: $folderName")
|
|
666
|
+
return@async
|
|
667
|
+
}
|
|
668
|
+
val created = folder.create(Folder.HOLDS_MESSAGES)
|
|
669
|
+
if (!created) throw Exception("Failed to create folder: $folderName (server returned false)")
|
|
670
|
+
Log.d(TAG, "Folder created: $folderName")
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
override fun imapDeleteFolder(folderName: String): Promise<Unit> {
|
|
675
|
+
return Promise.async {
|
|
676
|
+
val folder = ensureStoreConnected().getFolder(folderName)
|
|
677
|
+
if (!folder.exists()) {
|
|
678
|
+
Log.w(TAG, "Folder not found for deletion: $folderName")
|
|
679
|
+
return@async
|
|
680
|
+
}
|
|
681
|
+
folder.delete(false)
|
|
682
|
+
Log.d(TAG, "Folder deleted: $folderName")
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
override fun imapRenameFolder(oldName: String, newName: String): Promise<Unit> {
|
|
687
|
+
return Promise.async {
|
|
688
|
+
val s = ensureStoreConnected()
|
|
689
|
+
val folder = s.getFolder(oldName)
|
|
690
|
+
if (!folder.exists()) throw Exception("Folder not found: $oldName")
|
|
691
|
+
val renamed = folder.renameTo(s.getFolder(newName))
|
|
692
|
+
if (!renamed) throw Exception("Failed to rename folder: $oldName → $newName")
|
|
693
|
+
Log.d(TAG, "Folder renamed: $oldName → $newName")
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
override fun imapSubscribeFolder(folderName: String): Promise<Unit> {
|
|
698
|
+
return Promise.async { ensureStoreConnected().getFolder(folderName).setSubscribed(true) }
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
override fun imapUnsubscribeFolder(folderName: String): Promise<Unit> {
|
|
702
|
+
return Promise.async { ensureStoreConnected().getFolder(folderName).setSubscribed(false) }
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// ═══ IMAP — Fetch ══════════════════════════════════════════
|
|
706
|
+
|
|
707
|
+
override fun imapFetchEmails(options: FetchEmailsOptions): Promise<Array<EmailMessage>> {
|
|
708
|
+
return Promise.async {
|
|
709
|
+
val result = mutableListOf<EmailMessage>()
|
|
710
|
+
|
|
711
|
+
fun doFetch(): Array<Message> {
|
|
712
|
+
// 若指定了 mailbox,先切换到该邮箱
|
|
713
|
+
if (options.mailbox != null && options.mailbox != selectedMailbox) {
|
|
714
|
+
val store = ensureStoreConnected()
|
|
715
|
+
val raw = store.getFolder(options.mailbox) ?: throw Exception("Mailbox not found: ${options.mailbox}")
|
|
716
|
+
val f = raw as? IMAPFolder ?: throw Exception("Not an IMAP folder: ${options.mailbox}")
|
|
717
|
+
if (!f.isOpen()) {
|
|
718
|
+
try {
|
|
719
|
+
f.doCommand { protocol ->
|
|
720
|
+
protocol.id(buildIdMap(imapConfig?.username))
|
|
721
|
+
null
|
|
722
|
+
}
|
|
723
|
+
} catch (_: Exception) {}
|
|
724
|
+
f.open(Folder.READ_WRITE)
|
|
725
|
+
}
|
|
726
|
+
imapFolder = f; selectedMailbox = options.mailbox
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
val folder = ensureFolderOpen()
|
|
730
|
+
|
|
731
|
+
// ── 时间范围过滤:使用 ReceivedDateTerm 搜索 ──────────
|
|
732
|
+
val sinceDate = parseDateString(options.since)
|
|
733
|
+
val beforeDate = parseDateString(options.before)
|
|
734
|
+
val hasTimeRange = sinceDate != null || beforeDate != null
|
|
735
|
+
|
|
736
|
+
if (hasTimeRange) {
|
|
737
|
+
val dateTerms = mutableListOf<ReceivedDateTerm>()
|
|
738
|
+
sinceDate?.let { dateTerms.add(ReceivedDateTerm(ReceivedDateTerm.GE, it)) }
|
|
739
|
+
beforeDate?.let { dateTerms.add(ReceivedDateTerm(ReceivedDateTerm.LT, it)) }
|
|
740
|
+
val dateSearchTerm = if (dateTerms.size == 1) dateTerms[0] else jakarta.mail.search.AndTerm(dateTerms.toTypedArray())
|
|
741
|
+
val allMsgs = folder.search(dateSearchTerm)
|
|
742
|
+
// 时间范围内支持分页
|
|
743
|
+
return if (options.page != null) {
|
|
744
|
+
val page = options.page!!.toInt()
|
|
745
|
+
val size = (options.pageSize ?: 20.0).toInt().coerceIn(1, MAX_FETCH_COUNT)
|
|
746
|
+
val startIdx = (page - 1) * size
|
|
747
|
+
val endIdx = minOf(startIdx + size, allMsgs.size)
|
|
748
|
+
if (startIdx >= allMsgs.size) emptyArray()
|
|
749
|
+
else allMsgs.sliceArray(startIdx until endIdx).sortedByDescending { it.receivedDate?.time ?: 0L }.toTypedArray()
|
|
750
|
+
} else {
|
|
751
|
+
allMsgs.sortedByDescending { it.receivedDate?.time ?: 0L }.take(20).toTypedArray()
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// ── 无时间过滤:使用原有的范围/分页/默认逻辑 ──────────
|
|
756
|
+
return when {
|
|
757
|
+
// UID 范围模式
|
|
758
|
+
options.from != null && options.to != null && options.rangeType == "uid" -> {
|
|
759
|
+
val startUid = options.from!!.toLong()
|
|
760
|
+
val endUid = options.to!!.toLong()
|
|
761
|
+
val uidMsgs = folder.getMessagesByUID(startUid, endUid)
|
|
762
|
+
if (uidMsgs.size > MAX_FETCH_COUNT) uidMsgs.sliceArray(0 until MAX_FETCH_COUNT) else uidMsgs
|
|
763
|
+
}
|
|
764
|
+
// 序列号范围模式
|
|
765
|
+
options.from != null && options.to != null -> {
|
|
766
|
+
val start = options.from!!.toInt()
|
|
767
|
+
val end = options.to!!.toInt()
|
|
768
|
+
if (end - start + 1 > MAX_FETCH_COUNT) throw Exception("Fetch range too large (max $MAX_FETCH_COUNT per request)")
|
|
769
|
+
folder.getMessages(start, end)
|
|
770
|
+
}
|
|
771
|
+
// 分页模式
|
|
772
|
+
options.page != null -> {
|
|
773
|
+
val page = options.page!!.toInt()
|
|
774
|
+
val size = (options.pageSize ?: 20.0).toInt().coerceIn(1, MAX_FETCH_COUNT)
|
|
775
|
+
val allMsgs = folder.messages
|
|
776
|
+
val totalMsgs = allMsgs.size
|
|
777
|
+
val endIdx = totalMsgs - (page - 1) * size - 1
|
|
778
|
+
val startIdx = endIdx - size + 1
|
|
779
|
+
val s = startIdx.coerceAtLeast(0)
|
|
780
|
+
val e = endIdx.coerceAtLeast(0)
|
|
781
|
+
if (s > e) emptyArray() else allMsgs.sliceArray(s..e)
|
|
782
|
+
}
|
|
783
|
+
// 默认返回最近 20 封
|
|
784
|
+
else -> {
|
|
785
|
+
val total = folder.messageCount
|
|
786
|
+
val start = (total - 20).coerceAtLeast(1)
|
|
787
|
+
folder.getMessages(start, total)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
val msgs = try {
|
|
793
|
+
doFetch()
|
|
794
|
+
} catch (e: jakarta.mail.FolderClosedException) {
|
|
795
|
+
Log.w(TAG, "FolderClosedException in imapFetchEmails, re-opening and retrying: ${e.message}")
|
|
796
|
+
doFetch()
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
try {
|
|
800
|
+
msgs.forEach { result.add(emailToEmailMessage(it, ensureFolderOpen())) }
|
|
801
|
+
} catch (e: Exception) {
|
|
802
|
+
Log.e(TAG, "Fetch emails failed", e)
|
|
803
|
+
}
|
|
804
|
+
result.toTypedArray()
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
override fun imapFetchEmailByUID(uid: Double): Promise<Variant_NullType_EmailMessage> {
|
|
809
|
+
return Promise.async {
|
|
810
|
+
try {
|
|
811
|
+
val folder = ensureFolderOpen()
|
|
812
|
+
val msg = folder.getMessageByUID(uid.toLong())
|
|
813
|
+
if (msg != null) {
|
|
814
|
+
return@async Variant_NullType_EmailMessage.create(emailToEmailMessage(msg, folder))
|
|
815
|
+
}
|
|
816
|
+
} catch (e: Exception) {
|
|
817
|
+
Log.e(TAG, "Fetch by UID failed", e)
|
|
818
|
+
}
|
|
819
|
+
Variant_NullType_EmailMessage.create(com.margelo.nitro.core.NullType.NULL)
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// ═══ IMAP — Search ═════════════════════════════════════════
|
|
824
|
+
|
|
825
|
+
override fun imapSearchEmails(criteria: SearchCriteria): Promise<Array<EmailMessage>> {
|
|
826
|
+
return Promise.async {
|
|
827
|
+
val result = mutableListOf<EmailMessage>()
|
|
828
|
+
|
|
829
|
+
try {
|
|
830
|
+
// ── 若指定了 mailbox,先切换到该邮箱(略过已选中的)─
|
|
831
|
+
if (criteria.mailbox != null && criteria.mailbox != selectedMailbox) {
|
|
832
|
+
val store = ensureStoreConnected()
|
|
833
|
+
val raw = store.getFolder(criteria.mailbox) ?: throw Exception("Mailbox not found: ${criteria.mailbox}")
|
|
834
|
+
val f = raw as? IMAPFolder ?: throw Exception("Not an IMAP folder: ${criteria.mailbox}")
|
|
835
|
+
if (!f.isOpen()) {
|
|
836
|
+
try {
|
|
837
|
+
f.doCommand { protocol ->
|
|
838
|
+
protocol.id(buildIdMap(imapConfig?.username))
|
|
839
|
+
null
|
|
840
|
+
}
|
|
841
|
+
} catch (_: Exception) {}
|
|
842
|
+
f.open(Folder.READ_WRITE)
|
|
843
|
+
}
|
|
844
|
+
imapFolder = f; selectedMailbox = criteria.mailbox
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
val folder = ensureFolderOpen()
|
|
848
|
+
|
|
849
|
+
// ── 处理 UIDs 特例:直接按 UID 列表获取,忽略其他条件 ──
|
|
850
|
+
if (criteria.uids != null && criteria.uids.isNotEmpty()) {
|
|
851
|
+
val uidList = criteria.uids.map { it.toLong() }
|
|
852
|
+
val msgs = uidList.mapNotNull { uid ->
|
|
853
|
+
try { folder.getMessageByUID(uid) } catch (_: Exception) { null }
|
|
854
|
+
}
|
|
855
|
+
val limit = minOf(criteria.limit?.toInt() ?: msgs.size, msgs.size, MAX_FETCH_COUNT)
|
|
856
|
+
val sorted = msgs.sortedByDescending { it.receivedDate?.time ?: 0L }.take(limit)
|
|
857
|
+
sorted.forEach { result.add(emailToEmailMessage(it, folder)) }
|
|
858
|
+
return@async result.toTypedArray()
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
val terms = ArrayList<SearchTerm>()
|
|
862
|
+
|
|
863
|
+
// ── 基本文本搜索 ─────────────────────────────────────
|
|
864
|
+
criteria.subject?.let { terms.add(SubjectTerm(it)) }
|
|
865
|
+
criteria.from?.let { terms.add(FromStringTerm(it)) }
|
|
866
|
+
criteria.to?.let { terms.add(RecipientStringTerm(Message.RecipientType.TO, it)) }
|
|
867
|
+
criteria.body?.let { terms.add(BodyTerm(it)) }
|
|
868
|
+
// text: 同时搜索 subject 和 body
|
|
869
|
+
criteria.text?.let { terms.add(jakarta.mail.search.OrTerm(SubjectTerm(it), BodyTerm(it))) }
|
|
870
|
+
|
|
871
|
+
// ── 时间范围搜索 ─────────────────────────────────────
|
|
872
|
+
val sinceDate = parseDateString(criteria.since)
|
|
873
|
+
val beforeDate = parseDateString(criteria.before)
|
|
874
|
+
val onDate = parseDateString(criteria.on)
|
|
875
|
+
if (sinceDate != null) terms.add(ReceivedDateTerm(ReceivedDateTerm.GE, sinceDate))
|
|
876
|
+
if (beforeDate != null) terms.add(ReceivedDateTerm(ReceivedDateTerm.LT, beforeDate))
|
|
877
|
+
if (onDate != null) terms.add(ReceivedDateTerm(ReceivedDateTerm.EQ, onDate))
|
|
878
|
+
|
|
879
|
+
// 发送时间搜索
|
|
880
|
+
val sentSinceDate = parseDateString(criteria.sentSince)
|
|
881
|
+
val sentBeforeDate = parseDateString(criteria.sentBefore)
|
|
882
|
+
if (sentSinceDate != null) terms.add(SentDateTerm(SentDateTerm.GE, sentSinceDate))
|
|
883
|
+
if (sentBeforeDate != null) terms.add(SentDateTerm(SentDateTerm.LT, sentBeforeDate))
|
|
884
|
+
|
|
885
|
+
// ── 标志搜索 ─────────────────────────────────────────
|
|
886
|
+
if (criteria.seen != null) terms.add(FlagTerm(Flags().apply { add(Flags.Flag.SEEN) }, criteria.seen))
|
|
887
|
+
if (criteria.flagged != null) terms.add(FlagTerm(Flags().apply { add(Flags.Flag.FLAGGED) }, criteria.flagged))
|
|
888
|
+
if (criteria.answered != null) terms.add(FlagTerm(Flags().apply { add(Flags.Flag.ANSWERED) }, criteria.answered))
|
|
889
|
+
if (criteria.draft != null) terms.add(FlagTerm(Flags().apply { add(Flags.Flag.DRAFT) }, criteria.draft))
|
|
890
|
+
if (criteria.deleted != null) terms.add(FlagTerm(Flags().apply { add(Flags.Flag.DELETED) }, criteria.deleted))
|
|
891
|
+
if (criteria.junk != null) terms.add(FlagTerm(Flags().apply { add("Junk") }, criteria.junk))
|
|
892
|
+
|
|
893
|
+
// ── 邮件头搜索 ──────────────────────────────────────
|
|
894
|
+
criteria.header?.let { terms.add(HeaderTerm(it.key, it.value)) }
|
|
895
|
+
|
|
896
|
+
// ── 大小搜索 ─────────────────────────────────────────
|
|
897
|
+
criteria.larger?.let { terms.add(SizeTerm(SizeTerm.GT, it.toInt())) }
|
|
898
|
+
criteria.smaller?.let { terms.add(SizeTerm(SizeTerm.LT, it.toInt())) }
|
|
899
|
+
|
|
900
|
+
// ── 执行搜索 ─────────────────────────────────────────
|
|
901
|
+
val st = when (terms.size) { 0 -> null; 1 -> terms[0]; else -> AndTerm(terms.toTypedArray()) }
|
|
902
|
+
var rawMsgs = (st?.let { folder.search(it) } ?: folder.messages).toList()
|
|
903
|
+
|
|
904
|
+
// ── hasAttachment 过滤(IMAP 搜索不直接支持,需后过滤)─
|
|
905
|
+
if (criteria.hasAttachment == true) {
|
|
906
|
+
rawMsgs = rawMsgs.filter { msg ->
|
|
907
|
+
val ct = (msg as? MimeMessage)?.contentType ?: ""
|
|
908
|
+
ct.startsWith("multipart/mixed", ignoreCase = true) ||
|
|
909
|
+
ct.startsWith("multipart/related", ignoreCase = true) ||
|
|
910
|
+
ct.startsWith("multipart/signed", ignoreCase = true)
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// ── 排序 ─────────────────────────────────────────────
|
|
915
|
+
val isAsc = criteria.sortOrder == "asc"
|
|
916
|
+
val sortedMsgs = if (isAsc) rawMsgs.sortedBy { it.receivedDate?.time ?: 0L }
|
|
917
|
+
else rawMsgs.sortedByDescending { it.receivedDate?.time ?: 0L }
|
|
918
|
+
|
|
919
|
+
val limit = minOf(criteria.limit?.toInt() ?: sortedMsgs.size, sortedMsgs.size, MAX_FETCH_COUNT)
|
|
920
|
+
for (i in 0 until limit) {
|
|
921
|
+
result.add(emailToEmailMessage(sortedMsgs[i], folder))
|
|
922
|
+
}
|
|
923
|
+
} catch (e: Exception) {
|
|
924
|
+
Log.e(TAG, "Search failed", e)
|
|
925
|
+
}
|
|
926
|
+
result.toTypedArray()
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// ═══ IMAP — Flags ══════════════════════════════════════════
|
|
931
|
+
|
|
932
|
+
override fun imapMarkAsRead(uids: DoubleArray, silent: Boolean): Promise<Unit> {
|
|
933
|
+
return Promise.async {
|
|
934
|
+
val folder = ensureFolderOpen()
|
|
935
|
+
uids.forEach { uid ->
|
|
936
|
+
try {
|
|
937
|
+
val msg = folder.getMessageByUID(uid.toLong()) ?: return@forEach
|
|
938
|
+
// silent: 仅设置 flags,不触发 FolderListener 事件
|
|
939
|
+
// 非 silent: 设置 flags 并等待服务器确认
|
|
940
|
+
msg.setFlag(Flags.Flag.SEEN, true)
|
|
941
|
+
if (!silent) {
|
|
942
|
+
// 非 silent 模式下强制同步到服务器
|
|
943
|
+
folder.fetch(arrayOf(msg), FetchProfile().apply { add(FetchProfile.Item.FLAGS) })
|
|
944
|
+
}
|
|
945
|
+
} catch (_: Exception) {}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
override fun imapMarkAsFlagged(uids: DoubleArray, flagged: Boolean): Promise<Unit> {
|
|
951
|
+
return Promise.async {
|
|
952
|
+
val folder = ensureFolderOpen()
|
|
953
|
+
uids.forEach { uid ->
|
|
954
|
+
try {
|
|
955
|
+
val msg = folder.getMessageByUID(uid.toLong()) ?: return@forEach
|
|
956
|
+
if (flagged) msg.setFlags(Flags(Flags.Flag.FLAGGED), true)
|
|
957
|
+
else msg.setFlags(Flags(Flags.Flag.FLAGGED), false)
|
|
958
|
+
} catch (_: Exception) {}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// ═══ IMAP — Move / Copy / Delete ═══════════════════════════
|
|
964
|
+
|
|
965
|
+
override fun imapMoveEmails(uids: DoubleArray, destinationMailbox: String): Promise<Unit> {
|
|
966
|
+
return Promise.async {
|
|
967
|
+
val folder = ensureFolderOpen()
|
|
968
|
+
val store = ensureStoreConnected()
|
|
969
|
+
|
|
970
|
+
val msgs = uids.map { uid ->
|
|
971
|
+
try { folder.getMessageByUID(uid.toLong()) } catch (_: Exception) { null }
|
|
972
|
+
}.filterNotNull().toTypedArray()
|
|
973
|
+
if (msgs.isEmpty()) return@async
|
|
974
|
+
|
|
975
|
+
var lastError: Exception? = null
|
|
976
|
+
for (attempt in 0 until 3) {
|
|
977
|
+
val dest = store.getFolder(destinationMailbox)
|
|
978
|
+
if (!dest.exists()) {
|
|
979
|
+
Log.w(TAG, "Move: destination folder does not exist, creating: $destinationMailbox")
|
|
980
|
+
val created = dest.create(Folder.HOLDS_MESSAGES)
|
|
981
|
+
if (!created) throw Exception("Failed to create destination folder: $destinationMailbox (server returned false)")
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
// 先复制到目标文件夹
|
|
985
|
+
folder.copyMessages(msgs, dest)
|
|
986
|
+
lastError = null
|
|
987
|
+
// 复制成功后,在原文件夹标记删除并 expunge
|
|
988
|
+
try {
|
|
989
|
+
msgs.forEach { it.setFlags(Flags(Flags.Flag.DELETED), true) }
|
|
990
|
+
ensureFolderOpen().expunge()
|
|
991
|
+
} catch (e: Exception) {
|
|
992
|
+
Log.e(TAG, "Move: expunge after copy failed: ${e.message}")
|
|
993
|
+
}
|
|
994
|
+
break // 成功
|
|
995
|
+
} catch (e: jakarta.mail.FolderNotFoundException) {
|
|
996
|
+
lastError = e
|
|
997
|
+
Log.w(TAG, "Move: folder not found (attempt $attempt), re-creating destination: ${e.message}")
|
|
998
|
+
try { dest.delete(false) } catch (_: Exception) {}
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
lastError?.let { throw it }
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
override fun imapCopyEmails(uids: DoubleArray, destinationMailbox: String): Promise<Unit> {
|
|
1006
|
+
return Promise.async {
|
|
1007
|
+
val folder = ensureFolderOpen()
|
|
1008
|
+
val store = ensureStoreConnected()
|
|
1009
|
+
|
|
1010
|
+
val msgs = uids.map { uid ->
|
|
1011
|
+
try { folder.getMessageByUID(uid.toLong()) } catch (_: Exception) { null }
|
|
1012
|
+
}.filterNotNull().toTypedArray()
|
|
1013
|
+
if (msgs.isEmpty()) return@async
|
|
1014
|
+
|
|
1015
|
+
// 163 邮箱等 IMAP 服务器可能在文件夹创建后仍返回 [TRYCREATE],
|
|
1016
|
+
// 此时需要删除并重新创建目标文件夹再重试
|
|
1017
|
+
var lastError: Exception? = null
|
|
1018
|
+
for (attempt in 0 until 3) {
|
|
1019
|
+
val dest = store.getFolder(destinationMailbox)
|
|
1020
|
+
if (!dest.exists()) {
|
|
1021
|
+
Log.w(TAG, "Copy: destination folder does not exist, creating: $destinationMailbox")
|
|
1022
|
+
val created = dest.create(Folder.HOLDS_MESSAGES)
|
|
1023
|
+
if (!created) throw Exception("Failed to create destination folder: $destinationMailbox (server returned false)")
|
|
1024
|
+
}
|
|
1025
|
+
try {
|
|
1026
|
+
folder.copyMessages(msgs, dest)
|
|
1027
|
+
lastError = null
|
|
1028
|
+
break // 成功
|
|
1029
|
+
} catch (e: jakarta.mail.FolderNotFoundException) {
|
|
1030
|
+
lastError = e
|
|
1031
|
+
Log.w(TAG, "Copy: folder not found (attempt $attempt), re-creating destination: ${e.message}")
|
|
1032
|
+
// 删除旧的文件夹引用,下次循环用新的 getFolder() 获取
|
|
1033
|
+
try { dest.delete(false) } catch (_: Exception) {}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
lastError?.let { throw it }
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
override fun imapDeleteEmails(uids: DoubleArray): Promise<DeleteResult> {
|
|
1041
|
+
return Promise.async {
|
|
1042
|
+
val success = mutableListOf<Double>()
|
|
1043
|
+
val failed = mutableListOf<Double>()
|
|
1044
|
+
uids.forEach { uid ->
|
|
1045
|
+
var retried = false
|
|
1046
|
+
while (true) {
|
|
1047
|
+
try {
|
|
1048
|
+
val folder = ensureFolderOpen()
|
|
1049
|
+
val msg = folder.getMessageByUID(uid.toLong())
|
|
1050
|
+
if (msg != null) {
|
|
1051
|
+
msg.setFlags(Flags(Flags.Flag.DELETED), true)
|
|
1052
|
+
success.add(uid)
|
|
1053
|
+
Log.d(TAG, "Deleted UID=$uid")
|
|
1054
|
+
} else {
|
|
1055
|
+
Log.w(TAG, "Delete failed: message UID=$uid not found (already deleted?)")
|
|
1056
|
+
failed.add(uid)
|
|
1057
|
+
}
|
|
1058
|
+
break
|
|
1059
|
+
} catch (e: FolderClosedException) {
|
|
1060
|
+
if (!retried) {
|
|
1061
|
+
Log.w(TAG, "Folder closed during delete UID=$uid, re-opening and retrying...")
|
|
1062
|
+
retried = true
|
|
1063
|
+
continue
|
|
1064
|
+
}
|
|
1065
|
+
Log.e(TAG, "Delete failed for UID=$uid after retry: ${e.message}")
|
|
1066
|
+
failed.add(uid)
|
|
1067
|
+
break
|
|
1068
|
+
} catch (e: Exception) {
|
|
1069
|
+
Log.e(TAG, "Delete failed for UID=$uid: ${e.message}")
|
|
1070
|
+
failed.add(uid)
|
|
1071
|
+
break
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
// 对成功标记删除的邮件执行 EXPUNGE 彻底清除
|
|
1076
|
+
if (success.isNotEmpty()) {
|
|
1077
|
+
try {
|
|
1078
|
+
ensureFolderOpen().expunge()
|
|
1079
|
+
Log.d(TAG, "Expunge completed after deleting ${success.size} messages")
|
|
1080
|
+
} catch (e: Exception) {
|
|
1081
|
+
Log.e(TAG, "Expunge after delete failed: ${e.message}")
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
DeleteResult(
|
|
1085
|
+
success = success.toDoubleArray(),
|
|
1086
|
+
failed = failed.toDoubleArray(),
|
|
1087
|
+
error = if (failed.isNotEmpty()) "${failed.size} messages failed" else null
|
|
1088
|
+
)
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
override fun imapExpunge(): Promise<Unit> {
|
|
1093
|
+
return Promise.async {
|
|
1094
|
+
val folder = ensureFolderOpen()
|
|
1095
|
+
folder.expunge()
|
|
1096
|
+
Log.d(TAG, "Expunge completed")
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// ═══ IMAP — Attachments ════════════════════════════════════
|
|
1101
|
+
|
|
1102
|
+
override fun imapFetchAttachments(uid: Double, partId: String): Promise<Variant_NullType_AttachmentInfo> {
|
|
1103
|
+
return Promise.async {
|
|
1104
|
+
try {
|
|
1105
|
+
val folder = ensureFolderOpen()
|
|
1106
|
+
val msg = folder.getMessageByUID(uid.toLong()) ?: return@async Variant_NullType_AttachmentInfo.create(com.margelo.nitro.core.NullType.NULL)
|
|
1107
|
+
val result = findAttachmentByPartId(msg, partId)
|
|
1108
|
+
if (result != null) {
|
|
1109
|
+
return@async Variant_NullType_AttachmentInfo.create(result)
|
|
1110
|
+
}
|
|
1111
|
+
} catch (e: Exception) {
|
|
1112
|
+
Log.e(TAG, "Fetch attachment failed", e)
|
|
1113
|
+
}
|
|
1114
|
+
Variant_NullType_AttachmentInfo.create(com.margelo.nitro.core.NullType.NULL)
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
override fun imapFetchAllAttachments(uid: Double): Promise<Array<AttachmentInfo>> {
|
|
1119
|
+
return Promise.async {
|
|
1120
|
+
val result = mutableListOf<AttachmentInfo>()
|
|
1121
|
+
try {
|
|
1122
|
+
val folder = ensureFolderOpen()
|
|
1123
|
+
val msg = folder.getMessageByUID(uid.toLong()) ?: return@async result.toTypedArray()
|
|
1124
|
+
collectAttachments(msg, result)
|
|
1125
|
+
} catch (e: Exception) {
|
|
1126
|
+
Log.e(TAG, "Fetch all attachments failed", e)
|
|
1127
|
+
}
|
|
1128
|
+
result.toTypedArray()
|
|
1129
|
+
}
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
@Suppress("UNCHECKED_CAST")
|
|
1133
|
+
private fun findAttachmentByPartId(msg: Message, partId: String): AttachmentInfo? {
|
|
1134
|
+
val content = msg.content
|
|
1135
|
+
if (content is Multipart) {
|
|
1136
|
+
for (i in 0 until content.count) {
|
|
1137
|
+
val part = content.getBodyPart(i)
|
|
1138
|
+
val disp = part.disposition ?: Part.ATTACHMENT
|
|
1139
|
+
if (disp.equals(Part.ATTACHMENT, ignoreCase = true) || disp.equals(Part.INLINE, ignoreCase = true)) {
|
|
1140
|
+
val fileName = part.fileName ?: "attachment_$i"
|
|
1141
|
+
if (fileName == partId || i.toString() == partId) {
|
|
1142
|
+
return readAttachmentData(part, fileName, (part as? MimeBodyPart)?.contentID,
|
|
1143
|
+
disp.equals(Part.INLINE, ignoreCase = true), disp)
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
if (part.content is Multipart) {
|
|
1147
|
+
@Suppress("UNCHECKED_CAST")
|
|
1148
|
+
return findAttachmentByPartId(part as Message, partId)
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
return null
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
@Suppress("UNCHECKED_CAST")
|
|
1156
|
+
private fun collectAttachments(msg: Message, result: MutableList<AttachmentInfo>) {
|
|
1157
|
+
val content = msg.content
|
|
1158
|
+
if (content is Multipart) {
|
|
1159
|
+
for (i in 0 until content.count) {
|
|
1160
|
+
val part = content.getBodyPart(i)
|
|
1161
|
+
val disp = part.disposition ?: Part.ATTACHMENT
|
|
1162
|
+
if (disp.equals(Part.ATTACHMENT, ignoreCase = true) || disp.equals(Part.INLINE, ignoreCase = true)) {
|
|
1163
|
+
result.add(readAttachmentData(part, part.fileName ?: "attachment_$i",
|
|
1164
|
+
(part as? MimeBodyPart)?.contentID,
|
|
1165
|
+
disp.equals(Part.INLINE, ignoreCase = true), disp))
|
|
1166
|
+
}
|
|
1167
|
+
if (part.content is Multipart) {
|
|
1168
|
+
@Suppress("UNCHECKED_CAST")
|
|
1169
|
+
collectAttachments(part as Message, result)
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
// ═══ IMAP — IDLE ═══════════════════════════════════════════
|
|
1176
|
+
|
|
1177
|
+
private val idleDispatcher = CoroutineScope(Executors.newSingleThreadExecutor().asCoroutineDispatcher() + SupervisorJob())
|
|
1178
|
+
|
|
1179
|
+
override fun imapIdle(callback: (IdleNotification) -> Unit): Promise<Unit> {
|
|
1180
|
+
return Promise.async(idleDispatcher) {
|
|
1181
|
+
if (isIdling) throw Exception("ALREADY_IDLE")
|
|
1182
|
+
// 确保文件夹已打开,避免进入循环后因空指针退出
|
|
1183
|
+
val folder = imapFolder ?: throw Exception("No mailbox selected. Call imapSelectMailbox first.")
|
|
1184
|
+
isIdling = true
|
|
1185
|
+
try {
|
|
1186
|
+
while (isIdling) {
|
|
1187
|
+
try {
|
|
1188
|
+
folder.idle()
|
|
1189
|
+
if (!isIdling) break
|
|
1190
|
+
callback(
|
|
1191
|
+
IdleNotification(
|
|
1192
|
+
type = "newMessage",
|
|
1193
|
+
mailbox = selectedMailbox ?: "",
|
|
1194
|
+
uid = null, flag = null, messageCount = null,
|
|
1195
|
+
timestamp = System.currentTimeMillis().toDouble()
|
|
1196
|
+
)
|
|
1197
|
+
)
|
|
1198
|
+
} catch (e: jakarta.mail.FolderClosedException) {
|
|
1199
|
+
break
|
|
1200
|
+
} catch (_: Exception) {
|
|
1201
|
+
if (!isIdling) break
|
|
1202
|
+
callback(
|
|
1203
|
+
IdleNotification(
|
|
1204
|
+
type = "connectionLost",
|
|
1205
|
+
mailbox = selectedMailbox ?: "",
|
|
1206
|
+
uid = null, flag = null, messageCount = null,
|
|
1207
|
+
timestamp = System.currentTimeMillis().toDouble()
|
|
1208
|
+
)
|
|
1209
|
+
)
|
|
1210
|
+
break
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
} catch (_: Exception) {}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
override fun imapStopIdle(): Promise<Unit> {
|
|
1218
|
+
return Promise.async {
|
|
1219
|
+
isIdling = false
|
|
1220
|
+
// 发送 NOOP 唤醒 IDLE 循环
|
|
1221
|
+
try {
|
|
1222
|
+
ensureFolderOpen().doCommand { protocol ->
|
|
1223
|
+
protocol.simpleCommand("NOOP", null)
|
|
1224
|
+
}
|
|
1225
|
+
} catch (_: Exception) {}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// ═══ IMAP — Quota ══════════════════════════════════════════
|
|
1230
|
+
|
|
1231
|
+
override fun imapGetQuota(root: String?): Promise<QuotaInfo> {
|
|
1232
|
+
return Promise.async {
|
|
1233
|
+
try {
|
|
1234
|
+
val quotas = ensureStoreConnected().getQuota(root ?: "")
|
|
1235
|
+
val r = quotas.firstOrNull()?.resources?.firstOrNull()
|
|
1236
|
+
QuotaInfo(
|
|
1237
|
+
storageUsed = r?.usage?.toDouble() ?: 0.0,
|
|
1238
|
+
storageLimit = r?.limit?.toDouble() ?: -1.0,
|
|
1239
|
+
storageUnit = "KB",
|
|
1240
|
+
messagesUsed = null, messagesLimit = null,
|
|
1241
|
+
root = root ?: ""
|
|
1242
|
+
)
|
|
1243
|
+
} catch (_: Exception) {
|
|
1244
|
+
QuotaInfo(storageUsed = 0.0, storageLimit = -1.0, storageUnit = "KB",
|
|
1245
|
+
messagesUsed = null, messagesLimit = null, root = root ?: "")
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// ═══ IMAP — Append ═════════════════════════════════════════
|
|
1251
|
+
|
|
1252
|
+
override fun imapAppendMessage(mailbox: String, rawMimeData: ArrayBuffer, flags: Array<String>?): Promise<AppendResult> {
|
|
1253
|
+
// ⚠️ 在 JS 线程拷贝数据,避免后台线程访问 ArrayBuffer.size() 报错
|
|
1254
|
+
val mimeBytes = rawMimeData.toByteArray()
|
|
1255
|
+
return Promise.async {
|
|
1256
|
+
val store = ensureStoreConnected()
|
|
1257
|
+
|
|
1258
|
+
// ✅ 优先复用已打开的文件夹连接,避免触发 163 邮箱的 SELECT Unsafe Login
|
|
1259
|
+
val folder = if (mailbox == selectedMailbox && imapFolder?.isOpen() == true) {
|
|
1260
|
+
Log.d(TAG, "Append: reusing already-open folder: $mailbox")
|
|
1261
|
+
imapFolder!!
|
|
1262
|
+
} else {
|
|
1263
|
+
val rawFolder = store.getFolder(mailbox) ?: throw Exception("Folder not found: $mailbox")
|
|
1264
|
+
val f = rawFolder as? IMAPFolder
|
|
1265
|
+
?: throw Exception("Folder is not an IMAP folder: $mailbox")
|
|
1266
|
+
// 🔴 打开文件夹前必须先发送 ID 命令(网易邮箱要求),否则 SELECT 会返回 "Unsafe Login"
|
|
1267
|
+
if (!f.isOpen()) {
|
|
1268
|
+
try {
|
|
1269
|
+
f.doCommand { protocol ->
|
|
1270
|
+
protocol.id(buildIdMap(imapConfig?.username))
|
|
1271
|
+
null
|
|
1272
|
+
}
|
|
1273
|
+
} catch (_: Exception) {}
|
|
1274
|
+
f.open(Folder.READ_WRITE)
|
|
1275
|
+
Log.d(TAG, "Append: opened folder $mailbox for append")
|
|
1276
|
+
}
|
|
1277
|
+
f
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
val msg = MimeMessage(Session.getInstance(Properties()), mimeBytes.inputStream())
|
|
1281
|
+
val messageId = msg.messageID
|
|
1282
|
+
|
|
1283
|
+
// ✅ 记录 append 前的邮件数量,用于后续通过 sequence number 查找新邮件
|
|
1284
|
+
val countBefore = folder.messageCount
|
|
1285
|
+
Log.d(TAG, "Append: messageCount before append = $countBefore")
|
|
1286
|
+
|
|
1287
|
+
folder.appendMessages(arrayOf(msg))
|
|
1288
|
+
|
|
1289
|
+
// 强制刷新文件夹状态,使服务器分配 UID 后能正确反映到本地缓存
|
|
1290
|
+
folder.getMessageCount()
|
|
1291
|
+
|
|
1292
|
+
val countAfter = folder.messageCount
|
|
1293
|
+
Log.d(TAG, "Append: messageCount after append = $countAfter")
|
|
1294
|
+
|
|
1295
|
+
// 策略 1: 通过 Message-ID 搜索来精确匹配刚追加的邮件
|
|
1296
|
+
var uid = -1.0
|
|
1297
|
+
if (messageId != null) {
|
|
1298
|
+
try {
|
|
1299
|
+
val searchTerm = HeaderTerm("Message-ID", messageId)
|
|
1300
|
+
val found = folder.search(searchTerm)
|
|
1301
|
+
if (found.isNotEmpty()) {
|
|
1302
|
+
uid = try { folder.getUID(found[0]).toDouble() } catch (_: Exception) { -1.0 }
|
|
1303
|
+
}
|
|
1304
|
+
} catch (e: Exception) {
|
|
1305
|
+
Log.w(TAG, "Append: Message-ID search failed: ${e.message}")
|
|
1306
|
+
}
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// 策略 2: 通过 uidNext 推断(新邮件 UID = append 前的 uidNext)
|
|
1310
|
+
if (uid <= 0.0) {
|
|
1311
|
+
val uidNext = folder.uidNext
|
|
1312
|
+
if (uidNext > 0L) {
|
|
1313
|
+
uid = uidNext.toDouble() - 1.0
|
|
1314
|
+
Log.d(TAG, "Append: resolved UID via uidNext=$uidNext -> $uid")
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
// 策略 3: 通过 sequence number 获取(适用于服务器不支持 UIDNEXT 的情况)
|
|
1319
|
+
if (uid <= 0.0 && countAfter > countBefore) {
|
|
1320
|
+
try {
|
|
1321
|
+
// 新邮件应该是最后一封,用 sequence number 获取
|
|
1322
|
+
val lastMsg = folder.getMessage(countAfter)
|
|
1323
|
+
if (lastMsg != null) {
|
|
1324
|
+
uid = try { folder.getUID(lastMsg).toDouble() } catch (_: Exception) { -1.0 }
|
|
1325
|
+
Log.d(TAG, "Append: resolved UID via sequence number (msg $countAfter) -> $uid")
|
|
1326
|
+
}
|
|
1327
|
+
} catch (e: Exception) {
|
|
1328
|
+
Log.w(TAG, "Append: sequence number lookup failed: ${e.message}")
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (uid <= 0.0) {
|
|
1333
|
+
throw Exception("Append succeeded but could not determine UID (countBefore=$countBefore, countAfter=$countAfter, uidNext=${folder.uidNext}, messageId=$messageId)")
|
|
1334
|
+
}
|
|
1335
|
+
Log.d(TAG, "Append: resolved UID=$uid for messageId=$messageId")
|
|
1336
|
+
|
|
1337
|
+
if (flags != null && flags.isNotEmpty()) {
|
|
1338
|
+
try {
|
|
1339
|
+
val uidVal = uid.toLong()
|
|
1340
|
+
if (uidVal > 0L) {
|
|
1341
|
+
val stored = folder.getMessageByUID(uidVal)
|
|
1342
|
+
val jakartaFlags = Flags()
|
|
1343
|
+
flags.forEach { f ->
|
|
1344
|
+
when (f.lowercase()) {
|
|
1345
|
+
"seen" -> jakartaFlags.add(Flags.Flag.SEEN)
|
|
1346
|
+
"answered" -> jakartaFlags.add(Flags.Flag.ANSWERED)
|
|
1347
|
+
"flagged" -> jakartaFlags.add(Flags.Flag.FLAGGED)
|
|
1348
|
+
"deleted" -> jakartaFlags.add(Flags.Flag.DELETED)
|
|
1349
|
+
"draft" -> jakartaFlags.add(Flags.Flag.DRAFT)
|
|
1350
|
+
else -> jakartaFlags.add(f)
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (jakartaFlags.getSystemFlags().isNotEmpty() || jakartaFlags.getUserFlags().isNotEmpty()) stored?.setFlags(jakartaFlags, true)
|
|
1354
|
+
}
|
|
1355
|
+
} catch (_: Exception) {}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
AppendResult(
|
|
1359
|
+
uid = uid,
|
|
1360
|
+
uidValidity = folder.uidValidity.toDouble(),
|
|
1361
|
+
date = msg.receivedDate?.toISO8601() ?: ""
|
|
1362
|
+
)
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
// ═══════════════════════════════════════════════════════════
|
|
1367
|
+
// SMTP
|
|
1368
|
+
// ═══════════════════════════════════════════════════════════
|
|
1369
|
+
|
|
1370
|
+
override fun smtpConnect(config: SmtpConnectionConfig): Promise<ConnectionResult> {
|
|
1371
|
+
return Promise.async {
|
|
1372
|
+
try {
|
|
1373
|
+
val session = smtpSession(config)
|
|
1374
|
+
val transport = session.transport as? SMTPTransport
|
|
1375
|
+
?: return@async ConnectionResult(success = false, serverInfo = null, error = "Failed to create SMTP transport")
|
|
1376
|
+
transport.connect(config.username, config.password)
|
|
1377
|
+
smtpTransport = transport
|
|
1378
|
+
smtpSessionCache = session
|
|
1379
|
+
smtpConfig = config
|
|
1380
|
+
ConnectionResult(
|
|
1381
|
+
success = true,
|
|
1382
|
+
serverInfo = Variant_ImapServerInfo_SmtpServerInfo.create(
|
|
1383
|
+
SmtpServerInfo(
|
|
1384
|
+
serverName = config.host,
|
|
1385
|
+
capabilities = emptyArray(),
|
|
1386
|
+
supportedAuthMethods = arrayOf("LOGIN", "PLAIN")
|
|
1387
|
+
)
|
|
1388
|
+
),
|
|
1389
|
+
error = null
|
|
1390
|
+
)
|
|
1391
|
+
} catch (e: Exception) {
|
|
1392
|
+
Log.e(TAG, "SMTP connect failed", e)
|
|
1393
|
+
ConnectionResult(success = false, serverInfo = null, error = e.message)
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
override fun smtpDisconnect(): Promise<Unit> {
|
|
1399
|
+
return Promise.async {
|
|
1400
|
+
try { smtpTransport?.close() } catch (_: Exception) {}
|
|
1401
|
+
smtpTransport = null
|
|
1402
|
+
smtpSessionCache = null
|
|
1403
|
+
smtpConfig = null
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
override fun smtpSendMail(options: SendMailOptions): Promise<SendMailResult> {
|
|
1408
|
+
// ⚠️ 在 JS 线程预拷贝附件数据,避免后台线程访问 ArrayBuffer.size() 报错
|
|
1409
|
+
val preCopiedAttachments = options.attachments?.map { att ->
|
|
1410
|
+
att.data.toByteArray()
|
|
1411
|
+
}
|
|
1412
|
+
return Promise.async {
|
|
1413
|
+
val session = smtpSessionCache
|
|
1414
|
+
?: return@async SendMailResult(
|
|
1415
|
+
success = false, messageId = null, date = null,
|
|
1416
|
+
serverResponseCode = null, rawMimeData = null, queueId = null,
|
|
1417
|
+
error = "SMTP not configured. Call smtpConnect first.", retryable = true
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
// 即用即连:每次发送创建临时连接,避免长连接失效
|
|
1421
|
+
var tempTransport: SMTPTransport? = null
|
|
1422
|
+
try {
|
|
1423
|
+
val transport = session.transport as? SMTPTransport
|
|
1424
|
+
?: return@async SendMailResult(
|
|
1425
|
+
success = false, messageId = null, date = null,
|
|
1426
|
+
serverResponseCode = null, rawMimeData = null, queueId = null,
|
|
1427
|
+
error = "Failed to create SMTP transport", retryable = true
|
|
1428
|
+
)
|
|
1429
|
+
tempTransport = transport
|
|
1430
|
+
transport.connect(smtpConfig?.username, smtpConfig?.password)
|
|
1431
|
+
|
|
1432
|
+
val message = MimeMessage(session).apply {
|
|
1433
|
+
setFrom(InternetAddress(options.from.email, options.from.name))
|
|
1434
|
+
options.to.forEach { addRecipient(Message.RecipientType.TO, InternetAddress(it.email, it.name)) }
|
|
1435
|
+
options.cc?.forEach { addRecipient(Message.RecipientType.CC, InternetAddress(it.email, it.name)) }
|
|
1436
|
+
options.bcc?.forEach { addRecipient(Message.RecipientType.BCC, InternetAddress(it.email, it.name)) }
|
|
1437
|
+
options.replyTo?.let { setReplyTo(it.map { a -> InternetAddress(a.email, a.name) }.toTypedArray()) }
|
|
1438
|
+
setSubject(options.subject, "UTF-8")
|
|
1439
|
+
|
|
1440
|
+
val hasText = options.textBody != null
|
|
1441
|
+
val hasHtml = options.htmlBody != null
|
|
1442
|
+
val hasAttachments = options.attachments?.isNotEmpty() == true
|
|
1443
|
+
|
|
1444
|
+
when {
|
|
1445
|
+
hasAttachments -> {
|
|
1446
|
+
val mixed = MimeMultipart("mixed")
|
|
1447
|
+
val altWrapper = MimeBodyPart().apply {
|
|
1448
|
+
setContent(MimeMultipart("alternative").apply {
|
|
1449
|
+
if (hasText) addBodyPart(MimeBodyPart().apply { setText(options.textBody, "UTF-8") })
|
|
1450
|
+
if (hasHtml) addBodyPart(MimeBodyPart().apply { setContent(options.htmlBody, "text/html; charset=UTF-8") })
|
|
1451
|
+
})
|
|
1452
|
+
}
|
|
1453
|
+
mixed.addBodyPart(altWrapper)
|
|
1454
|
+
options.attachments!!.forEachIndexed { index, att ->
|
|
1455
|
+
val bp = MimeBodyPart().apply {
|
|
1456
|
+
fileName = MimeUtility.encodeText(att.filename, "UTF-8", null)
|
|
1457
|
+
disposition = if (att.inline) Part.INLINE else Part.ATTACHMENT
|
|
1458
|
+
if (att.contentId != null) setContentID(att.contentId)
|
|
1459
|
+
setDataHandler(jakarta.activation.DataHandler(
|
|
1460
|
+
object : jakarta.activation.DataSource {
|
|
1461
|
+
override fun getInputStream() = preCopiedAttachments!![index].inputStream()
|
|
1462
|
+
override fun getOutputStream() = throw UnsupportedOperationException()
|
|
1463
|
+
override fun getContentType() = att.mimeType
|
|
1464
|
+
override fun getName() = att.filename
|
|
1465
|
+
}
|
|
1466
|
+
))
|
|
1467
|
+
}
|
|
1468
|
+
mixed.addBodyPart(bp)
|
|
1469
|
+
}
|
|
1470
|
+
setContent(mixed)
|
|
1471
|
+
}
|
|
1472
|
+
hasHtml && hasText -> {
|
|
1473
|
+
setContent(MimeMultipart("alternative").apply {
|
|
1474
|
+
addBodyPart(MimeBodyPart().apply { setText(options.textBody, "UTF-8") })
|
|
1475
|
+
addBodyPart(MimeBodyPart().apply { setContent(options.htmlBody, "text/html; charset=UTF-8") })
|
|
1476
|
+
})
|
|
1477
|
+
}
|
|
1478
|
+
hasHtml -> setContent(options.htmlBody, "text/html; charset=UTF-8")
|
|
1479
|
+
else -> setText(options.textBody ?: "", "UTF-8")
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
options.headers?.forEach { (key, value) -> addHeader(key, value) }
|
|
1483
|
+
if (options.priority != null && options.priority != "normal") {
|
|
1484
|
+
when (options.priority) {
|
|
1485
|
+
"high" -> { addHeader("Priority", "urgent"); addHeader("X-Priority", "1") }
|
|
1486
|
+
"low" -> { addHeader("Priority", "non-urgent"); addHeader("X-Priority", "5") }
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
if (options.inReplyTo != null) setHeader("In-Reply-To", options.inReplyTo)
|
|
1490
|
+
if (options.references != null) setHeader("References", options.references)
|
|
1491
|
+
options.readReceiptTo?.let {
|
|
1492
|
+
setHeader("Disposition-Notification-To", "<${it.email}>")
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
transport.sendMessage(message, message.allRecipients)
|
|
1497
|
+
|
|
1498
|
+
val baos = ByteArrayOutputStream().let { message.writeTo(it); it }
|
|
1499
|
+
SendMailResult(
|
|
1500
|
+
success = true,
|
|
1501
|
+
messageId = message.messageID ?: "",
|
|
1502
|
+
date = message.sentDate?.toISO8601() ?: "",
|
|
1503
|
+
serverResponseCode = 250.0,
|
|
1504
|
+
rawMimeData = ArrayBuffer.copy(baos.toByteArray()),
|
|
1505
|
+
queueId = null, error = null, retryable = false
|
|
1506
|
+
)
|
|
1507
|
+
} catch (e: jakarta.mail.SendFailedException) {
|
|
1508
|
+
Log.e(TAG, "SMTP send partial failure", e)
|
|
1509
|
+
SendMailResult(
|
|
1510
|
+
success = false, messageId = null, date = null,
|
|
1511
|
+
serverResponseCode = e.invalidAddresses?.firstOrNull()?.let { 550.0 },
|
|
1512
|
+
rawMimeData = null, queueId = null,
|
|
1513
|
+
error = "Send failed: ${e.message}", retryable = false
|
|
1514
|
+
)
|
|
1515
|
+
} catch (e: Exception) {
|
|
1516
|
+
Log.e(TAG, "SMTP send failed", e)
|
|
1517
|
+
SendMailResult(
|
|
1518
|
+
success = false, messageId = null, date = null,
|
|
1519
|
+
serverResponseCode = null, rawMimeData = null, queueId = null,
|
|
1520
|
+
error = e.message, retryable = true
|
|
1521
|
+
)
|
|
1522
|
+
} finally {
|
|
1523
|
+
try { tempTransport?.close() } catch (_: Exception) {}
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
override fun smtpVerifyConnection(): Promise<Boolean> {
|
|
1529
|
+
return Promise.async {
|
|
1530
|
+
try { smtpTransport?.isConnected == true } catch (_: Exception) { false }
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
}
|