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,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module TempmailSdk
6
+ module Providers
7
+ # smails.dev 渠道实现
8
+ #
9
+ # 流程:
10
+ # POST /api/mailbox 建箱(空 JSON body,响应 address/token)
11
+ # GET /api/mailbox/messages 读信(Header Authorization: Bearer <token>,数组响应)
12
+ # GET /api/mailbox/messages/{id} 取单封详情(Bearer)
13
+ # 列表元素按 from/subject/text/html/date 多候选归一;详情拉取失败时以列表摘要兜底。
14
+ module Smails
15
+ CHANNEL = "smails"
16
+ BASE_URL = "https://smails.dev"
17
+
18
+ HEADERS = {
19
+ "Accept" => "application/json",
20
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
21
+ "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0"
22
+ }.freeze
23
+
24
+ # 可行 ID 候选键,按序提取(结果转为字符串)
25
+ ID_KEYS = %w[id Id slug messageId message_id].freeze
26
+
27
+ module_function
28
+
29
+ # 设置 smails.dev 请求的通用请求头
30
+ # @param token [String] 认证令牌,非空时携带 Authorization: Bearer
31
+ # @return [Hash]
32
+ def auth_headers(token = "")
33
+ hdrs = HEADERS.dup
34
+ hdrs["Authorization"] = "Bearer #{token}" unless token.to_s.empty?
35
+ hdrs
36
+ end
37
+
38
+ # 创建 smails.dev 临时邮箱
39
+ # POST /api/mailbox(空 JSON body)返回 address 与 token
40
+ # @return [EmailInfo]
41
+ def generate_email
42
+ resp = Http.post("#{BASE_URL}/api/mailbox",
43
+ headers: HEADERS.merge("Content-Type" => "application/json"),
44
+ json: {}, timeout: 15)
45
+ resp.raise_for_status
46
+ data = resp.json
47
+ address = data.is_a?(Hash) ? data["address"].to_s.strip : ""
48
+ token = data.is_a?(Hash) ? data["token"].to_s.strip : ""
49
+ raise "smails: 创建邮箱响应缺少必要字段" if address.empty? || token.empty?
50
+
51
+ EmailInfo.new(channel: CHANNEL, email: address, token: token)
52
+ end
53
+
54
+ # 获取 smails.dev 邮件列表
55
+ # GET /api/mailbox/messages;对每个元素按 id 逐封请求详情合并,失败时以摘要归一。
56
+ # @param token [String] Bearer 令牌
57
+ # @param email [String] 邮箱地址
58
+ # @return [Array<Email>]
59
+ def get_emails(token, email)
60
+ tk = token.to_s.strip
61
+ raise "smails: token 为空" if tk.empty?
62
+
63
+ resp = Http.get("#{BASE_URL}/api/mailbox/messages", headers: auth_headers(tk), timeout: 15)
64
+ resp.raise_for_status
65
+ list = resp.json
66
+ return [] unless list.is_a?(Array)
67
+
68
+ addr = email.to_s.strip
69
+ list.filter_map do |m|
70
+ next unless m.is_a?(Hash)
71
+
72
+ Normalize.normalize_email(detail_row(tk, m, addr), addr)
73
+ end
74
+ end
75
+
76
+ # 从列表元素中提取消息 ID(候选键 id/Id/slug/messageId/message_id)
77
+ # @param row [Hash]
78
+ # @return [String]
79
+ def message_id_of(row)
80
+ ID_KEYS.each do |key|
81
+ val = row[key]
82
+ return val.to_s.strip unless val.nil? || val.to_s.strip.empty?
83
+ end
84
+ ""
85
+ end
86
+
87
+ # 逐封拉取详情并合并到列表摘要;无 ID 或详情失败时以摘要归一
88
+ # @param token [String] Bearer 令牌
89
+ # @param summary [Hash] 列表元素摘要
90
+ # @param email [String] 收件人邮箱
91
+ # @return [Hash] 归一化输入行
92
+ def detail_row(token, summary, email)
93
+ id = message_id_of(summary)
94
+ uri = "#{BASE_URL}/api/mailbox/messages/#{URI.encode_www_form_component(id)}"
95
+ resp = Http.get(uri, headers: auth_headers(token), timeout: 15)
96
+ merged = summary
97
+ if resp.ok?
98
+ detail = begin
99
+ resp.json
100
+ rescue StandardError
101
+ nil
102
+ end
103
+ # 详情缺失字段不覆盖列表摘要(包含 null 的键跳过)
104
+ merged = summary.merge(detail.reject { |_k, v| v.nil? }) if detail.is_a?(Hash)
105
+ end
106
+ to_normalized_row(merged, email)
107
+ rescue StandardError
108
+ to_normalized_row(summary, email)
109
+ end
110
+
111
+ # 将原始消息 Hash 转换为归一化输入行
112
+ # @param raw [Hash]
113
+ # @param email [String]
114
+ # @return [Hash]
115
+ def to_normalized_row(raw, email)
116
+ {
117
+ "id" => raw["id"] || raw["messageId"] || raw["message_id"],
118
+ "from" => raw["from"],
119
+ "to" => email,
120
+ "subject" => raw["subject"],
121
+ "text" => raw["text"],
122
+ "html" => raw["html"],
123
+ "date" => raw["date"]
124
+ }
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TempmailSdk
4
+ module Providers
5
+ # socketio-mail 渠道桩实现
6
+ #
7
+ # 该渠道依赖 WebSocket/Socket.IO 协议,Ruby 标准 HTTP 客户端无法实现。
8
+ # 当前注册为桩,调用时抛出 NotImplementedProviderError。
9
+ module SocketioMail
10
+ CHANNEL = "socketio-mail"
11
+
12
+ module_function
13
+
14
+ # @raise [NotImplementedError]
15
+ def generate_email
16
+ raise NotImplementedProviderError, "channel not implemented yet: #{CHANNEL}"
17
+ end
18
+
19
+ # @raise [NotImplementedError]
20
+ def get_emails(_email, _token)
21
+ raise NotImplementedProviderError, "channel not implemented yet: #{CHANNEL}"
22
+ end
23
+ end
24
+ end
25
+ end
@@ -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,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module TempmailSdk
6
+ module Providers
7
+ # tempgbox.net 渠道实现
8
+ #
9
+ # 流程:POST /api/proxy?route=generate 创建邮箱(JSON body,X-Device-ID 头)
10
+ # POST /api/proxy?route=inbox 获取邮件列表
11
+ # 响应体为 HTML 页面,实际数据编码在 data-x 属性(base64 JSON)
12
+ # token 存储 device_id
13
+ module Tempgbox
14
+ CHANNEL = "tempgbox"
15
+ API_URL = "https://tempgbox.net/api/proxy"
16
+
17
+ module_function
18
+
19
+ # 生成随机 64 位十六进制 device id
20
+ # @return [String]
21
+ def random_device_id
22
+ require "securerandom"
23
+ SecureRandom.hex(32)
24
+ end
25
+
26
+ # 生成随机 IP 地址
27
+ # @return [String]
28
+ def random_ip
29
+ "#{rand(1..254)}.#{rand(1..254)}.#{rand(1..254)}.#{rand(1..254)}"
30
+ end
31
+
32
+ # 构造请求头
33
+ # @param device_id [String]
34
+ # @return [Hash]
35
+ def build_headers(device_id)
36
+ ip = random_ip
37
+ {
38
+ "Accept" => "text/html,application/json",
39
+ "Content-Type" => "application/json",
40
+ "Origin" => "https://tempgbox.net",
41
+ "Referer" => "https://tempgbox.net/",
42
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
43
+ "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
44
+ "X-Device-ID" => device_id,
45
+ "X-Forwarded-For" => ip,
46
+ "X-Real-IP" => ip,
47
+ "X-Originating-IP" => ip
48
+ }
49
+ end
50
+
51
+ # 从响应 HTML 中解码 data-x 属性(base64 JSON)
52
+ # @param body [String]
53
+ # @return [Hash]
54
+ def decode_payload(body)
55
+ require "base64"
56
+ m = body.match(/data-x=["']([^"']+)["']/)
57
+ raise "tempgbox: 响应中缺少 data-x 属性" unless m
58
+
59
+ raw = Base64.strict_decode64(m[1])
60
+ JSON.parse(raw)
61
+ end
62
+
63
+ # 发起 POST 请求并解码响应
64
+ # @param route [String]
65
+ # @param device_id [String]
66
+ # @param body [Hash]
67
+ # @return [Hash]
68
+ def post_route(route, device_id, body)
69
+ resp = Http.post(
70
+ "#{API_URL}?route=#{route}",
71
+ headers: build_headers(device_id),
72
+ body: JSON.generate(body),
73
+ timeout: 15
74
+ )
75
+ resp.raise_for_status
76
+ decode_payload(resp.body)
77
+ end
78
+
79
+ # 创建 tempgbox.net 临时邮箱
80
+ # @return [EmailInfo]
81
+ def generate_email
82
+ device_id = random_device_id
83
+ payload = post_route("generate", device_id, "variant" => "googlemail")
84
+ alias_data = payload["alias"]
85
+ raise "tempgbox: 响应缺少 alias" unless alias_data.is_a?(Hash)
86
+
87
+ email = alias_data["email"].to_s.strip
88
+ email = alias_data["alias"].to_s.strip if email.empty?
89
+ raise "tempgbox: 响应缺少 email" if email.empty?
90
+
91
+ EmailInfo.new(channel: CHANNEL, email: email, token: device_id)
92
+ end
93
+
94
+ # 获取 tempgbox.net 邮件列表
95
+ # @param device_id [String] token
96
+ # @param email [String]
97
+ # @return [Array<Email>]
98
+ def get_emails(device_id, email)
99
+ raise "tempgbox: missing device id" if device_id.to_s.strip.empty?
100
+
101
+ payload = post_route("inbox", device_id, "email" => email)
102
+ messages = Array(payload["messages"])
103
+
104
+ messages.filter_map do |raw|
105
+ next unless raw.is_a?(Hash)
106
+
107
+ raw["from"] ||= raw["sender"]
108
+ raw["text"] ||= raw["body_text"]
109
+ raw["html"] ||= raw["body_html"]
110
+ raw["date"] ||= raw["received_at"]
111
+ raw["messageId"] ||= raw["message_id"]
112
+ raw["attachments"] ||= raw["attachments_info"]
113
+ Normalize.normalize_email(raw.merge("to" => email), email)
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end