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.
- 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/altmails.rb +99 -0
- data/lib/tempmail_sdk/providers/apihz.rb +138 -0
- data/lib/tempmail_sdk/providers/awamail.rb +95 -0
- data/lib/tempmail_sdk/providers/best_temp_mail.rb +77 -0
- data/lib/tempmail_sdk/providers/chatgpt_org_uk.rb +129 -0
- data/lib/tempmail_sdk/providers/clawdemail.rb +122 -0
- data/lib/tempmail_sdk/providers/crazymailing.rb +114 -0
- data/lib/tempmail_sdk/providers/disposablemail.rb +125 -0
- data/lib/tempmail_sdk/providers/disposablemail_app.rb +66 -0
- data/lib/tempmail_sdk/providers/email10min.rb +152 -0
- data/lib/tempmail_sdk/providers/emailnator.rb +132 -0
- data/lib/tempmail_sdk/providers/emailtemp_org.rb +131 -0
- data/lib/tempmail_sdk/providers/expressinboxhub.rb +118 -0
- data/lib/tempmail_sdk/providers/fakemail.rb +143 -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/haribu.rb +29 -3
- 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/linshiyouxiang_net.rb +85 -0
- data/lib/tempmail_sdk/providers/mail_sunls.rb +42 -1
- data/lib/tempmail_sdk/providers/mail_td.rb +161 -0
- data/lib/tempmail_sdk/providers/mailcat_ai.rb +52 -0
- data/lib/tempmail_sdk/providers/maildrop.rb +79 -4
- data/lib/tempmail_sdk/providers/mailgolem.rb +96 -0
- data/lib/tempmail_sdk/providers/mailinator.rb +9 -2
- data/lib/tempmail_sdk/providers/mailmomy.rb +1 -1
- data/lib/tempmail_sdk/providers/mailtemp_cc.rb +91 -0
- data/lib/tempmail_sdk/providers/mailticking.rb +139 -0
- data/lib/tempmail_sdk/providers/minuteinbox.rb +174 -0
- data/lib/tempmail_sdk/providers/moakt.rb +29 -3
- data/lib/tempmail_sdk/providers/mtempmail.rb +140 -0
- data/lib/tempmail_sdk/providers/mytempmail_cc.rb +76 -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/openinbox.rb +102 -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/smail_pw.rb +156 -0
- data/lib/tempmail_sdk/providers/smails.rb +128 -0
- data/lib/tempmail_sdk/providers/socketio_mail.rb +25 -0
- data/lib/tempmail_sdk/providers/temp_mail_gg.rb +362 -0
- data/lib/tempmail_sdk/providers/tempgbox.rb +118 -0
- data/lib/tempmail_sdk/providers/tempmail100.rb +148 -0
- data/lib/tempmail_sdk/providers/tempmail_cn.rb +111 -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/tenminutemail_net.rb +183 -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/twentyfourmail_chacuo.rb +83 -0
- data/lib/tempmail_sdk/providers/vip_215.rb +171 -0
- data/lib/tempmail_sdk/providers/zerodrop.rb +90 -0
- data/lib/tempmail_sdk/registry.rb +285 -23
- data/lib/tempmail_sdk/types.rb +13 -0
- data/lib/tempmail_sdk/version.rb +1 -1
- data/lib/tempmail_sdk.rb +1 -1
- metadata +58 -6
- data/lib/tempmail_sdk/providers/gonebox_email.rb +0 -89
- data/lib/tempmail_sdk/providers/temp_mail_now.rb +0 -103
- data/lib/tempmail_sdk/providers/webmailtemp.rb +0 -108
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
module TempmailSdk
|
|
6
|
+
module Providers
|
|
7
|
+
# NowtempMail 渠道实现(nowtempmail.com)
|
|
8
|
+
#
|
|
9
|
+
# POST /mailbox 建箱(无 body 或 {},响应 token(JWT)/mailbox),
|
|
10
|
+
# GET /messages 读信列表(Header Authorization: Bearer <token>,
|
|
11
|
+
# 响应 {"messages":[...]}),GET /message/{id} 取单封详情(Bearer)。
|
|
12
|
+
# 列表元素按多候选字段归一;详情拉取失败时以列表摘要兜底。
|
|
13
|
+
module Nowtempmail
|
|
14
|
+
CHANNEL = "nowtempmail"
|
|
15
|
+
BASE_URL = "https://nowtempmail.com"
|
|
16
|
+
|
|
17
|
+
HEADERS = {
|
|
18
|
+
"Accept" => "application/json",
|
|
19
|
+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
|
|
20
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
RETRY_KEYS = %w[id Id slug messageId message_id].freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# 设置 nowtempmail 请求的通用请求头(可选附带 Bearer token)
|
|
28
|
+
# @param token [String] 认证令牌
|
|
29
|
+
# @return [Hash]
|
|
30
|
+
def auth_headers(token)
|
|
31
|
+
hdrs = HEADERS.dup
|
|
32
|
+
hdrs["Authorization"] = "Bearer #{token}" unless token.to_s.empty?
|
|
33
|
+
hdrs
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 创建 nowtempmail.com 临时邮箱
|
|
37
|
+
# POST /mailbox(空 body)返回 token(JWT)与 mailbox 地址。
|
|
38
|
+
# @return [EmailInfo]
|
|
39
|
+
def generate_email
|
|
40
|
+
resp = Http.post("#{BASE_URL}/mailbox",
|
|
41
|
+
headers: auth_headers("").merge("Content-Type" => "application/json"),
|
|
42
|
+
timeout: 15)
|
|
43
|
+
raise "nowtempmail: 创建邮箱失败 http #{resp.status_code}" unless resp.ok?
|
|
44
|
+
|
|
45
|
+
data = resp.json
|
|
46
|
+
raise "nowtempmail: 创建响应非对象" unless data.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
token = data["token"].to_s.strip
|
|
49
|
+
mailbox = data["mailbox"].to_s.strip
|
|
50
|
+
raise "nowtempmail: 创建邮箱响应缺少必要字段" if token.empty? || mailbox.empty? || !mailbox.include?("@")
|
|
51
|
+
|
|
52
|
+
EmailInfo.new(channel: CHANNEL, email: mailbox, token: token)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# 获取 nowtempmail.com 邮件列表
|
|
56
|
+
# 流程:GET /messages 取列表,对每个元素按 id 逐封 GET /message/{id} 合并详情;
|
|
57
|
+
# 详情失败时以列表摘要归一。
|
|
58
|
+
# @param email [String] 邮箱地址
|
|
59
|
+
# @param token [String] 建箱返回的 JWT 认证令牌
|
|
60
|
+
# @return [Array<Email>]
|
|
61
|
+
def get_emails(email, token)
|
|
62
|
+
addr = email.to_s.strip
|
|
63
|
+
resp = Http.get("#{BASE_URL}/messages", headers: auth_headers(token), timeout: 15)
|
|
64
|
+
raise "nowtempmail: 获取邮件列表失败 http #{resp.status_code}" unless resp.ok?
|
|
65
|
+
|
|
66
|
+
data = resp.json
|
|
67
|
+
messages = data.is_a?(Hash) ? data["messages"] : nil
|
|
68
|
+
return [] unless messages.is_a?(Array)
|
|
69
|
+
|
|
70
|
+
messages.filter_map do |m|
|
|
71
|
+
next unless m.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
detail = get_detail(token, message_id_of(m))
|
|
74
|
+
row = m.dup
|
|
75
|
+
if detail.is_a?(Hash)
|
|
76
|
+
detail.each { |k, v| row[k] = v unless row.key?(k) }
|
|
77
|
+
end
|
|
78
|
+
Normalize.normalize_email(row, addr)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# 从列表元素中提取邮件 ID(候选键 id/Id/slug/messageId/message_id)
|
|
83
|
+
# @param row [Hash]
|
|
84
|
+
# @return [String]
|
|
85
|
+
def message_id_of(row)
|
|
86
|
+
RETRY_KEYS.each do |key|
|
|
87
|
+
val = row[key]
|
|
88
|
+
return val.to_s.strip unless val.nil? || val.to_s.strip.empty?
|
|
89
|
+
end
|
|
90
|
+
""
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# 获取 nowtempmail.com 单封邮件详情
|
|
94
|
+
# GET /message/{id}(Bearer token),响应为单封对象,含 text/html 等完整字段。
|
|
95
|
+
# @param token [String] JWT
|
|
96
|
+
# @param id [String] 邮件 ID
|
|
97
|
+
# @return [Hash, nil]
|
|
98
|
+
def get_detail(token, id)
|
|
99
|
+
return nil if id.to_s.empty?
|
|
100
|
+
|
|
101
|
+
uri = "#{BASE_URL}/message/#{URI.encode_www_form_component(id)}"
|
|
102
|
+
resp = Http.get(uri, headers: auth_headers(token), timeout: 15)
|
|
103
|
+
return nil unless resp.ok?
|
|
104
|
+
|
|
105
|
+
detail = resp.json
|
|
106
|
+
detail.is_a?(Hash) ? detail : nil
|
|
107
|
+
rescue StandardError
|
|
108
|
+
nil
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "base64"
|
|
4
|
+
require "time"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module TempmailSdk
|
|
8
|
+
module Providers
|
|
9
|
+
# NoxenDe5Net 渠道实现(UniMail-Bot 公共实例 tempmail.noxen.de5.net)
|
|
10
|
+
#
|
|
11
|
+
# 完整接入契约:
|
|
12
|
+
# 登录 POST /api/login {"username":"guest","password":"123456"}
|
|
13
|
+
# → 200 {"success":true,"role":"guest"} 并 Set-Cookie: iding-session=<JWT>;
|
|
14
|
+
# 建箱 GET /api/generate → 200 {"email":"随机@域名","expires":毫秒时间戳};
|
|
15
|
+
# 读信 GET /api/emails?mailbox=<地址>&limit=20 → 200 邮件数组(无邮件为 []);
|
|
16
|
+
# 详情 GET /api/email/{id} → {..., content, html_content, to_addrs, r2_bucket,
|
|
17
|
+
# r2_object_key, download};content/html_content 平台恒为空,原始 EML 存
|
|
18
|
+
# Cloudflare R2,download 指向 GET /api/email/{id}/download 下载端点。
|
|
19
|
+
# 鉴权边界:读信不带会话 Cookie 返回 401;访客邮箱只能查自己的 mailbox。
|
|
20
|
+
# 会话隔离:先 GET /api/session 兜底校验 cookie,未通过则重新 login;
|
|
21
|
+
# 凭据串只由会话 Cookie 与同源地址构成,读信时逐请求显式携带。
|
|
22
|
+
module NoxenDe5Net
|
|
23
|
+
BASE_URL = "https://tempmail.noxen.de5.net"
|
|
24
|
+
USER = "guest"
|
|
25
|
+
PASS = "123456"
|
|
26
|
+
# 本渠道凭据串前缀
|
|
27
|
+
TOKEN_PREFIX = "noxen-de5-net|"
|
|
28
|
+
|
|
29
|
+
HEADERS = {
|
|
30
|
+
"Accept" => "application/json",
|
|
31
|
+
"User-Agent" => "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " \
|
|
32
|
+
"(KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
module_function
|
|
36
|
+
|
|
37
|
+
# 登录取得会话 Cookie(iding-session=JWT)
|
|
38
|
+
# @return [String] "iding-session=<JWT>"
|
|
39
|
+
def session_cookie
|
|
40
|
+
headers = {
|
|
41
|
+
"Content-Type" => "application/json",
|
|
42
|
+
"Accept" => "application/json",
|
|
43
|
+
"User-Agent" => HEADERS["User-Agent"]
|
|
44
|
+
}
|
|
45
|
+
resp = Http.post("#{BASE_URL}/api/login",
|
|
46
|
+
headers: headers,
|
|
47
|
+
json: { "username" => USER, "password" => PASS },
|
|
48
|
+
timeout: 15)
|
|
49
|
+
raise "noxen-de5-net login: http #{resp.status_code}" unless resp.ok?
|
|
50
|
+
|
|
51
|
+
data = resp.json
|
|
52
|
+
raise "noxen-de5-net login: 登录失败" unless data.is_a?(Hash) && data["success"] == true
|
|
53
|
+
|
|
54
|
+
session = resp.cookie_value("iding-session").to_s.strip
|
|
55
|
+
raise "noxen-de5-net login: 未下发会话 Cookie" if session.empty?
|
|
56
|
+
|
|
57
|
+
"iding-session=#{session}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 校验会话 Cookie 是否仍有效(GET /api/session)
|
|
61
|
+
# @param cookie [String]
|
|
62
|
+
# @return [Boolean]
|
|
63
|
+
def cookie_still_valid(cookie)
|
|
64
|
+
resp = Http.get("#{BASE_URL}/api/session",
|
|
65
|
+
headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
|
|
66
|
+
return false unless resp.ok?
|
|
67
|
+
|
|
68
|
+
data = resp.json
|
|
69
|
+
data.is_a?(Hash) && data["authenticated"] == true
|
|
70
|
+
rescue StandardError
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# 校验域名是否在平台域名池内
|
|
75
|
+
# @param domain [String]
|
|
76
|
+
# @return [Boolean]
|
|
77
|
+
def domain_in_pool(domain)
|
|
78
|
+
resp = Http.get("#{BASE_URL}/api/domains", headers: HEADERS, timeout: 15)
|
|
79
|
+
return false unless resp.ok?
|
|
80
|
+
|
|
81
|
+
pool = resp.json
|
|
82
|
+
return false unless pool.is_a?(Array)
|
|
83
|
+
|
|
84
|
+
pool.any? { |d| d.to_s.casecmp(domain).zero? }
|
|
85
|
+
rescue StandardError
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# 登录并创建临时邮箱
|
|
90
|
+
# @param domain [String, nil] 可选首选域名(空时平台自动选域)
|
|
91
|
+
# token 凭据串格式:"noxen-de5-net|<iding-session=JWT>|base=<基址>"
|
|
92
|
+
# @return [EmailInfo]
|
|
93
|
+
def generate_email(domain = nil)
|
|
94
|
+
want = domain.to_s.strip
|
|
95
|
+
if !want.empty? && !domain_in_pool(want)
|
|
96
|
+
raise "noxen-de5-net generate: 域名 #{want} 不在平台域名池"
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
cookie = session_cookie
|
|
100
|
+
|
|
101
|
+
resp = Http.get("#{BASE_URL}/api/generate",
|
|
102
|
+
headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
|
|
103
|
+
raise "noxen-de5-net generate: http #{resp.status_code}" unless resp.ok?
|
|
104
|
+
|
|
105
|
+
data = resp.json
|
|
106
|
+
email = data.is_a?(Hash) ? data["email"].to_s.strip : ""
|
|
107
|
+
raise "noxen-de5-net generate: 响应缺少 email" if email.empty?
|
|
108
|
+
|
|
109
|
+
expires = data.is_a?(Hash) ? data["expires"].to_i : 0
|
|
110
|
+
expires_at = expires.positive? ? Time.at(expires / 1000).utc.iso8601 : nil
|
|
111
|
+
token = TOKEN_PREFIX + URI.encode_www_form_component(cookie) + "|base=" + BASE_URL
|
|
112
|
+
EmailInfo.new(channel: "noxen-de5-net", email: email, token: token, expires_at: expires_at)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# 从凭据串解析会话 Cookie
|
|
116
|
+
# @param token [String] 建箱下发的凭据串
|
|
117
|
+
# @return [String] "iding-session=<JWT>"
|
|
118
|
+
def cookie_from_token(token)
|
|
119
|
+
raise "noxen-de5-net: token 格式错误" unless token.to_s.start_with?(TOKEN_PREFIX)
|
|
120
|
+
|
|
121
|
+
enc = token.to_s[TOKEN_PREFIX.length..].to_s
|
|
122
|
+
enc = enc.sub(/\|base=#{Regexp.escape(BASE_URL)}\z/, "")
|
|
123
|
+
URI.decode_www_form_component(enc)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# 读取收件箱
|
|
127
|
+
# 正文获取优先级:
|
|
128
|
+
# 1) 详情(download 定位)+ 下载端点拉取原始 EML,本地拆分 text/plain 与 text/html;
|
|
129
|
+
# 2) 详情/EML 链路不可得时,用 verification_code 置顶 + preview 合成占位正文。
|
|
130
|
+
# @param email [String] 信箱地址(与凭据同源)
|
|
131
|
+
# @param token [String] 建箱下发的凭据串
|
|
132
|
+
# @return [Array<Email>]
|
|
133
|
+
def get_emails(email, token)
|
|
134
|
+
addr = email.to_s.strip
|
|
135
|
+
cookie = cookie_from_token(token)
|
|
136
|
+
cookie = session_cookie unless cookie_still_valid(cookie)
|
|
137
|
+
|
|
138
|
+
uri = "#{BASE_URL}/api/emails?mailbox=#{URI.encode_www_form_component(addr)}&limit=20"
|
|
139
|
+
resp = Http.get(uri, headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
|
|
140
|
+
if resp.status_code == 401
|
|
141
|
+
raise "noxen-de5-net 读信: http 401(会话失效或非本会话邮箱)"
|
|
142
|
+
end
|
|
143
|
+
raise "noxen-de5-net 读信: http #{resp.status_code}" unless resp.ok?
|
|
144
|
+
|
|
145
|
+
list = resp.json
|
|
146
|
+
raise "noxen-de5-net 读信: 收件箱响应非数组" unless list.is_a?(Array)
|
|
147
|
+
|
|
148
|
+
list.filter_map do |m|
|
|
149
|
+
next unless m.is_a?(Hash)
|
|
150
|
+
|
|
151
|
+
flat = m.dup
|
|
152
|
+
flat["from"] = m["sender"]
|
|
153
|
+
flat["to"] = addr
|
|
154
|
+
flat["date"] = m["received_at"]
|
|
155
|
+
flat["text"] = m["preview"]
|
|
156
|
+
flat["isRead"] = m["is_read"]
|
|
157
|
+
full = false
|
|
158
|
+
id = m["id"].to_s.strip
|
|
159
|
+
if !id.empty? && id != "0"
|
|
160
|
+
detail = fetch_detail(cookie, id)
|
|
161
|
+
if detail.is_a?(Hash)
|
|
162
|
+
flat["content"] = detail["content"]
|
|
163
|
+
flat["html_content"] = detail["html_content"]
|
|
164
|
+
flat["to_addrs"] = detail["to_addrs"]
|
|
165
|
+
flat["r2_bucket"] = detail["r2_bucket"]
|
|
166
|
+
flat["r2_object_key"] = detail["r2_object_key"]
|
|
167
|
+
dl = detail["download"].to_s
|
|
168
|
+
unless dl.empty?
|
|
169
|
+
eml = fetch_eml(cookie, dl)
|
|
170
|
+
unless eml.empty?
|
|
171
|
+
text, html = parse_eml(eml)
|
|
172
|
+
if !text.empty? || !html.empty?
|
|
173
|
+
flat["text"] = text
|
|
174
|
+
flat["html"] = html
|
|
175
|
+
full = true
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
flat["text"] = compose_placeholder(m) unless full
|
|
181
|
+
end
|
|
182
|
+
Normalize.normalize_email(flat, addr)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# 拉取单封邮件详情(GET /api/email/{id})
|
|
187
|
+
# @param cookie [String]
|
|
188
|
+
# @param id [String]
|
|
189
|
+
# @return [Hash, nil]
|
|
190
|
+
def fetch_detail(cookie, id)
|
|
191
|
+
uri = "#{BASE_URL}/api/email/#{URI.encode_www_form_component(id)}"
|
|
192
|
+
resp = Http.get(uri, headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
|
|
193
|
+
return nil unless resp.ok?
|
|
194
|
+
|
|
195
|
+
resp.json
|
|
196
|
+
rescue StandardError
|
|
197
|
+
nil
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# 拉取原始 EML 报文(详情 download 字段指向的下载端点)
|
|
201
|
+
# @param cookie [String]
|
|
202
|
+
# @param dl_path [String] 下载相对路径(如 /api/email/3880/download)
|
|
203
|
+
# @return [String] 原始报文文本
|
|
204
|
+
def fetch_eml(cookie, dl_path)
|
|
205
|
+
u = dl_path.to_s
|
|
206
|
+
unless u.start_with?("http://", "https://")
|
|
207
|
+
u = BASE_URL + u
|
|
208
|
+
end
|
|
209
|
+
resp = Http.get(u,
|
|
210
|
+
headers: {
|
|
211
|
+
"Accept" => "message/rfc822, */*",
|
|
212
|
+
"User-Agent" => HEADERS["User-Agent"],
|
|
213
|
+
"Cookie" => cookie
|
|
214
|
+
},
|
|
215
|
+
timeout: 20)
|
|
216
|
+
return "" unless resp.ok?
|
|
217
|
+
|
|
218
|
+
resp.body.to_s
|
|
219
|
+
rescue StandardError
|
|
220
|
+
""
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# 全文不可得时的合成占位正文:verification_code 置顶,preview 附后。
|
|
224
|
+
# @param m [Hash] 列表元素 map(verification_code/preview)
|
|
225
|
+
# @return [String]
|
|
226
|
+
def compose_placeholder(m)
|
|
227
|
+
code = m["verification_code"].to_s.strip
|
|
228
|
+
preview = m["preview"].to_s.strip
|
|
229
|
+
parts = []
|
|
230
|
+
parts << "验证码: #{code}" unless code.empty?
|
|
231
|
+
parts << preview unless preview.empty?
|
|
232
|
+
parts.join("\n\n")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# 解析 EML 原始报文 → [纯文本正文, HTML 正文]
|
|
236
|
+
# @param eml [String] 原始报文
|
|
237
|
+
# @return [Array(String, String)]
|
|
238
|
+
def parse_eml(eml)
|
|
239
|
+
payload = eml.to_s.gsub("\r\n", "\n").gsub("\r", "")
|
|
240
|
+
headers, body = split_eml(payload, 0)
|
|
241
|
+
parse_entity(headers, body)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# 将原始报文切分为首部 map 与正文块
|
|
245
|
+
# @param payload [String] 已做 CRLF→LF 归一化的原始报文
|
|
246
|
+
# @param offset [Integer] 起始行号(外包 "#participant"+part 时置 1)
|
|
247
|
+
# @return [Array(Hash, String)]
|
|
248
|
+
def split_eml(payload, offset)
|
|
249
|
+
lines = payload.split("\n", -1)
|
|
250
|
+
headers = {}
|
|
251
|
+
i = offset
|
|
252
|
+
i += 1 if i < lines.length && lines[i].start_with?("From ")
|
|
253
|
+
cur_key = ""
|
|
254
|
+
while i < lines.length
|
|
255
|
+
line = lines[i]
|
|
256
|
+
break if line == ""
|
|
257
|
+
|
|
258
|
+
# 折行续行(RFC 5322):以空白开头且已有当前头键时拼接到上一条
|
|
259
|
+
if (line[0] == " " || line[0] == "\t") && !cur_key.empty?
|
|
260
|
+
headers[cur_key] = "#{headers[cur_key]} #{line.strip}"
|
|
261
|
+
i += 1
|
|
262
|
+
next
|
|
263
|
+
end
|
|
264
|
+
if (k = line.index(":")) && k.positive?
|
|
265
|
+
cur_key = line[0...k].strip.downcase
|
|
266
|
+
headers[cur_key] = line[(k + 1)..].to_s.strip
|
|
267
|
+
end
|
|
268
|
+
i += 1
|
|
269
|
+
end
|
|
270
|
+
i += 1 if i < lines.length
|
|
271
|
+
[headers, lines[i..].to_a.join("\n")]
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# 从 Content-Type 头值提取 multipart boundary(引号可选)
|
|
275
|
+
# @param ct_raw [String]
|
|
276
|
+
# @return [String]
|
|
277
|
+
def extract_boundary(ct_raw)
|
|
278
|
+
m = /boundary="?([^";\s]+)"?/i.match(ct_raw.to_s)
|
|
279
|
+
m ? m[1] : ""
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# 按 boundary 切出各 part(含各自首部行)
|
|
283
|
+
# @param body [String]
|
|
284
|
+
# @param boundary [String]
|
|
285
|
+
# @return [Array<String>]
|
|
286
|
+
def split_multipart(body, boundary)
|
|
287
|
+
segments = body.split("--#{boundary}")
|
|
288
|
+
segments.filter_map do |seg|
|
|
289
|
+
seg = seg.sub(/\A\n/, "")
|
|
290
|
+
seg = seg.sub(/--\n\z/, "").sub(/--\z/, "")
|
|
291
|
+
seg.empty? ? nil : seg
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# 递归解析单个 MIME 实体(上游 parseEntity 同构)
|
|
296
|
+
# @param headers [Hash]
|
|
297
|
+
# @param body [String]
|
|
298
|
+
# @return [Array(String, String)]
|
|
299
|
+
def parse_entity(headers, body)
|
|
300
|
+
ct = headers["content-type"].to_s.downcase
|
|
301
|
+
cte = headers["content-transfer-encoding"].to_s.downcase
|
|
302
|
+
|
|
303
|
+
# 单体:text/html 或 text/plain(含无 Content-Type 时按纯文本处理)
|
|
304
|
+
unless ct.start_with?("multipart/")
|
|
305
|
+
decoded = decode_part(body, cte)
|
|
306
|
+
return ct.include?("text/html") ? ["", decoded] : [decoded, ""]
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
# 复合:递归拆分,text 槽与 html 槽各自取第一个非空命中
|
|
310
|
+
text = ""
|
|
311
|
+
html = ""
|
|
312
|
+
boundary = extract_boundary(headers["content-type"].to_s)
|
|
313
|
+
unless boundary.empty?
|
|
314
|
+
split_multipart(body, boundary).each do |part|
|
|
315
|
+
ph, pb = split_eml("#participant\n#{part}", 1)
|
|
316
|
+
pct = ph["content-type"].to_s.downcase
|
|
317
|
+
if pct.start_with?("multipart/")
|
|
318
|
+
t, h = parse_entity(ph, pb)
|
|
319
|
+
text = t if text.empty?
|
|
320
|
+
html = h if html.empty?
|
|
321
|
+
elsif pct.start_with?("message/rfc822")
|
|
322
|
+
nh, nb = split_eml(pb, 0)
|
|
323
|
+
t, h = parse_entity(nh, nb)
|
|
324
|
+
text = t if text.empty?
|
|
325
|
+
html = h if html.empty?
|
|
326
|
+
elsif pct.include?("rfc822-headers")
|
|
327
|
+
# 纯头部 part 跳过,正文在后续 part 中抓取
|
|
328
|
+
next
|
|
329
|
+
else
|
|
330
|
+
t, h = parse_entity(ph, pb)
|
|
331
|
+
text = t if text.empty?
|
|
332
|
+
html = h if html.empty?
|
|
333
|
+
end
|
|
334
|
+
break if !text.empty? && !html.empty?
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
# 无 HTML 命中时从整体原文兜底抓取 HTML 片段(上游 guessHtmlFromRaw 同构)
|
|
338
|
+
html = guess_html(body) if html.empty?
|
|
339
|
+
[text, html]
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# 按 Content-Transfer-Encoding 解码 part 内容
|
|
343
|
+
# @param data [String]
|
|
344
|
+
# @param cte [String]
|
|
345
|
+
# @return [String]
|
|
346
|
+
def decode_part(data, cte)
|
|
347
|
+
case cte.to_s.strip.downcase
|
|
348
|
+
when "base64"
|
|
349
|
+
joined = data.gsub(/[\n\r\t ]/, "")
|
|
350
|
+
begin
|
|
351
|
+
Base64.strict_decode64(joined).strip.force_encoding("UTF-8")
|
|
352
|
+
rescue ArgumentError
|
|
353
|
+
data
|
|
354
|
+
end
|
|
355
|
+
when "quoted-printable"
|
|
356
|
+
decode_quoted_printable(data)
|
|
357
|
+
else
|
|
358
|
+
# 7bit/8bit/binary:原样返回
|
|
359
|
+
data.strip
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# 最小 quoted-printable 解码(=XX 十六进制转字节,行尾 = 软换行)
|
|
364
|
+
# @param data [String]
|
|
365
|
+
# @return [String]
|
|
366
|
+
def decode_quoted_printable(data)
|
|
367
|
+
out = data.gsub(/=\r?\n/, "")
|
|
368
|
+
out = out.gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
|
|
369
|
+
out.force_encoding("UTF-8")
|
|
370
|
+
rescue StandardError
|
|
371
|
+
data
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# 从整体原文中抓取 <html>…</html> 片段(上游 guessHtmlFromRaw 同构)
|
|
375
|
+
# @param body [String]
|
|
376
|
+
# @return [String]
|
|
377
|
+
def guess_html(body)
|
|
378
|
+
return "" if body.to_s.empty?
|
|
379
|
+
|
|
380
|
+
lower = body.downcase
|
|
381
|
+
hs = lower.index("<html")
|
|
382
|
+
hs = lower.index("<!doctype html") if hs.nil?
|
|
383
|
+
return "" if hs.nil?
|
|
384
|
+
|
|
385
|
+
he = lower.rindex("</html>")
|
|
386
|
+
return "" if he.nil? || he < hs
|
|
387
|
+
|
|
388
|
+
body[hs...(he + 7)]
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module TempmailSdk
|
|
7
|
+
module Providers
|
|
8
|
+
# Nukemail 渠道实现(nukemail.app)
|
|
9
|
+
#
|
|
10
|
+
# 完整接入契约:
|
|
11
|
+
# 挑战 GET /api/pow/challenge?difficulty=4 → 200 {"id","challenge","difficulty"};
|
|
12
|
+
# 求 nonce 使 SHA-256(challenge+nonce) 十六进制前 4 位为 0
|
|
13
|
+
# (前端 solvePow 逐 nonce 自 0 递增)。
|
|
14
|
+
# 建箱 POST /api/inbox/create body {"address","domain","pow_id","pow_nonce"}
|
|
15
|
+
# → 200 {"token":"NUKE-xxxxxxxx","email":"名@域名"},并
|
|
16
|
+
# Set-Cookie: nukemail_token=<token>(Secure; HttpOnly; SameSite=lax, 72h)。
|
|
17
|
+
# 读信 GET /api/inbox(Cookie: nukemail_token=<token>)→ 200
|
|
18
|
+
# {"token","state","addresses":[...],"messages":[...],"is_premium"...};
|
|
19
|
+
# messages 元素字段为 sender、sender_name、subject、body_html、body_text、
|
|
20
|
+
# received_at、read。
|
|
21
|
+
# 会话恢复 POST /api/inbox/resume {"accessCode":<token>} 重设 Cookie,仅作兜底。
|
|
22
|
+
# 会话隔离:nukemail_token 由生成结果持久化为 token,读信时以显式 Cookie 头携带。
|
|
23
|
+
module Nukemail
|
|
24
|
+
CHANNEL = "nukemail"
|
|
25
|
+
BASE_URL = "https://nukemail.app"
|
|
26
|
+
|
|
27
|
+
HEADERS = {
|
|
28
|
+
"Accept" => "application/json",
|
|
29
|
+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
|
|
30
|
+
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
module_function
|
|
34
|
+
|
|
35
|
+
# 求解 PoW:返回使 SHA-256(challenge+nonce) 十六进制
|
|
36
|
+
# 前 difficulty 位为 0 的最小 nonce(算法与前端 solvePow 逐字对齐)。
|
|
37
|
+
# @param challenge [String] 挑战串
|
|
38
|
+
# @param difficulty [Integer] 难度(十六进制前缀 0 位数)
|
|
39
|
+
# @return [Integer] nonce
|
|
40
|
+
def solve_pow(challenge, difficulty)
|
|
41
|
+
prefix = "0" * difficulty
|
|
42
|
+
nonce = 0
|
|
43
|
+
loop do
|
|
44
|
+
digest = Digest::SHA256.hexdigest("#{challenge}#{nonce}")
|
|
45
|
+
break if digest.start_with?(prefix)
|
|
46
|
+
|
|
47
|
+
nonce += 1
|
|
48
|
+
end
|
|
49
|
+
nonce
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# 生成本地随机名(与前端 generateRandomName 等价形态)
|
|
53
|
+
# @return [String]
|
|
54
|
+
def random_address
|
|
55
|
+
chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
56
|
+
"nuke" + Array.new(10) { chars[rand(chars.length)] }.join
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 取第一个非 premium 的活跃域名
|
|
60
|
+
# @return [String]
|
|
61
|
+
def default_domain
|
|
62
|
+
resp = Http.get("#{BASE_URL}/api/domains", headers: HEADERS, timeout: 15)
|
|
63
|
+
raise "nukemail generate: domains http #{resp.status_code}" unless resp.ok?
|
|
64
|
+
|
|
65
|
+
data = resp.json
|
|
66
|
+
domains = data.is_a?(Hash) ? data["domains"] : nil
|
|
67
|
+
raise "nukemail generate: domains 响应非对象" unless domains.is_a?(Array)
|
|
68
|
+
|
|
69
|
+
domains.each do |d|
|
|
70
|
+
next unless d.is_a?(Hash)
|
|
71
|
+
|
|
72
|
+
dom = d["domain"].to_s.strip
|
|
73
|
+
return dom if !dom.empty? && d["is_premium_only"] != true
|
|
74
|
+
end
|
|
75
|
+
raise "nukemail generate: 无可用非 premium 域名"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# 创建临时邮箱(PoW 建箱)
|
|
79
|
+
# token 为平台返回的 NUKE-<随机> 访问码,读信时转成 nukemail_token Cookie 携带。
|
|
80
|
+
# @return [EmailInfo]
|
|
81
|
+
def generate_email
|
|
82
|
+
# 1) 取 PoW 挑战
|
|
83
|
+
resp = Http.get("#{BASE_URL}/api/pow/challenge?difficulty=4",
|
|
84
|
+
headers: HEADERS, timeout: 15)
|
|
85
|
+
raise "nukemail generate: challenge http #{resp.status_code}" unless resp.ok?
|
|
86
|
+
|
|
87
|
+
ch = resp.json
|
|
88
|
+
raise "nukemail generate: challenge 响应非对象" unless ch.is_a?(Hash)
|
|
89
|
+
|
|
90
|
+
ch_id = ch["id"].to_s.strip
|
|
91
|
+
challenge = ch["challenge"].to_s.strip
|
|
92
|
+
raise "nukemail generate: challenge 响应缺少 id/challenge" if ch_id.empty? || challenge.empty?
|
|
93
|
+
|
|
94
|
+
difficulty = ch["difficulty"].to_i
|
|
95
|
+
difficulty = 4 if difficulty <= 0
|
|
96
|
+
|
|
97
|
+
# 2) 本地求 PoW 解(SHA-256 前缀 4 零)
|
|
98
|
+
nonce = solve_pow(challenge, difficulty)
|
|
99
|
+
|
|
100
|
+
# 3) 取域名并建箱
|
|
101
|
+
dom = default_domain
|
|
102
|
+
body = {
|
|
103
|
+
"address" => random_address,
|
|
104
|
+
"domain" => dom,
|
|
105
|
+
"pow_id" => ch_id,
|
|
106
|
+
"pow_nonce" => nonce.to_s
|
|
107
|
+
}
|
|
108
|
+
headers = {
|
|
109
|
+
"Content-Type" => "application/json",
|
|
110
|
+
"Accept" => "application/json",
|
|
111
|
+
"User-Agent" => HEADERS["User-Agent"]
|
|
112
|
+
}
|
|
113
|
+
resp2 = Http.post("#{BASE_URL}/api/inbox/create",
|
|
114
|
+
headers: headers, json: body, timeout: 15)
|
|
115
|
+
unless resp2.ok?
|
|
116
|
+
raise "nukemail generate: create http #{resp2.status_code}"
|
|
117
|
+
end
|
|
118
|
+
data = resp2.json
|
|
119
|
+
raise "nukemail generate: create 响应非对象" unless data.is_a?(Hash)
|
|
120
|
+
|
|
121
|
+
token = data["token"].to_s.strip
|
|
122
|
+
email = data["email"].to_s.strip
|
|
123
|
+
raise "nukemail generate: create 响应缺少 token/email" if token.empty? || email.empty?
|
|
124
|
+
|
|
125
|
+
EmailInfo.new(channel: CHANNEL, email: email, token: token)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 读取收件箱
|
|
129
|
+
# 主通道 GET /api/inbox 带 Cookie: nukemail_token=<token>;
|
|
130
|
+
# 会话过期时经 POST /api/inbox/resume 显式重设会话后重试一次。
|
|
131
|
+
# @param email [String] 邮箱地址
|
|
132
|
+
# @param token [String] 建箱返回的 NUKE-<随机> 访问码
|
|
133
|
+
# @return [Array<Email>]
|
|
134
|
+
def get_emails(email, token)
|
|
135
|
+
addr = email.to_s.strip
|
|
136
|
+
tk = token.to_s.strip
|
|
137
|
+
raise "nukemail: token 为空" if tk.empty?
|
|
138
|
+
|
|
139
|
+
cookie = "nukemail_token=#{tk}"
|
|
140
|
+
|
|
141
|
+
fetch = lambda do
|
|
142
|
+
resp = Http.get("#{BASE_URL}/api/inbox",
|
|
143
|
+
headers: HEADERS.merge("Cookie" => cookie), timeout: 15)
|
|
144
|
+
raise "nukemail 读信: http #{resp.status_code}" unless resp.ok?
|
|
145
|
+
|
|
146
|
+
resp.json
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
data = fetch.call
|
|
150
|
+
raise "nukemail 读信: 响应非对象" unless data.is_a?(Hash)
|
|
151
|
+
|
|
152
|
+
if data["state"].to_s.empty? || data["state"] == "expired"
|
|
153
|
+
# 会话可能已过期:经 resume 重设会话后重试
|
|
154
|
+
headers = {
|
|
155
|
+
"Content-Type" => "application/json",
|
|
156
|
+
"User-Agent" => HEADERS["User-Agent"]
|
|
157
|
+
}
|
|
158
|
+
begin
|
|
159
|
+
Http.post("#{BASE_URL}/api/inbox/resume",
|
|
160
|
+
headers: headers, json: { "accessCode" => tk }, timeout: 15)
|
|
161
|
+
rescue StandardError
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
data = fetch.call
|
|
165
|
+
raise "nukemail 读信: 响应非对象" unless data.is_a?(Hash)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
messages = data["messages"]
|
|
169
|
+
return [] unless messages.is_a?(Array)
|
|
170
|
+
|
|
171
|
+
messages.filter_map do |m|
|
|
172
|
+
next unless m.is_a?(Hash)
|
|
173
|
+
|
|
174
|
+
flat = m.dup
|
|
175
|
+
flat["to"] = addr
|
|
176
|
+
# 平台消息字段为 body_html/body_text,补齐 text/html 候选(若原字段缺失)
|
|
177
|
+
flat["text"] = flat["body_text"] if flat["text"].nil?
|
|
178
|
+
flat["html"] = flat["body_html"] if flat["html"].nil?
|
|
179
|
+
flat["date"] = m["received_at"]
|
|
180
|
+
flat["read"] = m["read"]
|
|
181
|
+
# sender 是小写发件人地址,sender_name 是展示名
|
|
182
|
+
flat["sender_email"] = m["sender"]
|
|
183
|
+
Normalize.normalize_email(flat, addr)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|