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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/tempmail_sdk/channels.rb +29 -7
- data/lib/tempmail_sdk/channels_data.rb +29 -7
- data/lib/tempmail_sdk/providers/clawdemail.rb +122 -0
- data/lib/tempmail_sdk/providers/crazymailing.rb +114 -0
- data/lib/tempmail_sdk/providers/firetempmail.rb +86 -0
- data/lib/tempmail_sdk/providers/flybymail.rb +93 -0
- data/lib/tempmail_sdk/providers/generator_email.rb +146 -0
- data/lib/tempmail_sdk/providers/huskmail.rb +132 -0
- data/lib/tempmail_sdk/providers/internxt.rb +176 -0
- data/lib/tempmail_sdk/providers/linshi_xyz.rb +79 -0
- data/lib/tempmail_sdk/providers/mailmomy.rb +1 -1
- data/lib/tempmail_sdk/providers/mailticking.rb +139 -0
- data/lib/tempmail_sdk/providers/mtempmail.rb +140 -0
- data/lib/tempmail_sdk/providers/nowtempmail.rb +112 -0
- data/lib/tempmail_sdk/providers/noxen_de5_net.rb +392 -0
- data/lib/tempmail_sdk/providers/nukemail.rb +188 -0
- data/lib/tempmail_sdk/providers/nullmail.rb +95 -0
- data/lib/tempmail_sdk/providers/shadowmail.rb +174 -0
- data/lib/tempmail_sdk/providers/shitpost_email.rb +85 -0
- data/lib/tempmail_sdk/providers/smails.rb +128 -0
- data/lib/tempmail_sdk/providers/temp_mail_gg.rb +362 -0
- data/lib/tempmail_sdk/providers/tempmail100.rb +148 -0
- data/lib/tempmail_sdk/providers/tempmail_ee.rb +373 -0
- data/lib/tempmail_sdk/providers/tempmailportal.rb +131 -0
- data/lib/tempmail_sdk/providers/tempmails_io.rb +89 -0
- data/lib/tempmail_sdk/providers/tempmailto.rb +312 -0
- data/lib/tempmail_sdk/providers/temporarymail_com.rb +184 -0
- data/lib/tempmail_sdk/providers/tenmin_app.rb +100 -0
- data/lib/tempmail_sdk/providers/thirty_min_email.rb +90 -0
- data/lib/tempmail_sdk/providers/tmpkit.rb +129 -0
- data/lib/tempmail_sdk/providers/zerodrop.rb +90 -0
- data/lib/tempmail_sdk/registry.rb +129 -29
- data/lib/tempmail_sdk/types.rb +13 -0
- data/lib/tempmail_sdk/version.rb +1 -1
- data/lib/tempmail_sdk.rb +1 -1
- metadata +31 -7
- data/lib/tempmail_sdk/providers/gonebox_email.rb +0 -89
- data/lib/tempmail_sdk/providers/mohmal.rb +0 -208
- data/lib/tempmail_sdk/providers/temp_mail_now.rb +0 -103
- 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("=", "=").gsub("+", "+").gsub("/", "/")
|
|
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
|