tempmail_sdk 1.3.4 → 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 (42) 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/clawdemail.rb +122 -0
  6. data/lib/tempmail_sdk/providers/crazymailing.rb +114 -0
  7. data/lib/tempmail_sdk/providers/firetempmail.rb +86 -0
  8. data/lib/tempmail_sdk/providers/flybymail.rb +93 -0
  9. data/lib/tempmail_sdk/providers/generator_email.rb +146 -0
  10. data/lib/tempmail_sdk/providers/huskmail.rb +132 -0
  11. data/lib/tempmail_sdk/providers/internxt.rb +176 -0
  12. data/lib/tempmail_sdk/providers/linshi_xyz.rb +79 -0
  13. data/lib/tempmail_sdk/providers/mailmomy.rb +1 -1
  14. data/lib/tempmail_sdk/providers/mailticking.rb +139 -0
  15. data/lib/tempmail_sdk/providers/mtempmail.rb +140 -0
  16. data/lib/tempmail_sdk/providers/nowtempmail.rb +112 -0
  17. data/lib/tempmail_sdk/providers/noxen_de5_net.rb +392 -0
  18. data/lib/tempmail_sdk/providers/nukemail.rb +188 -0
  19. data/lib/tempmail_sdk/providers/nullmail.rb +95 -0
  20. data/lib/tempmail_sdk/providers/shadowmail.rb +174 -0
  21. data/lib/tempmail_sdk/providers/shitpost_email.rb +85 -0
  22. data/lib/tempmail_sdk/providers/smails.rb +128 -0
  23. data/lib/tempmail_sdk/providers/temp_mail_gg.rb +362 -0
  24. data/lib/tempmail_sdk/providers/tempmail100.rb +148 -0
  25. data/lib/tempmail_sdk/providers/tempmail_ee.rb +373 -0
  26. data/lib/tempmail_sdk/providers/tempmailportal.rb +131 -0
  27. data/lib/tempmail_sdk/providers/tempmails_io.rb +89 -0
  28. data/lib/tempmail_sdk/providers/tempmailto.rb +312 -0
  29. data/lib/tempmail_sdk/providers/temporarymail_com.rb +184 -0
  30. data/lib/tempmail_sdk/providers/tenmin_app.rb +100 -0
  31. data/lib/tempmail_sdk/providers/thirty_min_email.rb +90 -0
  32. data/lib/tempmail_sdk/providers/tmpkit.rb +129 -0
  33. data/lib/tempmail_sdk/providers/zerodrop.rb +90 -0
  34. data/lib/tempmail_sdk/registry.rb +129 -29
  35. data/lib/tempmail_sdk/types.rb +13 -0
  36. data/lib/tempmail_sdk/version.rb +1 -1
  37. data/lib/tempmail_sdk.rb +1 -1
  38. metadata +31 -7
  39. data/lib/tempmail_sdk/providers/gonebox_email.rb +0 -89
  40. data/lib/tempmail_sdk/providers/mohmal.rb +0 -208
  41. data/lib/tempmail_sdk/providers/temp_mail_now.rb +0 -103
  42. data/lib/tempmail_sdk/providers/webmailtemp.rb +0 -108
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "time"
6
+
7
+ module TempmailSdk
8
+ module Providers
9
+ # TempMailGG 渠道实现(temp-mail.gg,Laravel + Livewire v3)
10
+ #
11
+ # 全站无验证码,纯 HTTP 可实现:
12
+ # - GET https://temp-mail.gg/ 返回 data-csrf 属性令牌与初始
13
+ # wire:snapshot(JSON;data.email 初始为空,邮箱必须由 Livewire
14
+ # generateEmail 显式生成)。
15
+ # - POST https://temp-mail.gg/livewire/update 是唯一边界(同站
16
+ # fetch 协议):JSON body 顶层 _token(=data-csrf)+
17
+ # components[0]:{snapshot, updates:{}, calls:[]}。
18
+ # generateEmail 生成邮箱并回传新 snapshot。
19
+ # - 轮询 update(calls 为空 = 平台 20 秒刷信形态)响应
20
+ # effects.html 含收件箱 UI;逐封 selectEmail 拉详情正文。
21
+ # - Laravel 每次 livewire/update 轮换会话 Cookie,同值重放会 419,
22
+ # 必须用响应 Set-Cookie 覆写后续请求。
23
+ #
24
+ # 会话粘性:Generate 时把 {email, csrf, snapshot} 打成模块级凭据
25
+ # 状态;token 保存 JSON 凭据串。GetEmails 复用快照轮询/点开详情,
26
+ # 并以响应快照 data.email 断言会话仍指向本邮箱(防串箱)。
27
+ # 本端无全局 Cookie 罐,会话 Cookie 由本渠道模块级维护并逐请求
28
+ # 以显式 Cookie 头回填。邮箱约 30 分钟无活动过期。
29
+ module TempMailGg
30
+ CHANNEL = "temp-mail-gg"
31
+ BASE_URL = "https://temp-mail.gg"
32
+
33
+ # 渠道凭据串前缀,用于识别会话接管
34
+ TOKEN_PREFIX = "temp-mail-gg|"
35
+
36
+ USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " \
37
+ "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36"
38
+
39
+ HOME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9," \
40
+ "image/avif,image/webp,*/*;q=0.8"
41
+
42
+ # 模块级会话状态:站点 Cookie 键值 + Generate 时打包的凭据串
43
+ # (JSON:{email, csrf, snapshot},snapshot 为解析后的 Hash)
44
+ @cookies = {}
45
+ @session = nil
46
+
47
+ DATA_CSRF_RE = /data-csrf="([^"]*)"/
48
+ SNAPSHOT_RE = /wire:snapshot="([^"]*)"/
49
+ RELATIVE_RE = /^\s*(\d+)\s+(second|minute|hour|day)s?\s+ago\s*$/i
50
+
51
+ module_function
52
+
53
+ # 解析 Set-Cookie 头,覆写合并进模块级会话 Cookie 状态
54
+ # @param resp [Http::Response]
55
+ def store_cookies(resp)
56
+ resp.set_cookies.each do |line|
57
+ kv = line.split(";").first.to_s.strip
58
+ next if kv.empty?
59
+
60
+ k, v = kv.split("=", 2)
61
+ @cookies[k.strip] = v.to_s if k && !k.strip.empty?
62
+ end
63
+ end
64
+
65
+ # 由模块级 Cookie 状态拼出显式 Cookie 请求头
66
+ # @return [String, nil] 无 Cookie 时为 nil
67
+ def cookie_header
68
+ return nil if @cookies.empty?
69
+
70
+ @cookies.map { |k, v| "#{k}=#{v}" }.join("; ")
71
+ end
72
+
73
+ # 组装 livewire/update 同步请求头(同站 fetch 全套)
74
+ # @return [Hash]
75
+ def post_headers
76
+ hdrs = {
77
+ "User-Agent" => USER_AGENT,
78
+ "Accept" => "text/html, application/xhtml+xml",
79
+ "Accept-Language" => "en-US,en;q=0.9",
80
+ "Content-Type" => "application/json",
81
+ "X-Livewire" => "",
82
+ "X-Requested-With" => "XMLHttpRequest",
83
+ "Origin" => BASE_URL,
84
+ "Referer" => "#{BASE_URL}/"
85
+ }
86
+ hdrs["Cookie"] = cookie_header if cookie_header
87
+ hdrs
88
+ end
89
+
90
+ # 创建 temp-mail.gg 临时邮箱
91
+ # GET 首页取 CSRF + 初始快照 -> update calls=generateEmail ->
92
+ # 从响应快照取 data.email,并打包 {email, csrf, snapshot} 凭据串。
93
+ # 邮箱约 30 分钟无活动过期。
94
+ # @return [EmailInfo]
95
+ def generate_email
96
+ resp = Http.get(BASE_URL,
97
+ headers: {
98
+ "User-Agent" => USER_AGENT,
99
+ "Accept" => HOME_ACCEPT,
100
+ "Accept-Language" => "en-US,en;q=0.9"
101
+ },
102
+ timeout: 15)
103
+ raise "temp-mail-gg: 首页 http #{resp.status_code}" unless resp.ok?
104
+
105
+ store_cookies(resp)
106
+ page = resp.body
107
+ m_csrf = page.match(DATA_CSRF_RE)
108
+ m_snap = page.match(SNAPSHOT_RE)
109
+ if m_csrf.nil? || m_snap.nil?
110
+ raise "temp-mail-gg: 首页缺少 data-csrf 或 wire:snapshot,无法建箱"
111
+ end
112
+ csrf = m_csrf[1]
113
+ # 属性内快照 JSON 被 HTML 实体转义,一层反转义后还原为原始 JSON
114
+ snap_raw = CGI.unescapeHTML(m_snap[1])
115
+
116
+ # generateEmail: 平台免费额度为免登录每时段 5 个,耗尽时响应无 email
117
+ update = livewire_update(
118
+ { "snapshot" => snap_raw, "updates" => {}, "calls" => [{ "path" => "", "method" => "generateEmail", "params" => [] }] },
119
+ csrf
120
+ )
121
+ comp = update["components"][0]
122
+ raise "temp-mail-gg: generateEmail 响应异常(components 缺失)" if comp.nil?
123
+
124
+ snapshot_text = comp["snapshot"].to_s
125
+ if snapshot_text.strip.empty?
126
+ raise "temp-mail-gg: generateEmail 响应异常(components 缺失)"
127
+ end
128
+
129
+ begin
130
+ snapshot = JSON.parse(snapshot_text)
131
+ rescue JSON::ParserError
132
+ raise "temp-mail-gg: 解析 generateEmail 响应快照失败"
133
+ end
134
+ data = snapshot.is_a?(Hash) ? snapshot["data"] : nil
135
+ email = data.is_a?(Hash) ? data["email"].to_s.strip : ""
136
+ if email.empty?
137
+ raise "temp-mail-gg: 建箱失败(响应快照无 email),可能已耗尽免登录配额(每时段 5 个)"
138
+ end
139
+
140
+ token = TOKEN_PREFIX + JSON.generate("email" => email, "csrf" => csrf, "snapshot" => snapshot)
141
+
142
+ EmailInfo.new(channel: CHANNEL, email: email, token: token,
143
+ expires_at: ((Time.now.to_f + 30 * 60) * 1000).to_i)
144
+ end
145
+
146
+ # 读取 temp-mail.gg 收件箱
147
+ # 流程:校验凭据串 -> 轮询 update(无 calls)取 Inbox 列表 ->
148
+ # 对每封 selectEmail 提取详情正文。
149
+ # @param email [String] 邮箱地址(与 token 内会话邮箱一致才继续)
150
+ # @param token [String] 凭据串(prefix | {email,csrf,snapshot})
151
+ # @return [Array<Email>]
152
+ def get_emails(email, token)
153
+ addr = email.to_s.strip
154
+ raise "temp-mail-gg: 邮箱为空,请重新 Generate" if addr.empty?
155
+
156
+ session = decode_session(token)
157
+ if session["email"].to_s.casecmp(addr) != 0
158
+ raise "temp-mail-gg: 邮箱与凭据不匹配"
159
+ end
160
+
161
+ # 轮询刷新(calls 为空 = 平台 20s 自动刷新形态)
162
+ poll = livewire_update(
163
+ { "snapshot" => JSON.generate(session["snapshot"]), "updates" => {}, "calls" => [] },
164
+ session["csrf"]
165
+ )
166
+ comp = poll["components"][0]
167
+ raise "temp-mail-gg: 轮询响应异常(components 缺失)" if comp.nil?
168
+
169
+ if comp["snapshot"].to_s.strip != ""
170
+ begin
171
+ poll_snap = JSON.parse(comp["snapshot"])
172
+ if poll_snap.is_a?(Hash)
173
+ current = poll_snap["data"].is_a?(Hash) ? poll_snap["data"]["email"].to_s.strip : ""
174
+ if !current.empty? && current.casecmp(addr) != 0
175
+ raise "temp-mail-gg: 会话已被切换"
176
+ end
177
+ end
178
+ rescue JSON::ParserError
179
+ # 快照解析失败不阻断本轮,透传原快照继续
180
+ end
181
+ end
182
+ html_block = comp.dig("effects", "html").to_s.strip
183
+ if html_block.empty?
184
+ raise "temp-mail-gg: 轮询响应无 effects.html,会话可能已失效"
185
+ end
186
+
187
+ rows = parse_list(html_block)
188
+ return [] if rows.empty?
189
+
190
+ latest_snap = comp["snapshot"].to_s.strip
191
+ latest_snap = JSON.generate(session["snapshot"]) if latest_snap.empty?
192
+
193
+ rows.filter_map do |row|
194
+ next if row[:id].empty?
195
+
196
+ build_email(row, addr, session["csrf"], latest_snap)
197
+ end
198
+ end
199
+
200
+ # 解析凭据串 -> 会话 Hash(email + csrf + snapshot)
201
+ # @param token [String]
202
+ # @return [Hash]
203
+ def decode_session(token)
204
+ t = token.to_s
205
+ raise "temp-mail-gg: 凭据串前缀不符,请重新 Generate" unless t.start_with?(TOKEN_PREFIX)
206
+
207
+ begin
208
+ session = JSON.parse(t[TOKEN_PREFIX.length..])
209
+ rescue JSON::ParserError
210
+ raise "temp-mail-gg: 解析凭据串失败"
211
+ end
212
+ unless session.is_a?(Hash) && !session["email"].to_s.empty? && !session["snapshot"].to_s.empty?
213
+ raise "temp-mail-gg: 凭据串缺失邮箱或快照,请重新 Generate"
214
+ end
215
+
216
+ session
217
+ end
218
+
219
+ # 调用 livewire/update(响应 Set-Cookie 覆写模块级 Cookie 状态)
220
+ # @param component [Hash] components[0]:{snapshot, updates, calls}
221
+ # @param csrf [String] 顶层 _token
222
+ # @return [Hash] 解析后的响应 JSON
223
+ def livewire_update(component, csrf)
224
+ payload = JSON.generate("_token" => csrf, "components" => [component])
225
+ resp = Http.post("#{BASE_URL}/livewire/update",
226
+ headers: post_headers, body: payload, timeout: 15)
227
+ if resp.status_code == 419
228
+ raise "temp-mail-gg: livewire 会话过期(419),请重新 Generate"
229
+ end
230
+ raise "temp-mail-gg: livewire/update http #{resp.status_code}" unless resp.ok?
231
+
232
+ store_cookies(resp)
233
+ data = resp.json
234
+ raise "temp-mail-gg: livewire/update 响应非对象" unless data.is_a?(Hash)
235
+
236
+ data
237
+ end
238
+
239
+ # 解析轮询 effects.html 的 Inbox 条目
240
+ # 条目容器 <div wire:click="selectEmail(<数字id>)">,其内
241
+ # h3=发件人、p=主题、p=正文预览、span=相对时间。
242
+ # @param html_block [String]
243
+ # @return [Array<Hash>]
244
+ def parse_list(html_block)
245
+ rows = []
246
+ html_block.scan(/<div[^>]*wire:click="selectEmail\((\d+)\)"[^>]*>(.*?)<\/div>/mi).each do |cap|
247
+ id, chunk = cap
248
+ row = { id: id, from: "", subject: "", preview: "", when: "" }
249
+
250
+ # 向内收窄提取:h3(类含 font-semibold)、p(类含 text-zinc-300 / line-clamp-2)、span(类含 text-xs)
251
+ row[:from] = pick_class_text(chunk, "<h3", /font-semibold/)
252
+ row[:subject] = pick_class_text(chunk, "<p", /text-zinc-300/)
253
+ row[:preview] = pick_class_text(chunk, "<p", /line-clamp-2/)
254
+ row[:when] = pick_class_text(chunk, "<span", /text-xs/)
255
+
256
+ rows << row
257
+ end
258
+ rows
259
+ end
260
+
261
+ # 提取首个 class 含指定模式的标签内容并去 HTML 标签
262
+ # @param html [String] 范围 HTML
263
+ # @param tag [String] 起始标签(如 "<p")
264
+ # @param class_re [Regexp] class 匹配模式
265
+ # @return [String]
266
+ def pick_class_text(html, tag, class_re)
267
+ m = html.match(/#{Regexp.escape(tag)}\s+class="[^"]*#{class_re.source}[^"]*"[^>]*>(.*?)<\//mi)
268
+ return "" if m.nil?
269
+
270
+ HtmlUtils.html_to_text(m[1])
271
+ end
272
+
273
+ # 逐封构建 Email:selectEmail 拉详情,失败回退列表字段
274
+ # @param row [Hash] 列表行
275
+ # @param email [String] 收件人
276
+ # @param csrf [String] 凭据 CSRF
277
+ # @param snapshot [String] 当前最新快照 JSON 串(每封详情后滚动更新)
278
+ # @return [Email, nil]
279
+ def build_email(row, email, csrf, snapshot)
280
+ text = ""
281
+ detail = livewire_update(
282
+ { "snapshot" => snapshot, "updates" => {}, "calls" => [{ "path" => "", "method" => "selectEmail", "params" => [row[:id].to_i] }] },
283
+ csrf
284
+ )
285
+ dcomp = detail["components"][0]
286
+ unless dcomp.nil?
287
+ dhtml = dcomp.dig("effects", "html").to_s
288
+ parsed = parse_detail(dhtml)
289
+ row[:from] = parsed[:from] unless parsed[:from].empty?
290
+ row[:subject] = parsed[:subject] unless parsed[:subject].empty?
291
+ text = parsed[:text] if !parsed[:text].empty? || text.empty?
292
+ end
293
+
294
+ subject = row[:subject]
295
+ text = subject if text.empty?
296
+ html = "<html><body><pre>#{HtmlUtils.escape(text)}</pre></body></html>"
297
+ date = parse_relative(row[:when])
298
+
299
+ Normalize.normalize_email(
300
+ {
301
+ "id" => row[:id],
302
+ "from_email" => row[:from],
303
+ "to" => email,
304
+ "subject" => subject,
305
+ "text" => text,
306
+ "html" => html,
307
+ "date" => date
308
+ },
309
+ email
310
+ )
311
+ rescue StandardError
312
+ nil
313
+ end
314
+
315
+ # 解析 selectEmail 详情视图(模态框):h3=主题、From: 前缀=发件人、
316
+ # x-show 含 activeTab === 'text' 的 div=正文(含标签则去标签)。
317
+ # @param html_block [String]
318
+ # @return [Hash] {from:, subject:, text:}
319
+ def parse_detail(html_block)
320
+ out = { from: "", subject: "", text: "" }
321
+ html_block.scan(/<h3[^>]*>(.*?)<\/h3>/mi).each do |m|
322
+ cls = html_block[/<h3[^>]*class="([^"]*)"[^>]*>/i, 1].to_s
323
+ next if cls.empty?
324
+
325
+ next unless cls.split.include?("text-xl")
326
+
327
+ out[:subject] = HtmlUtils.html_to_text(m[0])
328
+ break
329
+ end
330
+
331
+ html_block.scan(/<span[^>]*>(.*?)<\/span>/mi).each do |m|
332
+ inner = HtmlUtils.html_to_text(m[0])
333
+ next unless inner.start_with?("From:")
334
+
335
+ out[:from] = inner.sub(/\AFrom:\s*/i, "").strip
336
+ break
337
+ end
338
+
339
+ if (m = html_block.match(%r{<div[^>]*x-show="[^"]*activeTab === 'text'[^"]*"[^>]*>(.*?)</div>}mi))
340
+ inner = m[1]
341
+ inner = HtmlUtils.html_to_text(inner) if inner =~ /<[^>]+>/
342
+ out[:text] = inner.strip
343
+ end
344
+ out
345
+ end
346
+
347
+ # 解析相对时间("N seconds/minutes/hours/days ago")为 UTC ISO8601
348
+ # @param s [String]
349
+ # @return [String]
350
+ def parse_relative(s)
351
+ m = s.to_s.strip.match(RELATIVE_RE)
352
+ return Time.now.utc.iso8601 if m.nil?
353
+
354
+ n = m[1].to_i
355
+ unit = { "second" => 1, "minute" => 60, "hour" => 3600, "day" => 86_400 }[m[2].downcase]
356
+ (Time.now.utc - n * unit).iso8601
357
+ rescue StandardError
358
+ Time.now.utc.iso8601
359
+ end
360
+ end
361
+ end
362
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TempmailSdk
4
+ module Providers
5
+ # TempMail100 渠道实现(tempmail100.com)
6
+ #
7
+ # POST /init 建箱(空 body,响应 code/data.token(JWT),无 Cookie),
8
+ # POST /web/generate 建随机地址(Header Authorization: <token>,响应 data.address),
9
+ # GET /web/emails 读信列表(Header Authorization: <token>,响应 data.list[]/data.total)。
10
+ #
11
+ # 平台限制(实测确认,非 SDK 缺陷):
12
+ # 列表元素 content 恒为空字符串,详情端点对真实 token 返回 HTTP 200 + 空 body,
13
+ # 因此正文永久不可得,本渠道客观为「列表-only」:subject/fromAddress/fromName/
14
+ # timestamp/read 可正确输出,正文恒为空属平台限制。
15
+ module Tempmail100
16
+ CHANNEL = "tempmail100"
17
+ BASE_URL = "https://tempmail100.com"
18
+
19
+ HEADERS = {
20
+ "Accept" => "application/json",
21
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
22
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
23
+ }.freeze
24
+
25
+ module_function
26
+
27
+ # 设置 tempmail100 请求的通用请求头
28
+ # 前端使用 Authorization: <token> 不带 Bearer。
29
+ # @param token [String] JWT
30
+ # @return [Hash]
31
+ def auth_headers(token)
32
+ hdrs = HEADERS.dup
33
+ hdrs["Authorization"] = token unless token.to_s.empty?
34
+ hdrs
35
+ end
36
+
37
+ # 创建 tempmail100.com 临时邮箱
38
+ # 流程:POST /init 取得 JWT token,再 POST /web/generate 创建地址。
39
+ # @return [EmailInfo]
40
+ def generate_email
41
+ # 第一步:初始化取得 token
42
+ resp = Http.post("#{BASE_URL}/init", headers: auth_headers(""), timeout: 15)
43
+ raise "tempmail100: 初始化失败 http #{resp.status_code}" unless resp.ok?
44
+
45
+ init = resp.json
46
+ raise "tempmail100: 初始化响应非对象" unless init.is_a?(Hash)
47
+
48
+ token = init.dig("data", "token").to_s.strip
49
+ raise "tempmail100: 初始化响应异常" if init["code"] != 0 || token.empty?
50
+
51
+ # 第二步:创建随机地址(Authorization: <token> 不带 Bearer)
52
+ resp2 = Http.post("#{BASE_URL}/web/generate",
53
+ headers: auth_headers(token), timeout: 15)
54
+ raise "tempmail100: 创建地址失败 http #{resp2.status_code}" unless resp2.ok?
55
+
56
+ gen = resp2.json
57
+ raise "tempmail100: 创建地址响应非对象" unless gen.is_a?(Hash)
58
+
59
+ address = gen.dig("data", "address").to_s.strip
60
+ unless gen["code"] == 0 && !address.empty? && address.include?("@")
61
+ raise "tempmail100: 创建地址响应异常"
62
+ end
63
+
64
+ EmailInfo.new(channel: CHANNEL, email: address, token: token)
65
+ end
66
+
67
+ # 获取 tempmail100.com 邮件列表
68
+ # 流程:GET /web/emails(Authorization: <token> 不带 Bearer)返回 data.list/data.total。
69
+ # @param email [String] 邮箱地址
70
+ # @param token [String] 初始化返回的 JWT 认证令牌
71
+ # @return [Array<Email>]
72
+ def get_emails(email, token)
73
+ addr = email.to_s.strip
74
+ resp = Http.get("#{BASE_URL}/web/emails",
75
+ headers: auth_headers(token), timeout: 15)
76
+ raise "tempmail100: 获取邮件列表失败 http #{resp.status_code}" unless resp.ok?
77
+
78
+ data = resp.json
79
+ raise "tempmail100: 获取邮件列表响应非对象" unless data.is_a?(Hash)
80
+
81
+ unless data["code"] == 0
82
+ raise "tempmail100: 获取邮件列表响应异常: #{data["message"]}"
83
+ end
84
+ inner = data["data"]
85
+ list = inner.is_a?(Hash) ? inner["list"] : nil
86
+ return [] unless list.is_a?(Array)
87
+
88
+ list.filter_map do |m|
89
+ next unless m.is_a?(Hash)
90
+
91
+ Normalize.normalize_email(normalize_item(m, addr), addr)
92
+ end
93
+ end
94
+
95
+ # 将 /web/emails 列表元素归一为统一邮件结构。
96
+ # fromName+fromAddress 组合为 "Name <address>" 填入 from;
97
+ # timestamp 为毫秒值(>1e12 时按 UnixMilli 解析);read 为布尔已读标记。
98
+ # @param item [Hash] 列表元素
99
+ # @param email [String] 收件人地址
100
+ # @return [Hash]
101
+ def normalize_item(item, email)
102
+ from_name = str_of(item["fromName"]).strip
103
+ from_address = str_of(item["fromAddress"]).strip
104
+ unless from_name.empty? || from_name.casecmp(from_address).zero? || !from_address.include?("@")
105
+ from_address = "#{from_name} <#{from_address}>"
106
+ end
107
+
108
+ {
109
+ "id" => str_of(item["uuid"]),
110
+ "from" => from_address,
111
+ "to" => str_of(item["toAddress"]).strip,
112
+ "subject" => str_of(item["subject"]),
113
+ "content" => str_of(item["content"]),
114
+ "timestamp" => item["timestamp"],
115
+ "isRead" => read_of(item["read"])
116
+ }
117
+ end
118
+
119
+ # 将接口字段值安全转换为字符串,nil 或非标量返回空串
120
+ # @param v [Object]
121
+ # @return [String]
122
+ def str_of(v)
123
+ case v
124
+ when String then v
125
+ when Numeric then v.to_i.to_s
126
+ when true then "true"
127
+ when false then "false"
128
+ else ""
129
+ end
130
+ end
131
+
132
+ # 将 read 字段归一为布尔已读标记,兼容 bool / 数字(0|1) / string("true"|"1")
133
+ # @param v [Object]
134
+ # @return [Boolean]
135
+ def read_of(v)
136
+ case v
137
+ when true then true
138
+ when false then false
139
+ when Numeric then v != 0
140
+ when String
141
+ s = v.strip
142
+ s.casecmp("true").zero? || s == "1"
143
+ else false
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end