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,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "cgi"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module TempmailSdk
9
+ module Providers
10
+ # Tempmail.ee 渠道实现(tempmail.ee)
11
+ #
12
+ # 平台读信端 /api/mails 的 403 Access denied 是「会话 Cookie 绑定校验」:
13
+ # temp_email 与 temp_mail_session 共同构成读信凭据,缺一即 403。
14
+ # 只要在同一事务内完成 change → 提取 Set-Cookie → 读信,即可真实读到邮件。
15
+ #
16
+ # 已验证通行配方:
17
+ # - POST /api/mailbox/change 若不带 sec-ch-ua 头会被平台拒绝
18
+ # (403 Browser request required),带齐即 200,并下发会话 Cookie;
19
+ # - POST /api/mails 带完整 sec-ch-ua 三件套 + 会话 Cookie 即 200。
20
+ # 本端无全局 Cookie 罐,会话凭据由本渠道以显式 Cookie 请求头逐请求携带。
21
+ module TempmailEe
22
+ CHANNEL = "tempmail-ee"
23
+ BASE_URL = "https://tempmail.ee"
24
+
25
+ # 与 sec-ch-ua 品牌版本一致的固定 UA(Chrome 154 / Linux)
26
+ USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " \
27
+ "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36"
28
+
29
+ # Token 前缀,用于识别本渠道会话凭据串
30
+ TOKEN_PREFIX = "tempmail-ee|"
31
+
32
+ SEC_CH_HEADERS = {
33
+ "Sec-Ch-Ua" => '"Chromium";v="154", "Google Chrome";v="154", "Not.A/Brand";v="99"',
34
+ "Sec-Ch-Ua-Mobile" => "?0",
35
+ "Sec-Ch-Ua-Platform" => '"Linux"'
36
+ }.freeze
37
+
38
+ # 建箱提交的浏览器指纹(与官方前端一致)
39
+ BROWSER_INTEGRITY = {
40
+ "webdriver" => false, "languagesMissing" => false, "languageMissing" => false,
41
+ "pluginsMissing" => false, "pluginsUndefined" => false, "outerSizeMissing" => false,
42
+ "innerSizeMissing" => false, "screenMissing" => false, "screenDepthMissing" => false,
43
+ "timezoneMissing" => false, "timezoneOffsetMissing" => false,
44
+ "userAgentDataPresent" => true, "userAgentMissing" => false,
45
+ "platformClass" => "Linux", "mobile" => false, "collectionFailed" => false
46
+ }.freeze
47
+
48
+ module_function
49
+
50
+ # 组装浏览器特征安全头(同站 fetch 全套)
51
+ # @param with_sec_ch [Boolean] 是否携带 sec-ch-ua 三件套(change 必须)
52
+ # @return [Hash]
53
+ def browser_headers(with_sec_ch)
54
+ hdrs = {
55
+ "Accept" => "application/json",
56
+ "Content-Type" => "application/json",
57
+ "X-Requested-With" => "XMLHttpRequest",
58
+ "Origin" => BASE_URL,
59
+ "Referer" => "#{BASE_URL}/",
60
+ "Sec-Fetch-Site" => "same-origin",
61
+ "Sec-Fetch-Mode" => "cors",
62
+ "Sec-Fetch-Dest" => "empty",
63
+ "Accept-Language" => "en-US,en;q=0.9",
64
+ "User-Agent" => USER_AGENT
65
+ }
66
+ hdrs.merge!(SEC_CH_HEADERS) if with_sec_ch
67
+ hdrs
68
+ end
69
+
70
+ # 创建 tempmail.ee 临时邮箱
71
+ # 事务会话:GET / 面熟首访 → POST /api/mailbox/change(sec-ch-ua 全套头)
72
+ # → 提取 Set-Cookie 中的 temp_mail_session,随 token 透传给读信。
73
+ # @return [EmailInfo]
74
+ def generate_email
75
+ # 步骤 1:GET / 建立会话面熟(响应 Cookie 不参与后续,凭据以 change 为准)
76
+ begin
77
+ Http.get(BASE_URL,
78
+ headers: {
79
+ "User-Agent" => USER_AGENT,
80
+ "Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9," \
81
+ "image/avif,image/webp,*/*;q=0.8"
82
+ },
83
+ timeout: 15)
84
+ rescue StandardError
85
+ nil
86
+ end
87
+
88
+ # 步骤 2:POST /api/mailbox/change 提交指纹换新邮箱(必须带 sec-ch-ua)
89
+ body = { "turnstileToken" => nil, "browserIntegrity" => BROWSER_INTEGRITY }
90
+ ch = Http.post("#{BASE_URL}/api/mailbox/change",
91
+ headers: browser_headers(true), json: body, timeout: 15)
92
+ data = ch.json
93
+ email = data.is_a?(Hash) && data["success"] ? data["newEmail"].to_s.strip : ""
94
+ raise "tempmail-ee: 建箱响应缺少邮箱(status #{ch.status_code})" if email.empty?
95
+
96
+ # 步骤 3:从 Set-Cookie 接管会话凭据,供读信校验使用
97
+ session = ch.cookie_value("temp_mail_session").to_s.strip
98
+ raise "tempmail-ee: 建箱成功但未下发会话 Cookie,无法读信" if session.empty?
99
+
100
+ expires_at = data["expiresAt"].to_s
101
+ if expires_at.empty?
102
+ expires_at = ((Time.now.to_f + 60 * 60) * 1000).to_i
103
+ end
104
+ EmailInfo.new(channel: CHANNEL, email: email,
105
+ token: "#{TOKEN_PREFIX}temp_email=#{email}; temp_mail_session=#{session}",
106
+ expires_at: expires_at)
107
+ end
108
+
109
+ # 解析读信凭据(防御 token 与 email 不一致,以请求邮箱为准重拼 Cookie)
110
+ # @param token [String] Generate 时下发的渠道凭据串
111
+ # @param email [String] 请求读取的邮箱
112
+ # @return [String] 显式 Cookie 请求头(temp_email + temp_mail_session)
113
+ def resolve_cookie(token, email)
114
+ raise "tempmail-ee: 会话凭据缺失或已失效,请重新 Generate 获取新邮箱" unless token.to_s.start_with?(TOKEN_PREFIX)
115
+
116
+ cred = token.to_s[TOKEN_PREFIX.length..]
117
+ session = ""
118
+ cred.split(";").each do |part|
119
+ kv = part.strip
120
+ session = kv[("temp_mail_session=".length)..] if kv.start_with?("temp_mail_session=")
121
+ end
122
+ raise "tempmail-ee: 会话凭据缺失或已失效,请重新 Generate 获取新邮箱" if session.empty?
123
+
124
+ "temp_email=#{email}; temp_mail_session=#{session}"
125
+ end
126
+
127
+ # 读取 tempmail.ee 收件箱
128
+ # POST /api/mails(显式会话 Cookie + sec-ch-ua);
129
+ # 列表只含元数据(id/fromAddress/toAddress/subject/createdAt/isRead),
130
+ # 正文逐封 GET /api/mails/{id},content 为 MIME multipart 原文(HTML 实体转义版)。
131
+ # @param email [String] 邮箱地址
132
+ # @param token [String] 渠道会话凭据串
133
+ # @return [Array<Email>]
134
+ def get_emails(email, token)
135
+ addr = email.to_s.strip
136
+ raise "tempmail-ee: 邮箱为空" if addr.empty?
137
+ raise "tempmail-ee: token 为空" if token.to_s.strip.empty?
138
+
139
+ cookie = resolve_cookie(token, addr)
140
+ hdrs = browser_headers(true).merge("Cookie" => cookie)
141
+
142
+ resp = Http.post("#{BASE_URL}/api/mails",
143
+ headers: hdrs, json: { "email" => addr }, timeout: 15)
144
+ if resp.status_code == 403
145
+ raise "tempmail-ee inbox: http 403(会话 Cookie 校验失败,邮箱可能已过期,请重新 Generate)"
146
+ end
147
+ resp.raise_for_status
148
+ data = resp.json
149
+ mails = data.is_a?(Hash) ? data["mails"] : nil
150
+ return [] unless mails.is_a?(Array)
151
+
152
+ mails.filter_map do |row|
153
+ next unless row.is_a?(Hash)
154
+
155
+ mail = row_skeleton(row, addr)
156
+ next if mail.nil?
157
+
158
+ fetch_detail(mail, cookie)
159
+ mail
160
+ end
161
+ end
162
+
163
+ # 将列表行(仅元数据)转换为 Email 骨架;id 缺失视为无效行
164
+ # @param row [Hash] 列表元素(id/fromAddress/toAddress/subject/createdAt/isRead)
165
+ # @param email [String] 当前邮箱
166
+ # @return [Email, nil]
167
+ def row_skeleton(row, email)
168
+ id = row["id"].to_s.strip
169
+ return nil if id.empty?
170
+
171
+ to_addr = row["toAddress"].to_s.strip
172
+ to_addr = email if to_addr.empty?
173
+ created = row["createdAt"].to_s.strip
174
+ created = Time.now.utc.iso8601 if created.empty?
175
+ Email.new(
176
+ id: id,
177
+ from_addr: row["fromAddress"].to_s.strip,
178
+ to: to_addr,
179
+ subject: row["subject"].to_s.strip,
180
+ date: created,
181
+ is_read: row["isRead"] == true || row["isRead"] == 1 || row["isRead"].to_s == "1"
182
+ )
183
+ end
184
+
185
+ # 拉取单封详情并填充正文;失败不阻塞列表其余邮件
186
+ # GET /api/mails/{id}(带显式会话 Cookie)
187
+ # @param mail [Email] 列表骨架
188
+ # @param cookie [String] 会话 Cookie 头
189
+ def fetch_detail(mail, cookie)
190
+ hdrs = browser_headers(false).merge("Cookie" => cookie)
191
+ resp = Http.get("#{BASE_URL}/api/mails/#{URI.encode_www_form_component(mail.id)}",
192
+ headers: hdrs, timeout: 15)
193
+ return unless resp.ok?
194
+
195
+ detail = begin
196
+ resp.json
197
+ rescue StandardError
198
+ return
199
+ end
200
+ return unless detail.is_a?(Hash)
201
+
202
+ content = detail["content"].to_s
203
+ if content.empty?
204
+ # content 缺失时回退兜底候选键,避免平台字段演进后正文丢失
205
+ content = detail["text"].to_s
206
+ content = detail["body"].to_s if content.empty?
207
+ content = detail["html"].to_s if content.empty?
208
+ return if content.empty?
209
+ end
210
+
211
+ text, html = parse_content(content)
212
+ mail.text = text if mail.text.empty?
213
+ mail.html = html if mail.html.empty?
214
+ mail.from_addr = detail["fromAddress"].to_s if mail.from_addr.empty?
215
+ mail.subject = detail["subject"].to_s if mail.subject.empty?
216
+ end
217
+
218
+ # 解析详情 content(MIME multipart 原文,HTML 实体已转义)
219
+ # 1) 反转义;2) 按首个 boundary 行切块解码;3) text/data-html 互为兜底合成。
220
+ # @param raw [String] 原始 content 字符串
221
+ # @return [Array(String, String)] [纯文本正文, HTML 正文]
222
+ def parse_content(raw)
223
+ payload = CGI.unescapeHTML(raw.to_s)
224
+ payload = payload.gsub("&#61;", "=").gsub("&#43;", "+").gsub("&#47;", "/")
225
+ payload = payload.gsub("\r\n", "\n")
226
+
227
+ lines = payload.split("\n", -1)
228
+ boundary = boundary_of(lines)
229
+ text = ""
230
+ html = ""
231
+ if boundary.empty?
232
+ text = singleton_part(payload)
233
+ else
234
+ text, html = parse_parts(lines, boundary)
235
+ end
236
+ if text.empty? && html.empty?
237
+ text = singleton_part(payload)
238
+ end
239
+ if text.empty? && !html.empty?
240
+ text = HtmlUtils.html_to_text(html)
241
+ end
242
+ if html.empty? && !text.empty?
243
+ html = "<html><body><pre>#{HtmlUtils.escape(text)}</pre></body></html>"
244
+ end
245
+ [text, html]
246
+ end
247
+
248
+ # 扫描首块寻找 multipart 边界行("--" 前缀)
249
+ # @param lines [Array<String>]
250
+ # @return [String] 边界串(无则空串)
251
+ def boundary_of(lines)
252
+ lines.first(120).each do |line|
253
+ return line[2..].to_s.strip if line.start_with?("--") && line.length > 2
254
+ end
255
+ ""
256
+ end
257
+
258
+ # 按 boundary 拆分 multipart 并解码归并 text/html 两个 part
259
+ # @param lines [Array<String>]
260
+ # @param boundary [String] 边界串
261
+ # @return [Array(String, String)] [纯文本正文, HTML 正文]
262
+ def parse_parts(lines, boundary)
263
+ text = ""
264
+ html = ""
265
+ i = 0
266
+ while i < lines.length
267
+ line = lines[i]
268
+ unless line.start_with?("--#{boundary}")
269
+ i += 1
270
+ next
271
+ end
272
+ break if line.start_with?("--#{boundary}--")
273
+
274
+ headers = {}
275
+ j = i + 1
276
+ # part 头部:直到首个空行
277
+ while j < lines.length && lines[j] != "" && !lines[j].start_with?("--#{boundary}")
278
+ ln = lines[j]
279
+ if (k = ln.index(":")) && k.positive?
280
+ headers[ln[0...k].strip.downcase] = ln[(k + 1)..].to_s.strip
281
+ end
282
+ j += 1
283
+ end
284
+ j += 1 if j < lines.length && lines[j] == ""
285
+ # part 正文:到下一个边界行为止
286
+ body = []
287
+ while j < lines.length && !lines[j].start_with?("--#{boundary}")
288
+ body << lines[j]
289
+ j += 1
290
+ end
291
+ text, html = merge_part(body, headers, text, html)
292
+ i = j
293
+ end
294
+ [text, html]
295
+ end
296
+
297
+ # 单个 part 的解码归并:按 content-type 归类,缺省回退 text 槽
298
+ # @param body [Array<String>] part 正文行
299
+ # @param headers [Hash] part 头部
300
+ # @param text [String] 已归并纯文本
301
+ # @param html [String] 已归并 HTML
302
+ # @return [Array(String, String)]
303
+ def merge_part(body, headers, text, html)
304
+ ct = headers["content-type"].to_s.downcase.split(";").first
305
+ cte = headers["content-transfer-encoding"].to_s.downcase
306
+ content = decode_part(body.join("\n"), cte)
307
+ if ct.include?("text/plain")
308
+ text = content if text.empty?
309
+ elsif ct.include?("text/html")
310
+ html = content if html.empty?
311
+ elsif text.empty?
312
+ text = content
313
+ end
314
+ [text, html]
315
+ end
316
+
317
+ # 单 part(无边界或拆不出内容)降级解析:
318
+ # 头部区隔(首个空行之后)取 body,并识别 Content-Transfer-Encoding 解码
319
+ # @param payload [String]
320
+ # @return [String]
321
+ def singleton_part(payload)
322
+ body = payload.strip
323
+ return body unless body.include?("\n")
324
+
325
+ lines = payload.split("\n", -1)
326
+ # 头部以 RFC822 形式出现时,正文从首个空行后开始
327
+ lines.each_with_index do |ln, idx|
328
+ if ln.strip.empty?
329
+ body = lines[(idx + 1)..].join("\n")
330
+ break
331
+ end
332
+ break if idx > 40 || (idx >= 3 && !ln.include?(":"))
333
+ end
334
+ lower = payload.downcase
335
+ cte = "base64" if lower.include?("base64")
336
+ cte = "quoted-printable" if lower.include?("quoted-printable")
337
+ decode_part(body, cte.to_s)
338
+ end
339
+
340
+ # 按 Content-Transfer-Encoding 解码 part 内容
341
+ # @param data [String]
342
+ # @param cte [String]
343
+ # @return [String]
344
+ def decode_part(data, cte)
345
+ data = data.to_s.strip
346
+ case cte
347
+ when "base64"
348
+ joined = data.gsub(/[\r\n\t ]/, "")
349
+ begin
350
+ Base64.strict_decode64(joined).strip.force_encoding("UTF-8")
351
+ rescue ArgumentError
352
+ data
353
+ end
354
+ when "quoted-printable"
355
+ decode_quoted_printable(data)
356
+ else
357
+ data
358
+ end
359
+ end
360
+
361
+ # 最小 quoted-printable 解码(=XX 十六进制转字节,行尾 = 软换行)
362
+ # @param data [String]
363
+ # @return [String]
364
+ def decode_quoted_printable(data)
365
+ out = data.gsub(/=\r?\n/, "")
366
+ out = out.gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
367
+ out.force_encoding("UTF-8")
368
+ rescue StandardError
369
+ data
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module TempmailSdk
6
+ module Providers
7
+ # Tempmailportal 渠道实现(api.tempmailportal.com)
8
+ #
9
+ # 流程:
10
+ # POST /api/v2/inbox 建箱(空 JSON body,响应 address/token/private/expiresAt/retentionMs,
11
+ # token 为 p2 前缀)
12
+ # GET /api/messages 读信(Header Authorization: Bearer <token>)
13
+ # GET /api/messages/{id} 取单封详情(Bearer)
14
+ # 列表元素按多候选字段归一;详情拉取失败时以列表摘要兜底。
15
+ module Tempmailportal
16
+ CHANNEL = "tempmailportal"
17
+ BASE_URL = "https://api.tempmailportal.com"
18
+
19
+ HEADERS = {
20
+ "Accept" => "application/json",
21
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
22
+ "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0"
23
+ }.freeze
24
+
25
+ # 可行 ID 候选键,按序提取
26
+ ID_KEYS = %w[id Id slug messageId message_id].freeze
27
+
28
+ module_function
29
+
30
+ # 设置 tempmailportal 请求的通用请求头
31
+ # @param token [String] 认证令牌,非空时携带 Authorization: Bearer
32
+ # @return [Hash]
33
+ def auth_headers(token = "")
34
+ hdrs = HEADERS.dup
35
+ hdrs["Authorization"] = "Bearer #{token}" unless token.to_s.empty?
36
+ hdrs
37
+ end
38
+
39
+ # 创建 tempmailportal 临时邮箱
40
+ # POST /api/v2/inbox(空 JSON body)返回 address 与 token
41
+ # @return [EmailInfo]
42
+ def generate_email
43
+ resp = Http.post("#{BASE_URL}/api/v2/inbox",
44
+ headers: HEADERS.merge("Content-Type" => "application/json"),
45
+ json: {}, timeout: 15)
46
+ resp.raise_for_status
47
+ data = resp.json
48
+ address = data.is_a?(Hash) ? data["address"].to_s.strip : ""
49
+ token = data.is_a?(Hash) ? data["token"].to_s.strip : ""
50
+ raise "tempmailportal: 创建邮箱响应缺少必要字段" if address.empty? || token.empty?
51
+
52
+ expires = data.is_a?(Hash) ? data["expiresAt"].to_s : ""
53
+ EmailInfo.new(channel: CHANNEL, email: address, token: token,
54
+ expires_at: expires.empty? ? nil : expires)
55
+ end
56
+
57
+ # 获取 tempmailportal 邮件列表
58
+ # GET /api/messages;对每个元素按 id 逐封请求详情合并,失败时以摘要归一。
59
+ # @param token [String] p2 前缀认证令牌
60
+ # @param email [String] 邮箱地址
61
+ # @return [Array<Email>]
62
+ def get_emails(token, email)
63
+ tk = token.to_s.strip
64
+ raise "tempmailportal: token 为空" if tk.empty?
65
+
66
+ resp = Http.get("#{BASE_URL}/api/messages", headers: auth_headers(tk), timeout: 15)
67
+ resp.raise_for_status
68
+ list = resp.json
69
+ return [] unless list.is_a?(Array)
70
+
71
+ addr = email.to_s.strip
72
+ list.filter_map do |m|
73
+ next unless m.is_a?(Hash)
74
+
75
+ Normalize.normalize_email(detail_row(tk, m, addr), addr)
76
+ end
77
+ end
78
+
79
+ # 从列表元素中提取消息 ID(候选键 id/Id/slug/messageId/message_id)
80
+ # @param row [Hash]
81
+ # @return [String]
82
+ def message_id_of(row)
83
+ ID_KEYS.each do |key|
84
+ val = row[key]
85
+ return val.to_s.strip unless val.nil? || val.to_s.strip.empty?
86
+ end
87
+ ""
88
+ end
89
+
90
+ # 逐封拉取详情并合并到列表摘要;无 ID 或详情失败时以摘要归一
91
+ # @param token [String] 认证令牌
92
+ # @param summary [Hash] 列表元素摘要
93
+ # @param email [String] 收件人邮箱
94
+ # @return [Hash] 归一化输入行
95
+ def detail_row(token, summary, email)
96
+ id = message_id_of(summary)
97
+ uri = "#{BASE_URL}/api/messages/#{URI.encode_www_form_component(id)}"
98
+ resp = Http.get(uri, headers: auth_headers(token), timeout: 15)
99
+ merged = summary
100
+ if resp.ok?
101
+ detail = begin
102
+ resp.json
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ # 详情缺失字段不覆盖列表摘要(包含 null 的键跳过)
107
+ merged = summary.merge(detail.reject { |_k, v| v.nil? }) if detail.is_a?(Hash)
108
+ end
109
+ to_normalized_row(merged, email)
110
+ rescue StandardError
111
+ to_normalized_row(summary, email)
112
+ end
113
+
114
+ # 将原始消息 Hash 转换为归一化输入行
115
+ # @param raw [Hash]
116
+ # @param email [String]
117
+ # @return [Hash]
118
+ def to_normalized_row(raw, email)
119
+ {
120
+ "id" => raw["id"] || raw["messageId"] || raw["message_id"],
121
+ "from" => raw["from"],
122
+ "to" => email,
123
+ "subject" => raw["subject"],
124
+ "text" => raw["text"],
125
+ "html" => raw["html"],
126
+ "date" => raw["date"]
127
+ }
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module TempmailSdk
6
+ module Providers
7
+ # tempmails.io 渠道实现
8
+ #
9
+ # 无认证 REST:
10
+ # POST /api/temp-mail/generate 建箱(空 JSON body,响应 data.email / data.token / data.expires_at)
11
+ # GET /api/temp-mail/inbox/{token} 读信(messages[] 含 from_email / text_body / html_body / received_at)
12
+ # 邮箱借用 uberip.com 等公共域(约 10 分钟自动过期)。
13
+ module TempmailsIo
14
+ CHANNEL = "tempmails-io"
15
+ BASE_URL = "https://tempmails.io"
16
+
17
+ HEADERS = {
18
+ "Accept" => "application/json",
19
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
20
+ "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0"
21
+ }.freeze
22
+
23
+ module_function
24
+
25
+ # 创建 10 分钟临时邮箱
26
+ # POST /api/temp-mail/generate(空 JSON body)
27
+ # @return [EmailInfo]
28
+ def generate_email
29
+ resp = Http.post("#{BASE_URL}/api/temp-mail/generate",
30
+ headers: HEADERS.merge("Content-Type" => "application/json"),
31
+ json: {}, timeout: 15)
32
+ resp.raise_for_status
33
+ data = resp.json
34
+ payload = data.is_a?(Hash) ? data["data"] : nil
35
+ email = payload.is_a?(Hash) ? payload["email"].to_s.strip : ""
36
+ token = payload.is_a?(Hash) ? payload["token"].to_s.strip : ""
37
+ raise "tempmails-io: 建箱响应缺少 email 或 token" if email.empty? || token.empty?
38
+
39
+ expires = payload.is_a?(Hash) ? payload["expires_at"].to_s : ""
40
+ EmailInfo.new(channel: CHANNEL, email: email, token: token,
41
+ expires_at: expires.empty? ? nil : expires)
42
+ end
43
+
44
+ # 读取收件箱
45
+ # 先 POST /api/temp-mail/fetch-emails/{token} 触发平台对上游信箱的主动同步,
46
+ # 再 GET /api/temp-mail/inbox/{token} 读取;只轮询 inbox 会永远为空。
47
+ # @param email [String] 邮箱地址
48
+ # @param token [String] 建箱返回的令牌
49
+ # @return [Array<Email>]
50
+ def get_emails(email, token)
51
+ tk = token.to_s.strip
52
+ raise "tempmails-io: token 为空" if tk.empty?
53
+
54
+ # 触发同步(失败不致命,仍尝试静态读)
55
+ begin
56
+ Http.post("#{BASE_URL}/api/temp-mail/fetch-emails/#{URI.encode_www_form_component(tk)}",
57
+ headers: HEADERS, timeout: 15)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ resp = Http.get("#{BASE_URL}/api/temp-mail/inbox/#{URI.encode_www_form_component(tk)}",
63
+ headers: HEADERS, timeout: 15)
64
+ resp.raise_for_status
65
+ data = resp.json
66
+ payload = data.is_a?(Hash) ? data["data"] : nil
67
+ messages = payload.is_a?(Hash) ? payload["messages"] : nil
68
+ return [] unless messages.is_a?(Array)
69
+
70
+ addr = email.to_s.strip
71
+ messages.filter_map do |m|
72
+ next unless m.is_a?(Hash)
73
+
74
+ row = {
75
+ "id" => m["id"],
76
+ "from" => m["from_email"] || m["from"],
77
+ "to" => addr,
78
+ "subject" => m["subject"],
79
+ "text" => m["text_body"] || m["text"],
80
+ "html" => m["html_body"] || m["html"],
81
+ "date" => m["received_at"] || m["date"],
82
+ "attachments" => m["attachments"]
83
+ }
84
+ Normalize.normalize_email(row, addr)
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end