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,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
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+ require "time"
5
+ require "uri"
6
+
7
+ module TempmailSdk
8
+ module Providers
9
+ # Tempmailto 渠道实现(tempmailto.com,Laravel)
10
+ #
11
+ # 前端同款协议:GET / 首页建立 Cookie 会话并从 #mainEmail 的 value
12
+ # 提取当前邮箱;POST /get_messages(_token=<CSRF>&captcha=)返回
13
+ # {status, mailbox, email_token, messages, histories};详情为站内
14
+ # GET /view/{id}。邮箱约 10 分钟无活动过期,无独立密钥。
15
+ #
16
+ # 会话粘性:邮箱由会话 Cookie 承载,Token 约定为邮箱本身(注册表
17
+ # 以 token 非空作防御)。读到的当前邮箱与请求邮箱不一致时,用
18
+ # POST /change(_token + name + domain)把会话拉回请求邮箱。
19
+ # 本端无全局 Cookie 罐,会话 Cookie 由本渠道模块级维护并逐请求
20
+ # 以显式 Cookie 头回填。
21
+ module Tempmailto
22
+ CHANNEL = "tempmailto"
23
+ BASE_URL = "https://tempmailto.com"
24
+
25
+ USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " \
26
+ "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36"
27
+
28
+ HOME_ACCEPT = "text/html,application/xhtml+xml,application/xml;q=0.9," \
29
+ "image/avif,image/webp,*/*;q=0.8"
30
+ XHR_ACCEPT = "application/json, text/plain, */*"
31
+
32
+ # 模块级会话 Cookie 状态:site => 值,逐请求回填 Cookie 头
33
+ @cookies = {}
34
+
35
+ CSRF_RE = /<meta\s+name="csrf-token"\s+content="([^"]+)"/
36
+ MAIN_EMAIL_RE = /id="mainEmail"[^>]*\bvalue="([^"]+)"/im
37
+
38
+ # 详情正文候选 class(按平台视图页结构依次尝试,后回退 main/article)
39
+ DETAIL_CLASSES = %w[mail-body mail_content email-body content-body
40
+ message-content mail-content].freeze
41
+
42
+ module_function
43
+
44
+ # 解析 Set-Cookie 头,覆写合并进模块级会话 Cookie 状态
45
+ # @param resp [Http::Response]
46
+ def store_cookies(resp)
47
+ resp.set_cookies.each do |line|
48
+ kv = line.split(";").first.to_s.strip
49
+ next if kv.empty?
50
+
51
+ k, v = kv.split("=", 2)
52
+ @cookies[k.strip] = v.to_s if k && !k.strip.empty?
53
+ end
54
+ end
55
+
56
+ # 由模块级 Cookie 状态拼出显式 Cookie 请求头
57
+ # @return [String, nil] 无 Cookie 时为 nil
58
+ def cookie_header
59
+ return nil if @cookies.empty?
60
+
61
+ @cookies.map { |k, v| "#{k}=#{v}" }.join("; ")
62
+ end
63
+
64
+ # 组装浏览器特征请求头(同站调用,照前端实际携带的头)
65
+ # @param accept [String] Accept 头取值
66
+ # @param with_cookie [Boolean] 是否回填会话 Cookie
67
+ # @return [Hash]
68
+ def browser_headers(accept, with_cookie: true)
69
+ hdrs = {
70
+ "User-Agent" => USER_AGENT,
71
+ "Accept" => accept,
72
+ "Accept-Language" => "en-US,en;q=0.9",
73
+ "Origin" => BASE_URL,
74
+ "Referer" => "#{BASE_URL}/"
75
+ }
76
+ hdrs["Cookie"] = cookie_header if with_cookie
77
+ hdrs.compact
78
+ end
79
+
80
+ # 组装 /get_messages、/change 的 POST 请求头
81
+ # @return [Hash]
82
+ def post_headers(accept)
83
+ hdrs = browser_headers(accept)
84
+ hdrs["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
85
+ hdrs["X-Requested-With"] = "XMLHttpRequest"
86
+ hdrs
87
+ end
88
+
89
+ # 创建 tempmailto.com 临时邮箱
90
+ # GET 首页建立会话(响应 Cookie 落入模块级状态)并提取服务端渲染
91
+ # 的当前邮箱;Token 约定为邮箱本身。邮箱约 10 分钟无活动过期。
92
+ # @return [EmailInfo]
93
+ def generate_email
94
+ resp = Http.get(BASE_URL, headers: browser_headers(HOME_ACCEPT, with_cookie: true), timeout: 15)
95
+ unless resp.ok?
96
+ raise "tempmailto: 首页 http #{resp.status_code}"
97
+ end
98
+
99
+ store_cookies(resp)
100
+ email = ""
101
+ if (m = resp.body.match(MAIN_EMAIL_RE))
102
+ email = m[1].strip
103
+ end
104
+ if email.empty?
105
+ raise "tempmailto: 首页未渲染出邮箱(mainEmail 缺失),无法建箱"
106
+ end
107
+
108
+ EmailInfo.new(channel: CHANNEL, email: email, token: email)
109
+ end
110
+
111
+ # 读取 tempmailto.com 当前邮箱的收件箱
112
+ # 邮箱与请求邮箱不一致时用 change 拉回,再重新读 messages。
113
+ # @param email [String] 邮箱地址
114
+ # @param token [String] 渠道会话凭据串(Generate 时约定的邮箱本身)
115
+ # @return [Array<Email>]
116
+ def get_emails(email, token)
117
+ addr = email.to_s.strip
118
+ if addr.empty?
119
+ raise "tempmailto: 邮箱为空,请重新 Generate"
120
+ end
121
+
122
+ csrf = fetch_csrf
123
+ data = fetch_messages(csrf)
124
+ mailbox = data["mailbox"].to_s.strip
125
+
126
+ # 会话粘性:当前邮箱与请求目标不一致 -> change 拉回
127
+ if !mailbox.empty? && mailbox.casecmp(addr) != 0
128
+ changed = change_mailbox(addr)
129
+ if changed.empty?
130
+ raise "tempmailto: change 响应异常"
131
+ end
132
+ if changed.casecmp(addr) != 0
133
+ raise "tempmailto: 会话邮箱无法拉回请求邮箱"
134
+ end
135
+ data = fetch_messages(fetch_csrf)
136
+ end
137
+
138
+ messages = data["messages"]
139
+ return [] unless messages.is_a?(Array)
140
+
141
+ messages.filter_map do |row|
142
+ next unless row.is_a?(Hash)
143
+
144
+ build_email(row, addr)
145
+ end
146
+ end
147
+
148
+ # GET 首页并提取 CSRF _token(读信/换箱共用,调用同时刷新会话 Cookie)
149
+ # @return [String]
150
+ def fetch_csrf
151
+ resp = Http.get(BASE_URL, headers: browser_headers(HOME_ACCEPT), timeout: 15)
152
+ store_cookies(resp)
153
+ m = resp.body.match(CSRF_RE)
154
+ raise "tempmailto: 首页未找到 csrf-token" unless m
155
+
156
+ m[1]
157
+ end
158
+
159
+ # POST /get_messages 拉取当前邮箱的消息 JSON
160
+ # @param csrf [String] _token 取值
161
+ # @return [Hash]
162
+ def fetch_messages(csrf)
163
+ resp = Http.post("#{BASE_URL}/get_messages",
164
+ headers: post_headers(XHR_ACCEPT),
165
+ body: "_token=#{csrf}&captcha=", timeout: 15)
166
+ unless resp.ok?
167
+ raise "tempmailto 读信: http #{resp.status_code}"
168
+ end
169
+ store_cookies(resp)
170
+
171
+ data = resp.json
172
+ raise "tempmailto: 读信响应非对象" unless data.is_a?(Hash)
173
+
174
+ data
175
+ end
176
+
177
+ # POST /change 换箱:以请求邮箱的 @ 前部/后部作为 name/domain 拉回会话
178
+ # @param email [String] 目标邮箱
179
+ # @return [String] 变更后的当前邮箱(响应 mailbox)
180
+ def change_mailbox(email)
181
+ name = email.split("@", 2).first.to_s
182
+ name = "TmSdk" if name.empty?
183
+ domain = email.split("@", 2)[1].to_s
184
+ domain = "tempmailto.com" if domain.empty?
185
+
186
+ csrf = fetch_csrf
187
+ resp = Http.post("#{BASE_URL}/change",
188
+ headers: post_headers(XHR_ACCEPT),
189
+ body: "_token=#{csrf}&name=#{CGI.escape(name)}&domain=#{CGI.escape(domain)}",
190
+ timeout: 15)
191
+ store_cookies(resp)
192
+ data = resp.json
193
+ return "" unless data.is_a?(Hash)
194
+
195
+ data["mailbox"].to_s.strip
196
+ rescue StandardError
197
+ ""
198
+ end
199
+
200
+ # 将 messages 列表元素组装为统一邮件:
201
+ # 详情页 /view/{id} 提取正文,失败回退列表字段;id 空跳过。
202
+ # @param row [Hash] 列表元素
203
+ # @param email [String] 收件人地址
204
+ # @return [Email, nil]
205
+ def build_email(row, email)
206
+ id = str_of(row["id"])
207
+ return nil if id.empty?
208
+
209
+ html = view_detail(id)
210
+ text = html.empty? ? "" : HtmlUtils.html_to_text(html)
211
+ if text.empty?
212
+ text = str_of(row["body"])
213
+ text = str_of(row["text"]) if text.empty?
214
+ text = str_of(row["snippet"]) if text.empty?
215
+ text = str_of(row["preview"]) if text.empty?
216
+ end
217
+ subject = str_of(row["subject"])
218
+ text = subject if text.empty?
219
+ html = "<html><body><pre>#{HtmlUtils.escape(text)}</pre></body></html>" if html.empty?
220
+ date = str_of(row["receivedAt"])
221
+ date = str_of(row["received_at"]) if date.empty?
222
+ date = str_of(row["createdAt"]) if date.empty?
223
+ date = Time.now.utc.iso8601 if date.empty?
224
+
225
+ Normalize.normalize_email(normalize_item(row, email, text, html, date), email)
226
+ end
227
+
228
+ # 组装 normalize_email 的入参 Hash(键按本端标准化候选键选取)
229
+ # @param row [Hash] 列表元素
230
+ # @param email [String] 收件人地址
231
+ # @param text [String] 纯文本正文
232
+ # @param html [String] HTML 正文
233
+ # @param date [String] 时间
234
+ # @return [Hash]
235
+ def normalize_item(row, email, text, html, date)
236
+ from_email = str_of(row["from_email"])
237
+ from_email = str_of(row["from"]) if from_email.empty?
238
+ from_email = str_of(row["from_name"]) if from_email.empty?
239
+
240
+ {
241
+ "id" => str_of(row["id"]),
242
+ "from_email" => from_email,
243
+ "to" => email,
244
+ "subject" => str_of(row["subject"]),
245
+ "text" => text,
246
+ "html" => html,
247
+ "receivedAt" => date,
248
+ "is_seen" => read_of(row["is_seen"])
249
+ }
250
+ end
251
+
252
+ # GET /view/{id} 提取邮件正文 HTML(同会话 Cookie,失败返回空串)
253
+ # @param id [String] 邮件 id
254
+ # @return [String]
255
+ def view_detail(id)
256
+ resp = Http.get("#{BASE_URL}/view/#{URI.encode_www_form_component(id)}",
257
+ headers: browser_headers(HOME_ACCEPT), timeout: 15)
258
+ return "" unless resp.ok?
259
+
260
+ store_cookies(resp)
261
+ extract_detail(resp.body)
262
+ rescue StandardError
263
+ ""
264
+ end
265
+
266
+ # 按候选 class 依次提取详情区块,全失败回退 <main>/<article>
267
+ # @param page [String] 详情页 HTML
268
+ # @return [String]
269
+ def extract_detail(page)
270
+ DETAIL_CLASSES.each do |klass|
271
+ re = %r{<[^>]+class="[^"]*\b#{Regexp.escape(klass)}\b[^"]*"[^>]*>(.*?)</(?:div|section|article)>}mi
272
+ m = page.match(re)
273
+ inner = m && m[1].to_s.strip
274
+ return inner unless inner.nil? || inner.empty?
275
+ end
276
+ if (m = page.match(%r{<(main|article)[^>]*>(.*?)</\1>}mi))
277
+ inner = m[2].to_s.strip
278
+ return inner unless inner.empty?
279
+ end
280
+ ""
281
+ end
282
+
283
+ # 将接口字段值安全转换为字符串,nil 或非标量返回空串
284
+ # @param v [Object]
285
+ # @return [String]
286
+ def str_of(v)
287
+ case v
288
+ when String then v
289
+ when Numeric then v.to_i.to_s
290
+ when true then "true"
291
+ when false then "false"
292
+ else ""
293
+ end
294
+ end
295
+
296
+ # 将 is_seen 归一为布尔已读标记,兼容 bool / 数字(0|1) / string("true"|"1")
297
+ # @param v [Object]
298
+ # @return [Boolean]
299
+ def read_of(v)
300
+ case v
301
+ when true then true
302
+ when false then false
303
+ when Numeric then v != 0
304
+ when String
305
+ s = v.strip
306
+ s.casecmp("true").zero? || s == "1"
307
+ else false
308
+ end
309
+ end
310
+ end
311
+ end
312
+ end