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,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
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module TempmailSdk
6
+ module Providers
7
+ # Temporarymail 渠道实现(temporarymail.com)
8
+ #
9
+ # 无认证 REST(key 为空即随机建箱):
10
+ # GET /api/?action=requestEmailAccess&key=&value=random 建箱,
11
+ # 响应 {"address":"...","secretKey":"..."},secretKey 用于后续 checkInbox。
12
+ # 读信 GET /api/?action=checkInbox&value=<secretKey>,响应有两种形态:
13
+ # 空收件箱为 [],有信时为 map[id]→邮件元数据对象。
14
+ # 详情 POST /api/?action=getEmail&value=<id> 覆盖真实主题(列表常为 "[No Subject]");
15
+ # 全文 GET /view/?i=<id> 返回 HTML 化网页,本地剥标签还原纯文本。
16
+ # 地址最长周期固定为 4 小时。
17
+ module TemporarymailCom
18
+ CHANNEL = "temporarymail-com"
19
+ BASE_URL = "https://temporarymail.com"
20
+
21
+ # 备用浏览器 UA(403 重试用,规避共享池随机 UA 耗尽)
22
+ ALT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
23
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
24
+
25
+ HEADERS = {
26
+ "Accept" => "application/json, text/plain, */*",
27
+ "Accept-Language" => "en-US,en;q=0.9",
28
+ "Sec-Fetch-Site" => "same-origin",
29
+ "Sec-Fetch-Mode" => "cors",
30
+ "Sec-Fetch-Dest" => "empty",
31
+ "Referer" => "#{BASE_URL}/",
32
+ "Origin" => BASE_URL,
33
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
34
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
35
+ }.freeze
36
+
37
+ module_function
38
+
39
+ # 构造 /api/ 请求头(403 时换备用 UA 重试)
40
+ # @param ua [String] User-Agent
41
+ # @return [Hash]
42
+ def api_headers(ua)
43
+ HEADERS.merge("User-Agent" => ua)
44
+ end
45
+
46
+ # 创建 temporarymail.com 临时邮箱
47
+ # GET /api/?action=requestEmailAccess&key=&value=random;token 复用 secretKey。
48
+ # @return [EmailInfo]
49
+ def generate_email
50
+ resp = Http.get("#{BASE_URL}/api/?action=requestEmailAccess&key=&value=random",
51
+ headers: api_headers(HEADERS["User-Agent"]), timeout: 15)
52
+ if resp.status_code == 429
53
+ raise "temporarymail: 创建邮箱平台限流(429),请稍后重试"
54
+ end
55
+ resp.raise_for_status
56
+ data = resp.json
57
+ raise "temporarymail: 创建响应非对象" unless data.is_a?(Hash)
58
+
59
+ addr = data["address"].to_s.strip
60
+ key = data["secretKey"].to_s.strip
61
+ raise "temporarymail: 创建响应缺少 address 或 secretKey" if addr.empty? || key.empty?
62
+
63
+ EmailInfo.new(channel: CHANNEL, email: addr, token: key)
64
+ end
65
+
66
+ # 解析平台双形态响应([] 或 map[id]→对象)为数组
67
+ # @param data [Object] JSON 解析结果
68
+ # @return [Array<Hash>, nil]
69
+ def list_of(data)
70
+ return data if data.is_a?(Array)
71
+
72
+ data.values if data.is_a?(Hash)
73
+ end
74
+
75
+ # 读取 temporarymail 收件箱
76
+ # 列表主题常为 "[No Subject]":逐封拉详情覆盖真实主题,并逐封抓 /view/ 全文。
77
+ # @param email [String] 邮箱地址
78
+ # @param token [String] 建箱返回的 secretKey
79
+ # @return [Array<Email>]
80
+ def get_emails(email, token)
81
+ tk = token.to_s.strip
82
+ raise "temporarymail: token 为空" if tk.empty?
83
+
84
+ body = check_inbox(tk)
85
+ list = list_of(body)
86
+ return [] unless list.is_a?(Array)
87
+
88
+ addr = email.to_s.strip
89
+ list.filter_map do |m|
90
+ next unless m.is_a?(Hash)
91
+
92
+ row = m.dup
93
+ row["to"] = addr unless row.key?("to")
94
+ id = row["id"].to_s.strip
95
+ unless id.empty?
96
+ # 详情覆盖真实主题/发件人(失败不致命:列表元数据兜底)
97
+ det = fetch_detail(id)
98
+ if det.is_a?(Hash)
99
+ row["subject"] = det["subject"] if det["subject"].to_s.strip != ""
100
+ row["from"] = det["from"] if det["from"].to_s.strip != ""
101
+ end
102
+ # /view/ 渲染端点全文(失败不致命:列表元数据兜底)
103
+ text = fetch_view(id)
104
+ row["text"] = text if !text.empty?
105
+ end
106
+ Normalize.normalize_email(row, addr)
107
+ end
108
+ end
109
+
110
+ # 拉取 checkInbox 响应体;403 换备用 UA 重试一次,429 报平台限流。
111
+ # @param token [String] secretKey
112
+ # @return [Object] JSON 解析结果
113
+ def check_inbox(token)
114
+ uas = [HEADERS["User-Agent"], ALT_UA]
115
+ last_status = 0
116
+ last_raw = ""
117
+ uas.each do |ua|
118
+ uri = "#{BASE_URL}/api/?action=checkInbox&value=#{URI.encode_www_form_component(token)}"
119
+ resp = Http.get(uri, headers: api_headers(ua), timeout: 15)
120
+ if resp.status_code == 429
121
+ raise "temporarymail: 读取收件箱平台限流(429),请拉大轮询间隔"
122
+ end
123
+ if resp.ok?
124
+ return resp.json
125
+ end
126
+ last_status = resp.status_code
127
+ last_raw = resp.body.to_s
128
+ # 403/404 疑似 UA 键控风控,换备用 UA 重试一次
129
+ next if [403, 404].include?(resp.status_code)
130
+
131
+ raise "temporarymail: 读取收件箱失败 http #{resp.status_code}: #{last_raw}"
132
+ end
133
+ raise "temporarymail: 读取收件箱失败 http #{last_status}(两次尝试均被拒)"
134
+ end
135
+
136
+ # 拉取单封详情(POST /api/?action=getEmail&value=<id>),风控失败返回 nil 降级
137
+ # @param id [String] 邮件 ID
138
+ # @return [Hash, nil]
139
+ def fetch_detail(id)
140
+ uri = "#{BASE_URL}/api/?action=getEmail&value=#{URI.encode_www_form_component(id)}"
141
+ resp = Http.post(uri, headers: api_headers(HEADERS["User-Agent"]), timeout: 15)
142
+ return nil if resp.status_code == 429 || !resp.ok?
143
+
144
+ data = resp.json
145
+ return nil unless data.is_a?(Hash)
146
+
147
+ data.values.first
148
+ rescue StandardError
149
+ nil
150
+ end
151
+
152
+ # 抓取 /view/ 渲染端点全文并还原纯文本
153
+ # @param id [String] 邮件 ID
154
+ # @return [String]
155
+ def fetch_view(id)
156
+ uri = "#{BASE_URL}/view/?i=#{URI.encode_www_form_component(id)}&width=800"
157
+ resp = Http.get(uri,
158
+ headers: {
159
+ "Accept" => "text/html, */*",
160
+ "User-Agent" => HEADERS["User-Agent"],
161
+ "Referer" => "#{BASE_URL}/"
162
+ },
163
+ timeout: 15)
164
+ return "" unless resp.ok?
165
+
166
+ view_to_text(resp.body.to_s)
167
+ end
168
+
169
+ # 将 /view/ 响应剥标签还原为纯文本(<br>/<p> 换行保留)
170
+ # @param src [String] HTML 化网页原文
171
+ # @return [String]
172
+ def view_to_text(src)
173
+ s = src.to_s
174
+ %w[<br\ /> <br/> <br> <p> </p>].each { |tag| s = s.gsub(tag, "\n") }
175
+ s = s.gsub(/<script\b[^>]*>.*?<\/script>/mi, " ")
176
+ s = s.gsub(/<style\b[^>]*>.*?<\/style>/mi, " ")
177
+ s = s.gsub(/<[^>]+>/, " ")
178
+ s = s.gsub("&nbsp;", "&").gsub("&nbsp;", " ").gsub("&gt;", ">").gsub("&lt;", "<")
179
+ s = s.gsub("&quot;", '"').gsub("&#39;", "'").gsub("&amp;", "&")
180
+ s.split("\n").map { |ln| ln.strip }.join("\n").strip
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TempmailSdk
4
+ module Providers
5
+ # Tenmin.app 渠道实现(真实 API 域 api.tenmin.app)
6
+ #
7
+ # 建箱+读信共用 GET /api/inbox/{localpart}(Accept: application/json)。
8
+ # localpart 为随机 6 位小写十六进制串(首访即建箱,平台无显式创建接口);
9
+ # 响应:{"inboxId":..,"address":"..@tenmin.app","ttl":600,"count":0,"messages":[]},
10
+ # messages[] 元素字段:id/from/subject/text/html/receivedAt(from 为 {name,address} 对象)。
11
+ module TenminApp
12
+ CHANNEL = "tenmin-app"
13
+ BASE_URL = "https://api.tenmin.app"
14
+ DEFAULT_DOMAIN = "tenmin.app"
15
+
16
+ HEADERS = {
17
+ "Accept" => "application/json",
18
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
19
+ "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0"
20
+ }.freeze
21
+
22
+ module_function
23
+
24
+ HEX_CHARS = ("0".."9").to_a + ("a".."f").to_a
25
+
26
+ # 生成 6 位小写十六进制随机 localpart
27
+ def random_local
28
+ Array.new(6) { HEX_CHARS.sample }.join
29
+ end
30
+
31
+ # 请求 /api/inbox/{localpart}
32
+ # @param localpart [String] 邮箱 localpart
33
+ # @return [Hash] 解析后的收件箱响应(解析失败返回空 Hash)
34
+ def fetch_inbox(localpart)
35
+ resp = Http.get("#{BASE_URL}/api/inbox/#{localpart}", headers: HEADERS, timeout: 15)
36
+ resp.raise_for_status
37
+ data = resp.json
38
+ data.is_a?(Hash) ? data : {}
39
+ end
40
+
41
+ # 创建 tenmin.app 临时邮箱
42
+ # 首次 GET 随机 localpart 即自动建箱(10 分钟 TTL),token 存储 localpart
43
+ # @return [EmailInfo]
44
+ def generate_email
45
+ local = random_local
46
+ data = fetch_inbox(local)
47
+ address = data["address"].to_s.strip
48
+ address = "#{local}@#{DEFAULT_DOMAIN}" if address.empty?
49
+ ttl = data["ttl"].to_i
50
+ expires_at = nil
51
+ expires_at = ((Time.now.to_f + ttl) * 1000).to_i if ttl.positive?
52
+ EmailInfo.new(channel: CHANNEL, email: address, token: local, expires_at: expires_at)
53
+ end
54
+
55
+ # 读取 tenmin.app 收件箱
56
+ # 复用建箱同一 localpart 轮询;from 为 {name,address} 对象,拆开格式化后归一。
57
+ # @param email [String] 邮箱地址
58
+ # @param token [String] localpart
59
+ # @return [Array<Email>]
60
+ def get_emails(email, token)
61
+ local = token.to_s.strip
62
+ raise "tenmin-app: token 为空" if local.empty?
63
+
64
+ data = fetch_inbox(local)
65
+ messages = data["messages"]
66
+ return [] unless messages.is_a?(Array)
67
+
68
+ addr = email.to_s.strip
69
+ messages.filter_map do |m|
70
+ next unless m.is_a?(Hash)
71
+
72
+ from = m["from"]
73
+ # from 为对象({name,address})时拆出地址字段
74
+ from_str = if from.is_a?(Hash)
75
+ fname = from["name"].to_s.strip
76
+ faddr = from["address"].to_s.strip
77
+ if !faddr.empty? && !fname.empty?
78
+ "#{fname} <#{faddr}>"
79
+ else
80
+ faddr.empty? ? fname : faddr
81
+ end
82
+ else
83
+ from.to_s
84
+ end
85
+
86
+ row = {
87
+ "id" => m["id"],
88
+ "from" => from_str,
89
+ "to" => addr,
90
+ "subject" => m["subject"],
91
+ "text" => m["text"],
92
+ "html" => m["html"],
93
+ "date" => m["receivedAt"] || m["created_at"] || m["date"]
94
+ }
95
+ Normalize.normalize_email(row, addr)
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module TempmailSdk
6
+ module Providers
7
+ # 30minemail 渠道实现(30minemail.com)
8
+ #
9
+ # 官网邮箱由服务端 16 位 hex 本地名识别:
10
+ # GET /?generate 返回完整 HTML 页面,从中解析 <地址>@30minemail.com;
11
+ # 本地名无效邮箱访问 messages.php 时返回 ok:false/expired:true,故必须经服务端建箱。
12
+ # 读信 GET /messages.php?email=<完整地址>&_=<unix毫秒>,响应
13
+ # {"ok":true,"expired":false,"count":0,"emails":[],"expires_in":...}。
14
+ # emails 元素字段实测:{id,from,to,subject,date,html}(html 为完整正文)。
15
+ # 无认证、无 Cookie、无 CSRF。
16
+ module ThirtyMinEmail
17
+ CHANNEL = "30minemail"
18
+ BASE_URL = "https://30minemail.com"
19
+ DOMAIN = "30minemail.com"
20
+
21
+ HEADERS = {
22
+ "Accept" => "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8",
23
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
24
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
25
+ }.freeze
26
+
27
+ module_function
28
+
29
+ # 创建 30minemail.com 临时邮箱
30
+ # GET /?generate 返回 HTML 页面,从其中解析 16 位 hex 本地名地址;
31
+ # token 复用完整地址(服务端以地址定位收件箱)。
32
+ # @return [EmailInfo]
33
+ def generate_email
34
+ resp = Http.get("#{BASE_URL}/?generate", headers: HEADERS, timeout: 15)
35
+ resp.raise_for_status
36
+ page = resp.body.to_s
37
+ idx = page.index("@#{DOMAIN}")
38
+ raise "30minemail: 创建页面未找到邮箱地址" if idx.nil?
39
+
40
+ # 向前查找本地名起点:空白或 > 之后
41
+ start = idx
42
+ while start.positive? && ![" ", "\n", "\t", ">", '"'].include?(page[start - 1])
43
+ start -= 1
44
+ end
45
+ local = page[start...idx].to_s.strip
46
+ raise "30minemail: 创建页面解析地址异常" if local.length < 8
47
+
48
+ addr = "#{local}@#{DOMAIN}"
49
+ EmailInfo.new(channel: CHANNEL, email: addr, token: addr)
50
+ end
51
+
52
+ # 读取 30minemail.com 收件箱
53
+ # GET /messages.php?email=<完整地址>&_=<unix毫秒>,模拟官方轮询参数。
54
+ # @param email [String] 完整邮箱地址
55
+ # @param token [String] 复用完整地址(服务端以地址定位收件箱)
56
+ # @return [Array<Email>]
57
+ def get_emails(email, token)
58
+ addr = email.to_s.strip
59
+ raise "30minemail: 邮箱地址为空" if addr.empty?
60
+
61
+ uri = "#{BASE_URL}/messages.php?email=#{URI.encode_www_form_component(addr)}&_=#{(Time.now.to_f * 1000).to_i}"
62
+ resp = Http.get(uri,
63
+ headers: {
64
+ "Accept" => "application/json",
65
+ "User-Agent" => HEADERS["User-Agent"]
66
+ },
67
+ timeout: 15)
68
+ raise "30minemail: 读取收件箱失败 http #{resp.status_code}" unless resp.ok?
69
+
70
+ data = resp.json
71
+ raise "30minemail: 收件箱响应非对象" unless data.is_a?(Hash)
72
+
73
+ unless data["ok"] == true && data["expired"] != true
74
+ raise "30minemail: 收件箱不可用或已过期"
75
+ end
76
+ emails = data["emails"]
77
+ return [] unless emails.is_a?(Array)
78
+
79
+ emails.filter_map do |m|
80
+ next unless m.is_a?(Hash)
81
+
82
+ row = m.dup
83
+ # 列表元素无 to 字段时注入收件人地址
84
+ row["to"] = addr unless row.key?("to")
85
+ Normalize.normalize_email(row, addr)
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end