tempmail_sdk 1.3.3 → 1.3.5

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/tempmail_sdk/channels.rb +29 -7
  4. data/lib/tempmail_sdk/channels_data.rb +29 -7
  5. data/lib/tempmail_sdk/providers/altmails.rb +99 -0
  6. data/lib/tempmail_sdk/providers/apihz.rb +138 -0
  7. data/lib/tempmail_sdk/providers/awamail.rb +95 -0
  8. data/lib/tempmail_sdk/providers/best_temp_mail.rb +77 -0
  9. data/lib/tempmail_sdk/providers/chatgpt_org_uk.rb +129 -0
  10. data/lib/tempmail_sdk/providers/clawdemail.rb +122 -0
  11. data/lib/tempmail_sdk/providers/crazymailing.rb +114 -0
  12. data/lib/tempmail_sdk/providers/disposablemail.rb +125 -0
  13. data/lib/tempmail_sdk/providers/disposablemail_app.rb +66 -0
  14. data/lib/tempmail_sdk/providers/email10min.rb +152 -0
  15. data/lib/tempmail_sdk/providers/emailnator.rb +132 -0
  16. data/lib/tempmail_sdk/providers/emailtemp_org.rb +131 -0
  17. data/lib/tempmail_sdk/providers/expressinboxhub.rb +118 -0
  18. data/lib/tempmail_sdk/providers/fakemail.rb +143 -0
  19. data/lib/tempmail_sdk/providers/firetempmail.rb +86 -0
  20. data/lib/tempmail_sdk/providers/flybymail.rb +93 -0
  21. data/lib/tempmail_sdk/providers/generator_email.rb +146 -0
  22. data/lib/tempmail_sdk/providers/haribu.rb +29 -3
  23. data/lib/tempmail_sdk/providers/huskmail.rb +132 -0
  24. data/lib/tempmail_sdk/providers/internxt.rb +176 -0
  25. data/lib/tempmail_sdk/providers/linshi_xyz.rb +79 -0
  26. data/lib/tempmail_sdk/providers/linshiyouxiang_net.rb +85 -0
  27. data/lib/tempmail_sdk/providers/mail_sunls.rb +42 -1
  28. data/lib/tempmail_sdk/providers/mail_td.rb +161 -0
  29. data/lib/tempmail_sdk/providers/mailcat_ai.rb +52 -0
  30. data/lib/tempmail_sdk/providers/maildrop.rb +79 -4
  31. data/lib/tempmail_sdk/providers/mailgolem.rb +96 -0
  32. data/lib/tempmail_sdk/providers/mailinator.rb +9 -2
  33. data/lib/tempmail_sdk/providers/mailmomy.rb +1 -1
  34. data/lib/tempmail_sdk/providers/mailtemp_cc.rb +91 -0
  35. data/lib/tempmail_sdk/providers/mailticking.rb +139 -0
  36. data/lib/tempmail_sdk/providers/minuteinbox.rb +174 -0
  37. data/lib/tempmail_sdk/providers/moakt.rb +29 -3
  38. data/lib/tempmail_sdk/providers/mtempmail.rb +140 -0
  39. data/lib/tempmail_sdk/providers/mytempmail_cc.rb +76 -0
  40. data/lib/tempmail_sdk/providers/nowtempmail.rb +112 -0
  41. data/lib/tempmail_sdk/providers/noxen_de5_net.rb +392 -0
  42. data/lib/tempmail_sdk/providers/nukemail.rb +188 -0
  43. data/lib/tempmail_sdk/providers/nullmail.rb +95 -0
  44. data/lib/tempmail_sdk/providers/openinbox.rb +102 -0
  45. data/lib/tempmail_sdk/providers/shadowmail.rb +174 -0
  46. data/lib/tempmail_sdk/providers/shitpost_email.rb +85 -0
  47. data/lib/tempmail_sdk/providers/smail_pw.rb +156 -0
  48. data/lib/tempmail_sdk/providers/smails.rb +128 -0
  49. data/lib/tempmail_sdk/providers/socketio_mail.rb +25 -0
  50. data/lib/tempmail_sdk/providers/temp_mail_gg.rb +362 -0
  51. data/lib/tempmail_sdk/providers/tempgbox.rb +118 -0
  52. data/lib/tempmail_sdk/providers/tempmail100.rb +148 -0
  53. data/lib/tempmail_sdk/providers/tempmail_cn.rb +111 -0
  54. data/lib/tempmail_sdk/providers/tempmail_ee.rb +373 -0
  55. data/lib/tempmail_sdk/providers/tempmailportal.rb +131 -0
  56. data/lib/tempmail_sdk/providers/tempmails_io.rb +89 -0
  57. data/lib/tempmail_sdk/providers/tempmailto.rb +312 -0
  58. data/lib/tempmail_sdk/providers/temporarymail_com.rb +184 -0
  59. data/lib/tempmail_sdk/providers/tenmin_app.rb +100 -0
  60. data/lib/tempmail_sdk/providers/tenminutemail_net.rb +183 -0
  61. data/lib/tempmail_sdk/providers/thirty_min_email.rb +90 -0
  62. data/lib/tempmail_sdk/providers/tmpkit.rb +129 -0
  63. data/lib/tempmail_sdk/providers/twentyfourmail_chacuo.rb +83 -0
  64. data/lib/tempmail_sdk/providers/vip_215.rb +171 -0
  65. data/lib/tempmail_sdk/providers/zerodrop.rb +90 -0
  66. data/lib/tempmail_sdk/registry.rb +285 -23
  67. data/lib/tempmail_sdk/types.rb +13 -0
  68. data/lib/tempmail_sdk/version.rb +1 -1
  69. data/lib/tempmail_sdk.rb +1 -1
  70. metadata +58 -6
  71. data/lib/tempmail_sdk/providers/gonebox_email.rb +0 -89
  72. data/lib/tempmail_sdk/providers/temp_mail_now.rb +0 -103
  73. data/lib/tempmail_sdk/providers/webmailtemp.rb +0 -108
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module TempmailSdk
6
+ module Providers
7
+ # NowtempMail 渠道实现(nowtempmail.com)
8
+ #
9
+ # POST /mailbox 建箱(无 body 或 {},响应 token(JWT)/mailbox),
10
+ # GET /messages 读信列表(Header Authorization: Bearer <token>,
11
+ # 响应 {"messages":[...]}),GET /message/{id} 取单封详情(Bearer)。
12
+ # 列表元素按多候选字段归一;详情拉取失败时以列表摘要兜底。
13
+ module Nowtempmail
14
+ CHANNEL = "nowtempmail"
15
+ BASE_URL = "https://nowtempmail.com"
16
+
17
+ HEADERS = {
18
+ "Accept" => "application/json",
19
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
20
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
21
+ }.freeze
22
+
23
+ RETRY_KEYS = %w[id Id slug messageId message_id].freeze
24
+
25
+ module_function
26
+
27
+ # 设置 nowtempmail 请求的通用请求头(可选附带 Bearer token)
28
+ # @param token [String] 认证令牌
29
+ # @return [Hash]
30
+ def auth_headers(token)
31
+ hdrs = HEADERS.dup
32
+ hdrs["Authorization"] = "Bearer #{token}" unless token.to_s.empty?
33
+ hdrs
34
+ end
35
+
36
+ # 创建 nowtempmail.com 临时邮箱
37
+ # POST /mailbox(空 body)返回 token(JWT)与 mailbox 地址。
38
+ # @return [EmailInfo]
39
+ def generate_email
40
+ resp = Http.post("#{BASE_URL}/mailbox",
41
+ headers: auth_headers("").merge("Content-Type" => "application/json"),
42
+ timeout: 15)
43
+ raise "nowtempmail: 创建邮箱失败 http #{resp.status_code}" unless resp.ok?
44
+
45
+ data = resp.json
46
+ raise "nowtempmail: 创建响应非对象" unless data.is_a?(Hash)
47
+
48
+ token = data["token"].to_s.strip
49
+ mailbox = data["mailbox"].to_s.strip
50
+ raise "nowtempmail: 创建邮箱响应缺少必要字段" if token.empty? || mailbox.empty? || !mailbox.include?("@")
51
+
52
+ EmailInfo.new(channel: CHANNEL, email: mailbox, token: token)
53
+ end
54
+
55
+ # 获取 nowtempmail.com 邮件列表
56
+ # 流程:GET /messages 取列表,对每个元素按 id 逐封 GET /message/{id} 合并详情;
57
+ # 详情失败时以列表摘要归一。
58
+ # @param email [String] 邮箱地址
59
+ # @param token [String] 建箱返回的 JWT 认证令牌
60
+ # @return [Array<Email>]
61
+ def get_emails(email, token)
62
+ addr = email.to_s.strip
63
+ resp = Http.get("#{BASE_URL}/messages", headers: auth_headers(token), timeout: 15)
64
+ raise "nowtempmail: 获取邮件列表失败 http #{resp.status_code}" unless resp.ok?
65
+
66
+ data = resp.json
67
+ messages = data.is_a?(Hash) ? data["messages"] : nil
68
+ return [] unless messages.is_a?(Array)
69
+
70
+ messages.filter_map do |m|
71
+ next unless m.is_a?(Hash)
72
+
73
+ detail = get_detail(token, message_id_of(m))
74
+ row = m.dup
75
+ if detail.is_a?(Hash)
76
+ detail.each { |k, v| row[k] = v unless row.key?(k) }
77
+ end
78
+ Normalize.normalize_email(row, addr)
79
+ end
80
+ end
81
+
82
+ # 从列表元素中提取邮件 ID(候选键 id/Id/slug/messageId/message_id)
83
+ # @param row [Hash]
84
+ # @return [String]
85
+ def message_id_of(row)
86
+ RETRY_KEYS.each do |key|
87
+ val = row[key]
88
+ return val.to_s.strip unless val.nil? || val.to_s.strip.empty?
89
+ end
90
+ ""
91
+ end
92
+
93
+ # 获取 nowtempmail.com 单封邮件详情
94
+ # GET /message/{id}(Bearer token),响应为单封对象,含 text/html 等完整字段。
95
+ # @param token [String] JWT
96
+ # @param id [String] 邮件 ID
97
+ # @return [Hash, nil]
98
+ def get_detail(token, id)
99
+ return nil if id.to_s.empty?
100
+
101
+ uri = "#{BASE_URL}/message/#{URI.encode_www_form_component(id)}"
102
+ resp = Http.get(uri, headers: auth_headers(token), timeout: 15)
103
+ return nil unless resp.ok?
104
+
105
+ detail = resp.json
106
+ detail.is_a?(Hash) ? detail : nil
107
+ rescue StandardError
108
+ nil
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module TempmailSdk
8
+ module Providers
9
+ # NoxenDe5Net 渠道实现(UniMail-Bot 公共实例 tempmail.noxen.de5.net)
10
+ #
11
+ # 完整接入契约:
12
+ # 登录 POST /api/login {"username":"guest","password":"123456"}
13
+ # → 200 {"success":true,"role":"guest"} 并 Set-Cookie: iding-session=<JWT>;
14
+ # 建箱 GET /api/generate → 200 {"email":"随机@域名","expires":毫秒时间戳};
15
+ # 读信 GET /api/emails?mailbox=<地址>&limit=20 → 200 邮件数组(无邮件为 []);
16
+ # 详情 GET /api/email/{id} → {..., content, html_content, to_addrs, r2_bucket,
17
+ # r2_object_key, download};content/html_content 平台恒为空,原始 EML 存
18
+ # Cloudflare R2,download 指向 GET /api/email/{id}/download 下载端点。
19
+ # 鉴权边界:读信不带会话 Cookie 返回 401;访客邮箱只能查自己的 mailbox。
20
+ # 会话隔离:先 GET /api/session 兜底校验 cookie,未通过则重新 login;
21
+ # 凭据串只由会话 Cookie 与同源地址构成,读信时逐请求显式携带。
22
+ module NoxenDe5Net
23
+ BASE_URL = "https://tempmail.noxen.de5.net"
24
+ USER = "guest"
25
+ PASS = "123456"
26
+ # 本渠道凭据串前缀
27
+ TOKEN_PREFIX = "noxen-de5-net|"
28
+
29
+ HEADERS = {
30
+ "Accept" => "application/json",
31
+ "User-Agent" => "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " \
32
+ "(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
33
+ }.freeze
34
+
35
+ module_function
36
+
37
+ # 登录取得会话 Cookie(iding-session=JWT)
38
+ # @return [String] "iding-session=<JWT>"
39
+ def session_cookie
40
+ headers = {
41
+ "Content-Type" => "application/json",
42
+ "Accept" => "application/json",
43
+ "User-Agent" => HEADERS["User-Agent"]
44
+ }
45
+ resp = Http.post("#{BASE_URL}/api/login",
46
+ headers: headers,
47
+ json: { "username" => USER, "password" => PASS },
48
+ timeout: 15)
49
+ raise "noxen-de5-net login: http #{resp.status_code}" unless resp.ok?
50
+
51
+ data = resp.json
52
+ raise "noxen-de5-net login: 登录失败" unless data.is_a?(Hash) && data["success"] == true
53
+
54
+ session = resp.cookie_value("iding-session").to_s.strip
55
+ raise "noxen-de5-net login: 未下发会话 Cookie" if session.empty?
56
+
57
+ "iding-session=#{session}"
58
+ end
59
+
60
+ # 校验会话 Cookie 是否仍有效(GET /api/session)
61
+ # @param cookie [String]
62
+ # @return [Boolean]
63
+ def cookie_still_valid(cookie)
64
+ resp = Http.get("#{BASE_URL}/api/session",
65
+ headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
66
+ return false unless resp.ok?
67
+
68
+ data = resp.json
69
+ data.is_a?(Hash) && data["authenticated"] == true
70
+ rescue StandardError
71
+ false
72
+ end
73
+
74
+ # 校验域名是否在平台域名池内
75
+ # @param domain [String]
76
+ # @return [Boolean]
77
+ def domain_in_pool(domain)
78
+ resp = Http.get("#{BASE_URL}/api/domains", headers: HEADERS, timeout: 15)
79
+ return false unless resp.ok?
80
+
81
+ pool = resp.json
82
+ return false unless pool.is_a?(Array)
83
+
84
+ pool.any? { |d| d.to_s.casecmp(domain).zero? }
85
+ rescue StandardError
86
+ false
87
+ end
88
+
89
+ # 登录并创建临时邮箱
90
+ # @param domain [String, nil] 可选首选域名(空时平台自动选域)
91
+ # token 凭据串格式:"noxen-de5-net|<iding-session=JWT>|base=<基址>"
92
+ # @return [EmailInfo]
93
+ def generate_email(domain = nil)
94
+ want = domain.to_s.strip
95
+ if !want.empty? && !domain_in_pool(want)
96
+ raise "noxen-de5-net generate: 域名 #{want} 不在平台域名池"
97
+ end
98
+
99
+ cookie = session_cookie
100
+
101
+ resp = Http.get("#{BASE_URL}/api/generate",
102
+ headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
103
+ raise "noxen-de5-net generate: http #{resp.status_code}" unless resp.ok?
104
+
105
+ data = resp.json
106
+ email = data.is_a?(Hash) ? data["email"].to_s.strip : ""
107
+ raise "noxen-de5-net generate: 响应缺少 email" if email.empty?
108
+
109
+ expires = data.is_a?(Hash) ? data["expires"].to_i : 0
110
+ expires_at = expires.positive? ? Time.at(expires / 1000).utc.iso8601 : nil
111
+ token = TOKEN_PREFIX + URI.encode_www_form_component(cookie) + "|base=" + BASE_URL
112
+ EmailInfo.new(channel: "noxen-de5-net", email: email, token: token, expires_at: expires_at)
113
+ end
114
+
115
+ # 从凭据串解析会话 Cookie
116
+ # @param token [String] 建箱下发的凭据串
117
+ # @return [String] "iding-session=<JWT>"
118
+ def cookie_from_token(token)
119
+ raise "noxen-de5-net: token 格式错误" unless token.to_s.start_with?(TOKEN_PREFIX)
120
+
121
+ enc = token.to_s[TOKEN_PREFIX.length..].to_s
122
+ enc = enc.sub(/\|base=#{Regexp.escape(BASE_URL)}\z/, "")
123
+ URI.decode_www_form_component(enc)
124
+ end
125
+
126
+ # 读取收件箱
127
+ # 正文获取优先级:
128
+ # 1) 详情(download 定位)+ 下载端点拉取原始 EML,本地拆分 text/plain 与 text/html;
129
+ # 2) 详情/EML 链路不可得时,用 verification_code 置顶 + preview 合成占位正文。
130
+ # @param email [String] 信箱地址(与凭据同源)
131
+ # @param token [String] 建箱下发的凭据串
132
+ # @return [Array<Email>]
133
+ def get_emails(email, token)
134
+ addr = email.to_s.strip
135
+ cookie = cookie_from_token(token)
136
+ cookie = session_cookie unless cookie_still_valid(cookie)
137
+
138
+ uri = "#{BASE_URL}/api/emails?mailbox=#{URI.encode_www_form_component(addr)}&limit=20"
139
+ resp = Http.get(uri, headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
140
+ if resp.status_code == 401
141
+ raise "noxen-de5-net 读信: http 401(会话失效或非本会话邮箱)"
142
+ end
143
+ raise "noxen-de5-net 读信: http #{resp.status_code}" unless resp.ok?
144
+
145
+ list = resp.json
146
+ raise "noxen-de5-net 读信: 收件箱响应非数组" unless list.is_a?(Array)
147
+
148
+ list.filter_map do |m|
149
+ next unless m.is_a?(Hash)
150
+
151
+ flat = m.dup
152
+ flat["from"] = m["sender"]
153
+ flat["to"] = addr
154
+ flat["date"] = m["received_at"]
155
+ flat["text"] = m["preview"]
156
+ flat["isRead"] = m["is_read"]
157
+ full = false
158
+ id = m["id"].to_s.strip
159
+ if !id.empty? && id != "0"
160
+ detail = fetch_detail(cookie, id)
161
+ if detail.is_a?(Hash)
162
+ flat["content"] = detail["content"]
163
+ flat["html_content"] = detail["html_content"]
164
+ flat["to_addrs"] = detail["to_addrs"]
165
+ flat["r2_bucket"] = detail["r2_bucket"]
166
+ flat["r2_object_key"] = detail["r2_object_key"]
167
+ dl = detail["download"].to_s
168
+ unless dl.empty?
169
+ eml = fetch_eml(cookie, dl)
170
+ unless eml.empty?
171
+ text, html = parse_eml(eml)
172
+ if !text.empty? || !html.empty?
173
+ flat["text"] = text
174
+ flat["html"] = html
175
+ full = true
176
+ end
177
+ end
178
+ end
179
+ end
180
+ flat["text"] = compose_placeholder(m) unless full
181
+ end
182
+ Normalize.normalize_email(flat, addr)
183
+ end
184
+ end
185
+
186
+ # 拉取单封邮件详情(GET /api/email/{id})
187
+ # @param cookie [String]
188
+ # @param id [String]
189
+ # @return [Hash, nil]
190
+ def fetch_detail(cookie, id)
191
+ uri = "#{BASE_URL}/api/email/#{URI.encode_www_form_component(id)}"
192
+ resp = Http.get(uri, headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
193
+ return nil unless resp.ok?
194
+
195
+ resp.json
196
+ rescue StandardError
197
+ nil
198
+ end
199
+
200
+ # 拉取原始 EML 报文(详情 download 字段指向的下载端点)
201
+ # @param cookie [String]
202
+ # @param dl_path [String] 下载相对路径(如 /api/email/3880/download)
203
+ # @return [String] 原始报文文本
204
+ def fetch_eml(cookie, dl_path)
205
+ u = dl_path.to_s
206
+ unless u.start_with?("http://", "https://")
207
+ u = BASE_URL + u
208
+ end
209
+ resp = Http.get(u,
210
+ headers: {
211
+ "Accept" => "message/rfc822, */*",
212
+ "User-Agent" => HEADERS["User-Agent"],
213
+ "Cookie" => cookie
214
+ },
215
+ timeout: 20)
216
+ return "" unless resp.ok?
217
+
218
+ resp.body.to_s
219
+ rescue StandardError
220
+ ""
221
+ end
222
+
223
+ # 全文不可得时的合成占位正文:verification_code 置顶,preview 附后。
224
+ # @param m [Hash] 列表元素 map(verification_code/preview)
225
+ # @return [String]
226
+ def compose_placeholder(m)
227
+ code = m["verification_code"].to_s.strip
228
+ preview = m["preview"].to_s.strip
229
+ parts = []
230
+ parts << "验证码: #{code}" unless code.empty?
231
+ parts << preview unless preview.empty?
232
+ parts.join("\n\n")
233
+ end
234
+
235
+ # 解析 EML 原始报文 → [纯文本正文, HTML 正文]
236
+ # @param eml [String] 原始报文
237
+ # @return [Array(String, String)]
238
+ def parse_eml(eml)
239
+ payload = eml.to_s.gsub("\r\n", "\n").gsub("\r", "")
240
+ headers, body = split_eml(payload, 0)
241
+ parse_entity(headers, body)
242
+ end
243
+
244
+ # 将原始报文切分为首部 map 与正文块
245
+ # @param payload [String] 已做 CRLF→LF 归一化的原始报文
246
+ # @param offset [Integer] 起始行号(外包 "#participant"+part 时置 1)
247
+ # @return [Array(Hash, String)]
248
+ def split_eml(payload, offset)
249
+ lines = payload.split("\n", -1)
250
+ headers = {}
251
+ i = offset
252
+ i += 1 if i < lines.length && lines[i].start_with?("From ")
253
+ cur_key = ""
254
+ while i < lines.length
255
+ line = lines[i]
256
+ break if line == ""
257
+
258
+ # 折行续行(RFC 5322):以空白开头且已有当前头键时拼接到上一条
259
+ if (line[0] == " " || line[0] == "\t") && !cur_key.empty?
260
+ headers[cur_key] = "#{headers[cur_key]} #{line.strip}"
261
+ i += 1
262
+ next
263
+ end
264
+ if (k = line.index(":")) && k.positive?
265
+ cur_key = line[0...k].strip.downcase
266
+ headers[cur_key] = line[(k + 1)..].to_s.strip
267
+ end
268
+ i += 1
269
+ end
270
+ i += 1 if i < lines.length
271
+ [headers, lines[i..].to_a.join("\n")]
272
+ end
273
+
274
+ # 从 Content-Type 头值提取 multipart boundary(引号可选)
275
+ # @param ct_raw [String]
276
+ # @return [String]
277
+ def extract_boundary(ct_raw)
278
+ m = /boundary="?([^";\s]+)"?/i.match(ct_raw.to_s)
279
+ m ? m[1] : ""
280
+ end
281
+
282
+ # 按 boundary 切出各 part(含各自首部行)
283
+ # @param body [String]
284
+ # @param boundary [String]
285
+ # @return [Array<String>]
286
+ def split_multipart(body, boundary)
287
+ segments = body.split("--#{boundary}")
288
+ segments.filter_map do |seg|
289
+ seg = seg.sub(/\A\n/, "")
290
+ seg = seg.sub(/--\n\z/, "").sub(/--\z/, "")
291
+ seg.empty? ? nil : seg
292
+ end
293
+ end
294
+
295
+ # 递归解析单个 MIME 实体(上游 parseEntity 同构)
296
+ # @param headers [Hash]
297
+ # @param body [String]
298
+ # @return [Array(String, String)]
299
+ def parse_entity(headers, body)
300
+ ct = headers["content-type"].to_s.downcase
301
+ cte = headers["content-transfer-encoding"].to_s.downcase
302
+
303
+ # 单体:text/html 或 text/plain(含无 Content-Type 时按纯文本处理)
304
+ unless ct.start_with?("multipart/")
305
+ decoded = decode_part(body, cte)
306
+ return ct.include?("text/html") ? ["", decoded] : [decoded, ""]
307
+ end
308
+
309
+ # 复合:递归拆分,text 槽与 html 槽各自取第一个非空命中
310
+ text = ""
311
+ html = ""
312
+ boundary = extract_boundary(headers["content-type"].to_s)
313
+ unless boundary.empty?
314
+ split_multipart(body, boundary).each do |part|
315
+ ph, pb = split_eml("#participant\n#{part}", 1)
316
+ pct = ph["content-type"].to_s.downcase
317
+ if pct.start_with?("multipart/")
318
+ t, h = parse_entity(ph, pb)
319
+ text = t if text.empty?
320
+ html = h if html.empty?
321
+ elsif pct.start_with?("message/rfc822")
322
+ nh, nb = split_eml(pb, 0)
323
+ t, h = parse_entity(nh, nb)
324
+ text = t if text.empty?
325
+ html = h if html.empty?
326
+ elsif pct.include?("rfc822-headers")
327
+ # 纯头部 part 跳过,正文在后续 part 中抓取
328
+ next
329
+ else
330
+ t, h = parse_entity(ph, pb)
331
+ text = t if text.empty?
332
+ html = h if html.empty?
333
+ end
334
+ break if !text.empty? && !html.empty?
335
+ end
336
+ end
337
+ # 无 HTML 命中时从整体原文兜底抓取 HTML 片段(上游 guessHtmlFromRaw 同构)
338
+ html = guess_html(body) if html.empty?
339
+ [text, html]
340
+ end
341
+
342
+ # 按 Content-Transfer-Encoding 解码 part 内容
343
+ # @param data [String]
344
+ # @param cte [String]
345
+ # @return [String]
346
+ def decode_part(data, cte)
347
+ case cte.to_s.strip.downcase
348
+ when "base64"
349
+ joined = data.gsub(/[\n\r\t ]/, "")
350
+ begin
351
+ Base64.strict_decode64(joined).strip.force_encoding("UTF-8")
352
+ rescue ArgumentError
353
+ data
354
+ end
355
+ when "quoted-printable"
356
+ decode_quoted_printable(data)
357
+ else
358
+ # 7bit/8bit/binary:原样返回
359
+ data.strip
360
+ end
361
+ end
362
+
363
+ # 最小 quoted-printable 解码(=XX 十六进制转字节,行尾 = 软换行)
364
+ # @param data [String]
365
+ # @return [String]
366
+ def decode_quoted_printable(data)
367
+ out = data.gsub(/=\r?\n/, "")
368
+ out = out.gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
369
+ out.force_encoding("UTF-8")
370
+ rescue StandardError
371
+ data
372
+ end
373
+
374
+ # 从整体原文中抓取 <html>…</html> 片段(上游 guessHtmlFromRaw 同构)
375
+ # @param body [String]
376
+ # @return [String]
377
+ def guess_html(body)
378
+ return "" if body.to_s.empty?
379
+
380
+ lower = body.downcase
381
+ hs = lower.index("<html")
382
+ hs = lower.index("<!doctype html") if hs.nil?
383
+ return "" if hs.nil?
384
+
385
+ he = lower.rindex("</html>")
386
+ return "" if he.nil? || he < hs
387
+
388
+ body[hs...(he + 7)]
389
+ end
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "uri"
5
+
6
+ module TempmailSdk
7
+ module Providers
8
+ # Nukemail 渠道实现(nukemail.app)
9
+ #
10
+ # 完整接入契约:
11
+ # 挑战 GET /api/pow/challenge?difficulty=4 → 200 {"id","challenge","difficulty"};
12
+ # 求 nonce 使 SHA-256(challenge+nonce) 十六进制前 4 位为 0
13
+ # (前端 solvePow 逐 nonce 自 0 递增)。
14
+ # 建箱 POST /api/inbox/create body {"address","domain","pow_id","pow_nonce"}
15
+ # → 200 {"token":"NUKE-xxxxxxxx","email":"名@域名"},并
16
+ # Set-Cookie: nukemail_token=<token>(Secure; HttpOnly; SameSite=lax, 72h)。
17
+ # 读信 GET /api/inbox(Cookie: nukemail_token=<token>)→ 200
18
+ # {"token","state","addresses":[...],"messages":[...],"is_premium"...};
19
+ # messages 元素字段为 sender、sender_name、subject、body_html、body_text、
20
+ # received_at、read。
21
+ # 会话恢复 POST /api/inbox/resume {"accessCode":<token>} 重设 Cookie,仅作兜底。
22
+ # 会话隔离:nukemail_token 由生成结果持久化为 token,读信时以显式 Cookie 头携带。
23
+ module Nukemail
24
+ CHANNEL = "nukemail"
25
+ BASE_URL = "https://nukemail.app"
26
+
27
+ HEADERS = {
28
+ "Accept" => "application/json",
29
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
30
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
31
+ }.freeze
32
+
33
+ module_function
34
+
35
+ # 求解 PoW:返回使 SHA-256(challenge+nonce) 十六进制
36
+ # 前 difficulty 位为 0 的最小 nonce(算法与前端 solvePow 逐字对齐)。
37
+ # @param challenge [String] 挑战串
38
+ # @param difficulty [Integer] 难度(十六进制前缀 0 位数)
39
+ # @return [Integer] nonce
40
+ def solve_pow(challenge, difficulty)
41
+ prefix = "0" * difficulty
42
+ nonce = 0
43
+ loop do
44
+ digest = Digest::SHA256.hexdigest("#{challenge}#{nonce}")
45
+ break if digest.start_with?(prefix)
46
+
47
+ nonce += 1
48
+ end
49
+ nonce
50
+ end
51
+
52
+ # 生成本地随机名(与前端 generateRandomName 等价形态)
53
+ # @return [String]
54
+ def random_address
55
+ chars = "abcdefghijklmnopqrstuvwxyz0123456789"
56
+ "nuke" + Array.new(10) { chars[rand(chars.length)] }.join
57
+ end
58
+
59
+ # 取第一个非 premium 的活跃域名
60
+ # @return [String]
61
+ def default_domain
62
+ resp = Http.get("#{BASE_URL}/api/domains", headers: HEADERS, timeout: 15)
63
+ raise "nukemail generate: domains http #{resp.status_code}" unless resp.ok?
64
+
65
+ data = resp.json
66
+ domains = data.is_a?(Hash) ? data["domains"] : nil
67
+ raise "nukemail generate: domains 响应非对象" unless domains.is_a?(Array)
68
+
69
+ domains.each do |d|
70
+ next unless d.is_a?(Hash)
71
+
72
+ dom = d["domain"].to_s.strip
73
+ return dom if !dom.empty? && d["is_premium_only"] != true
74
+ end
75
+ raise "nukemail generate: 无可用非 premium 域名"
76
+ end
77
+
78
+ # 创建临时邮箱(PoW 建箱)
79
+ # token 为平台返回的 NUKE-<随机> 访问码,读信时转成 nukemail_token Cookie 携带。
80
+ # @return [EmailInfo]
81
+ def generate_email
82
+ # 1) 取 PoW 挑战
83
+ resp = Http.get("#{BASE_URL}/api/pow/challenge?difficulty=4",
84
+ headers: HEADERS, timeout: 15)
85
+ raise "nukemail generate: challenge http #{resp.status_code}" unless resp.ok?
86
+
87
+ ch = resp.json
88
+ raise "nukemail generate: challenge 响应非对象" unless ch.is_a?(Hash)
89
+
90
+ ch_id = ch["id"].to_s.strip
91
+ challenge = ch["challenge"].to_s.strip
92
+ raise "nukemail generate: challenge 响应缺少 id/challenge" if ch_id.empty? || challenge.empty?
93
+
94
+ difficulty = ch["difficulty"].to_i
95
+ difficulty = 4 if difficulty <= 0
96
+
97
+ # 2) 本地求 PoW 解(SHA-256 前缀 4 零)
98
+ nonce = solve_pow(challenge, difficulty)
99
+
100
+ # 3) 取域名并建箱
101
+ dom = default_domain
102
+ body = {
103
+ "address" => random_address,
104
+ "domain" => dom,
105
+ "pow_id" => ch_id,
106
+ "pow_nonce" => nonce.to_s
107
+ }
108
+ headers = {
109
+ "Content-Type" => "application/json",
110
+ "Accept" => "application/json",
111
+ "User-Agent" => HEADERS["User-Agent"]
112
+ }
113
+ resp2 = Http.post("#{BASE_URL}/api/inbox/create",
114
+ headers: headers, json: body, timeout: 15)
115
+ unless resp2.ok?
116
+ raise "nukemail generate: create http #{resp2.status_code}"
117
+ end
118
+ data = resp2.json
119
+ raise "nukemail generate: create 响应非对象" unless data.is_a?(Hash)
120
+
121
+ token = data["token"].to_s.strip
122
+ email = data["email"].to_s.strip
123
+ raise "nukemail generate: create 响应缺少 token/email" if token.empty? || email.empty?
124
+
125
+ EmailInfo.new(channel: CHANNEL, email: email, token: token)
126
+ end
127
+
128
+ # 读取收件箱
129
+ # 主通道 GET /api/inbox 带 Cookie: nukemail_token=<token>;
130
+ # 会话过期时经 POST /api/inbox/resume 显式重设会话后重试一次。
131
+ # @param email [String] 邮箱地址
132
+ # @param token [String] 建箱返回的 NUKE-<随机> 访问码
133
+ # @return [Array<Email>]
134
+ def get_emails(email, token)
135
+ addr = email.to_s.strip
136
+ tk = token.to_s.strip
137
+ raise "nukemail: token 为空" if tk.empty?
138
+
139
+ cookie = "nukemail_token=#{tk}"
140
+
141
+ fetch = lambda do
142
+ resp = Http.get("#{BASE_URL}/api/inbox",
143
+ headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
144
+ raise "nukemail 读信: http #{resp.status_code}" unless resp.ok?
145
+
146
+ resp.json
147
+ end
148
+
149
+ data = fetch.call
150
+ raise "nukemail 读信: 响应非对象" unless data.is_a?(Hash)
151
+
152
+ if data["state"].to_s.empty? || data["state"] == "expired"
153
+ # 会话可能已过期:经 resume 重设会话后重试
154
+ headers = {
155
+ "Content-Type" => "application/json",
156
+ "User-Agent" => HEADERS["User-Agent"]
157
+ }
158
+ begin
159
+ Http.post("#{BASE_URL}/api/inbox/resume",
160
+ headers: headers, json: { "accessCode" => tk }, timeout: 15)
161
+ rescue StandardError
162
+ nil
163
+ end
164
+ data = fetch.call
165
+ raise "nukemail 读信: 响应非对象" unless data.is_a?(Hash)
166
+ end
167
+
168
+ messages = data["messages"]
169
+ return [] unless messages.is_a?(Array)
170
+
171
+ messages.filter_map do |m|
172
+ next unless m.is_a?(Hash)
173
+
174
+ flat = m.dup
175
+ flat["to"] = addr
176
+ # 平台消息字段为 body_html/body_text,补齐 text/html 候选(若原字段缺失)
177
+ flat["text"] = flat["body_text"] if flat["text"].nil?
178
+ flat["html"] = flat["body_html"] if flat["html"].nil?
179
+ flat["date"] = m["received_at"]
180
+ flat["read"] = m["read"]
181
+ # sender 是小写发件人地址,sender_name 是展示名
182
+ flat["sender_email"] = m["sender"]
183
+ Normalize.normalize_email(flat, addr)
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end