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.
Files changed (201) hide show
  1. package/EmailImapSmtp.podspec +29 -0
  2. package/LICENSE +20 -0
  3. package/README.md +121 -0
  4. package/android/CMakeLists.txt +24 -0
  5. package/android/build.gradle +117 -0
  6. package/android/src/main/AndroidManifest.xml +3 -0
  7. package/android/src/main/cpp/cpp-adapter.cpp +11 -0
  8. package/android/src/main/java/com/margelo/nitro/emailimapsmtp/EmailImapSmtp.kt +1533 -0
  9. package/android/src/main/java/com/margelo/nitro/emailimapsmtp/EmailImapSmtpPackage.kt +22 -0
  10. package/ios/EmailImapSmtp.swift +5 -0
  11. package/lib/module/EmailImapSmtp.nitro.js +4 -0
  12. package/lib/module/EmailImapSmtp.nitro.js.map +1 -0
  13. package/lib/module/NativeEmailImapSmtp.js +14 -0
  14. package/lib/module/NativeEmailImapSmtp.js.map +1 -0
  15. package/lib/module/index.js +64 -0
  16. package/lib/module/index.js.map +1 -0
  17. package/lib/module/native.js +227 -0
  18. package/lib/module/native.js.map +1 -0
  19. package/lib/module/package.json +1 -0
  20. package/lib/module/types.js +2 -0
  21. package/lib/module/types.js.map +1 -0
  22. package/lib/module/web.js +107 -0
  23. package/lib/module/web.js.map +1 -0
  24. package/lib/module/windows.js +156 -0
  25. package/lib/module/windows.js.map +1 -0
  26. package/lib/typescript/package.json +1 -0
  27. package/lib/typescript/src/EmailImapSmtp.nitro.d.ts +268 -0
  28. package/lib/typescript/src/EmailImapSmtp.nitro.d.ts.map +1 -0
  29. package/lib/typescript/src/NativeEmailImapSmtp.d.ts +36 -0
  30. package/lib/typescript/src/NativeEmailImapSmtp.d.ts.map +1 -0
  31. package/lib/typescript/src/index.d.ts +33 -0
  32. package/lib/typescript/src/index.d.ts.map +1 -0
  33. package/lib/typescript/src/native.d.ts +32 -0
  34. package/lib/typescript/src/native.d.ts.map +1 -0
  35. package/lib/typescript/src/types.d.ts +261 -0
  36. package/lib/typescript/src/types.d.ts.map +1 -0
  37. package/lib/typescript/src/web.d.ts +32 -0
  38. package/lib/typescript/src/web.d.ts.map +1 -0
  39. package/lib/typescript/src/windows.d.ts +32 -0
  40. package/lib/typescript/src/windows.d.ts.map +1 -0
  41. package/nitro.json +23 -0
  42. package/nitrogen/generated/android/c++/JAppendResult.hpp +65 -0
  43. package/nitrogen/generated/android/c++/JAttachmentInfo.hpp +96 -0
  44. package/nitrogen/generated/android/c++/JConnectionConfig.hpp +94 -0
  45. package/nitrogen/generated/android/c++/JConnectionResult.hpp +73 -0
  46. package/nitrogen/generated/android/c++/JDeleteResult.hpp +87 -0
  47. package/nitrogen/generated/android/c++/JEmailAddress.hpp +61 -0
  48. package/nitrogen/generated/android/c++/JEmailBody.hpp +84 -0
  49. package/nitrogen/generated/android/c++/JEmailMessage.hpp +273 -0
  50. package/nitrogen/generated/android/c++/JFetchEmailsOptions.hpp +110 -0
  51. package/nitrogen/generated/android/c++/JFolderInfo.hpp +126 -0
  52. package/nitrogen/generated/android/c++/JFunc_void_IdleNotification.hpp +79 -0
  53. package/nitrogen/generated/android/c++/JHybridEmailImapSmtpSpec.cpp +697 -0
  54. package/nitrogen/generated/android/c++/JHybridEmailImapSmtpSpec.hpp +92 -0
  55. package/nitrogen/generated/android/c++/JIdleNotification.hpp +78 -0
  56. package/nitrogen/generated/android/c++/JImapServerInfo.hpp +84 -0
  57. package/nitrogen/generated/android/c++/JMailboxInfo.hpp +134 -0
  58. package/nitrogen/generated/android/c++/JMailboxListItem.hpp +92 -0
  59. package/nitrogen/generated/android/c++/JQuotaInfo.hpp +78 -0
  60. package/nitrogen/generated/android/c++/JSearchCriteria.hpp +187 -0
  61. package/nitrogen/generated/android/c++/JSearchHeader.hpp +61 -0
  62. package/nitrogen/generated/android/c++/JSendMailOptions.hpp +229 -0
  63. package/nitrogen/generated/android/c++/JSendMailResult.hpp +88 -0
  64. package/nitrogen/generated/android/c++/JSmtpAttachment.hpp +80 -0
  65. package/nitrogen/generated/android/c++/JSmtpConnectionConfig.hpp +98 -0
  66. package/nitrogen/generated/android/c++/JSmtpServerInfo.hpp +102 -0
  67. package/nitrogen/generated/android/c++/JVariant_ImapServerInfo_SmtpServerInfo.cpp +26 -0
  68. package/nitrogen/generated/android/c++/JVariant_ImapServerInfo_SmtpServerInfo.hpp +73 -0
  69. package/nitrogen/generated/android/c++/JVariant_NullType_AttachmentInfo.cpp +26 -0
  70. package/nitrogen/generated/android/c++/JVariant_NullType_AttachmentInfo.hpp +75 -0
  71. package/nitrogen/generated/android/c++/JVariant_NullType_EmailMessage.cpp +26 -0
  72. package/nitrogen/generated/android/c++/JVariant_NullType_EmailMessage.hpp +83 -0
  73. package/nitrogen/generated/android/emailimapsmtp+autolinking.cmake +84 -0
  74. package/nitrogen/generated/android/emailimapsmtp+autolinking.gradle +27 -0
  75. package/nitrogen/generated/android/emailimapsmtpOnLoad.cpp +56 -0
  76. package/nitrogen/generated/android/emailimapsmtpOnLoad.hpp +34 -0
  77. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/AppendResult.kt +61 -0
  78. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/AttachmentInfo.kt +96 -0
  79. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/ConnectionConfig.kt +96 -0
  80. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/ConnectionResult.kt +61 -0
  81. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/DeleteResult.kt +61 -0
  82. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/EmailAddress.kt +56 -0
  83. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/EmailBody.kt +61 -0
  84. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/EmailMessage.kt +156 -0
  85. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/FetchEmailsOptions.kt +116 -0
  86. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/FolderInfo.kt +91 -0
  87. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/Func_void_IdleNotification.kt +80 -0
  88. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/HybridEmailImapSmtpSpec.kt +178 -0
  89. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/IdleNotification.kt +76 -0
  90. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/ImapServerInfo.kt +61 -0
  91. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/MailboxInfo.kt +101 -0
  92. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/MailboxListItem.kt +71 -0
  93. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/QuotaInfo.kt +76 -0
  94. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SearchCriteria.kt +196 -0
  95. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SearchHeader.kt +56 -0
  96. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SendMailOptions.kt +126 -0
  97. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SendMailResult.kt +86 -0
  98. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SmtpAttachment.kt +76 -0
  99. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SmtpConnectionConfig.kt +101 -0
  100. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/SmtpServerInfo.kt +61 -0
  101. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/Variant_ImapServerInfo_SmtpServerInfo.kt +62 -0
  102. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/Variant_NullType_AttachmentInfo.kt +62 -0
  103. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/Variant_NullType_EmailMessage.kt +62 -0
  104. package/nitrogen/generated/android/kotlin/com/margelo/nitro/emailimapsmtp/emailimapsmtpOnLoad.kt +35 -0
  105. package/nitrogen/generated/ios/EmailImapSmtp+autolinking.rb +62 -0
  106. package/nitrogen/generated/ios/EmailImapSmtp-Swift-Cxx-Bridge.cpp +161 -0
  107. package/nitrogen/generated/ios/EmailImapSmtp-Swift-Cxx-Bridge.hpp +1132 -0
  108. package/nitrogen/generated/ios/EmailImapSmtp-Swift-Cxx-Umbrella.hpp +118 -0
  109. package/nitrogen/generated/ios/EmailImapSmtpAutolinking.mm +33 -0
  110. package/nitrogen/generated/ios/EmailImapSmtpAutolinking.swift +26 -0
  111. package/nitrogen/generated/ios/c++/HybridEmailImapSmtpSpecSwift.cpp +11 -0
  112. package/nitrogen/generated/ios/c++/HybridEmailImapSmtpSpecSwift.hpp +390 -0
  113. package/nitrogen/generated/ios/swift/AppendResult.swift +39 -0
  114. package/nitrogen/generated/ios/swift/AttachmentInfo.swift +113 -0
  115. package/nitrogen/generated/ios/swift/ConnectionConfig.swift +87 -0
  116. package/nitrogen/generated/ios/swift/ConnectionResult.swift +84 -0
  117. package/nitrogen/generated/ios/swift/DeleteResult.swift +64 -0
  118. package/nitrogen/generated/ios/swift/EmailAddress.swift +34 -0
  119. package/nitrogen/generated/ios/swift/EmailBody.swift +45 -0
  120. package/nitrogen/generated/ios/swift/EmailMessage.swift +184 -0
  121. package/nitrogen/generated/ios/swift/FetchEmailsOptions.swift +198 -0
  122. package/nitrogen/generated/ios/swift/FolderInfo.swift +81 -0
  123. package/nitrogen/generated/ios/swift/Func_void.swift +46 -0
  124. package/nitrogen/generated/ios/swift/Func_void_AppendResult.swift +46 -0
  125. package/nitrogen/generated/ios/swift/Func_void_ConnectionResult.swift +46 -0
  126. package/nitrogen/generated/ios/swift/Func_void_DeleteResult.swift +46 -0
  127. package/nitrogen/generated/ios/swift/Func_void_IdleNotification.swift +46 -0
  128. package/nitrogen/generated/ios/swift/Func_void_MailboxInfo.swift +46 -0
  129. package/nitrogen/generated/ios/swift/Func_void_QuotaInfo.swift +46 -0
  130. package/nitrogen/generated/ios/swift/Func_void_SendMailResult.swift +46 -0
  131. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  132. package/nitrogen/generated/ios/swift/Func_void_std__exception_ptr.swift +46 -0
  133. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__AttachmentInfo_.swift +58 -0
  134. package/nitrogen/generated/ios/swift/Func_void_std__variant_nitro__NullType__EmailMessage_.swift +58 -0
  135. package/nitrogen/generated/ios/swift/Func_void_std__vector_AttachmentInfo_.swift +46 -0
  136. package/nitrogen/generated/ios/swift/Func_void_std__vector_EmailMessage_.swift +46 -0
  137. package/nitrogen/generated/ios/swift/Func_void_std__vector_FolderInfo_.swift +46 -0
  138. package/nitrogen/generated/ios/swift/Func_void_std__vector_MailboxListItem_.swift +46 -0
  139. package/nitrogen/generated/ios/swift/HybridEmailImapSmtpSpec.swift +84 -0
  140. package/nitrogen/generated/ios/swift/HybridEmailImapSmtpSpec_cxx.swift +759 -0
  141. package/nitrogen/generated/ios/swift/IdleNotification.swift +93 -0
  142. package/nitrogen/generated/ios/swift/ImapServerInfo.swift +45 -0
  143. package/nitrogen/generated/ios/swift/MailboxInfo.swift +91 -0
  144. package/nitrogen/generated/ios/swift/MailboxListItem.swift +55 -0
  145. package/nitrogen/generated/ios/swift/QuotaInfo.swift +80 -0
  146. package/nitrogen/generated/ios/swift/SearchCriteria.swift +485 -0
  147. package/nitrogen/generated/ios/swift/SearchHeader.swift +34 -0
  148. package/nitrogen/generated/ios/swift/SendMailOptions.swift +310 -0
  149. package/nitrogen/generated/ios/swift/SendMailResult.swift +155 -0
  150. package/nitrogen/generated/ios/swift/SmtpAttachment.swift +80 -0
  151. package/nitrogen/generated/ios/swift/SmtpConnectionConfig.swift +105 -0
  152. package/nitrogen/generated/ios/swift/SmtpServerInfo.swift +51 -0
  153. package/nitrogen/generated/ios/swift/Variant_ImapServerInfo_SmtpServerInfo.swift +30 -0
  154. package/nitrogen/generated/ios/swift/Variant_NullType_AttachmentInfo.swift +30 -0
  155. package/nitrogen/generated/ios/swift/Variant_NullType_EmailMessage.swift +30 -0
  156. package/nitrogen/generated/shared/c++/AppendResult.hpp +91 -0
  157. package/nitrogen/generated/shared/c++/AttachmentInfo.hpp +121 -0
  158. package/nitrogen/generated/shared/c++/ConnectionConfig.hpp +120 -0
  159. package/nitrogen/generated/shared/c++/ConnectionResult.hpp +98 -0
  160. package/nitrogen/generated/shared/c++/DeleteResult.hpp +93 -0
  161. package/nitrogen/generated/shared/c++/EmailAddress.hpp +87 -0
  162. package/nitrogen/generated/shared/c++/EmailBody.hpp +92 -0
  163. package/nitrogen/generated/shared/c++/EmailMessage.hpp +177 -0
  164. package/nitrogen/generated/shared/c++/FetchEmailsOptions.hpp +136 -0
  165. package/nitrogen/generated/shared/c++/FolderInfo.hpp +116 -0
  166. package/nitrogen/generated/shared/c++/HybridEmailImapSmtpSpec.cpp +50 -0
  167. package/nitrogen/generated/shared/c++/HybridEmailImapSmtpSpec.hpp +145 -0
  168. package/nitrogen/generated/shared/c++/IdleNotification.hpp +104 -0
  169. package/nitrogen/generated/shared/c++/ImapServerInfo.hpp +92 -0
  170. package/nitrogen/generated/shared/c++/MailboxInfo.hpp +124 -0
  171. package/nitrogen/generated/shared/c++/MailboxListItem.hpp +100 -0
  172. package/nitrogen/generated/shared/c++/QuotaInfo.hpp +104 -0
  173. package/nitrogen/generated/shared/c++/SearchCriteria.hpp +203 -0
  174. package/nitrogen/generated/shared/c++/SearchHeader.hpp +87 -0
  175. package/nitrogen/generated/shared/c++/SendMailOptions.hpp +151 -0
  176. package/nitrogen/generated/shared/c++/SendMailResult.hpp +113 -0
  177. package/nitrogen/generated/shared/c++/SmtpAttachment.hpp +105 -0
  178. package/nitrogen/generated/shared/c++/SmtpConnectionConfig.hpp +124 -0
  179. package/nitrogen/generated/shared/c++/SmtpServerInfo.hpp +92 -0
  180. package/package.json +196 -0
  181. package/react-native.config.js +27 -0
  182. package/src/EmailImapSmtp.nitro.ts +333 -0
  183. package/src/NativeEmailImapSmtp.ts +100 -0
  184. package/src/index.tsx +166 -0
  185. package/src/native.ts +305 -0
  186. package/src/types.ts +328 -0
  187. package/src/web.ts +196 -0
  188. package/src/windows.ts +304 -0
  189. package/windows/EmailImapSmtp.cpp +735 -0
  190. package/windows/EmailImapSmtp.h +137 -0
  191. package/windows/EmailImapSmtpModule.cpp +10 -0
  192. package/windows/EmailImapSmtpModule.h +139 -0
  193. package/windows/EmailImapSmtpPackage.cpp +15 -0
  194. package/windows/EmailImapSmtpPackage.h +13 -0
  195. package/windows/ImapProtocol.cpp +1382 -0
  196. package/windows/ImapProtocol.h +97 -0
  197. package/windows/ProtocolDefs.h +300 -0
  198. package/windows/SmtpProtocol.cpp +334 -0
  199. package/windows/SmtpProtocol.h +36 -0
  200. package/windows/TlsSocket.cpp +818 -0
  201. 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
+ }