tempmail_sdk 1.3.3
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 +7 -0
- data/README.md +116 -0
- data/lib/tempmail_sdk/channels.rb +289 -0
- data/lib/tempmail_sdk/channels_data.rb +289 -0
- data/lib/tempmail_sdk/client.rb +193 -0
- data/lib/tempmail_sdk/config.rb +104 -0
- data/lib/tempmail_sdk/html_utils.rb +42 -0
- data/lib/tempmail_sdk/http.rb +225 -0
- data/lib/tempmail_sdk/logger.rb +38 -0
- data/lib/tempmail_sdk/normalize.rb +193 -0
- data/lib/tempmail_sdk/providers/anonbox.rb +248 -0
- data/lib/tempmail_sdk/providers/anonymmail.rb +92 -0
- data/lib/tempmail_sdk/providers/byom.rb +48 -0
- data/lib/tempmail_sdk/providers/catchmail.rb +110 -0
- data/lib/tempmail_sdk/providers/cleantempmail.rb +49 -0
- data/lib/tempmail_sdk/providers/devmail_uk.rb +58 -0
- data/lib/tempmail_sdk/providers/dropmail.rb +133 -0
- data/lib/tempmail_sdk/providers/dropmail_click.rb +81 -0
- data/lib/tempmail_sdk/providers/dropmail_me.rb +163 -0
- data/lib/tempmail_sdk/providers/duckmail.rb +63 -0
- data/lib/tempmail_sdk/providers/eyepaste.rb +119 -0
- data/lib/tempmail_sdk/providers/fake_email_site.rb +48 -0
- data/lib/tempmail_sdk/providers/fake_legal.rb +109 -0
- data/lib/tempmail_sdk/providers/fmail.rb +87 -0
- data/lib/tempmail_sdk/providers/freecustom.rb +135 -0
- data/lib/tempmail_sdk/providers/getnada.rb +146 -0
- data/lib/tempmail_sdk/providers/gonebox_email.rb +89 -0
- data/lib/tempmail_sdk/providers/guerrillamail.rb +62 -0
- data/lib/tempmail_sdk/providers/guerrillamail_mirrors.rb +81 -0
- data/lib/tempmail_sdk/providers/harakirimail.rb +84 -0
- data/lib/tempmail_sdk/providers/haribu.rb +176 -0
- data/lib/tempmail_sdk/providers/inboxes.rb +110 -0
- data/lib/tempmail_sdk/providers/inboxkitten.rb +90 -0
- data/lib/tempmail_sdk/providers/linshiyou.rb +157 -0
- data/lib/tempmail_sdk/providers/lroid.rb +170 -0
- data/lib/tempmail_sdk/providers/m2u.rb +126 -0
- data/lib/tempmail_sdk/providers/mail10s.rb +59 -0
- data/lib/tempmail_sdk/providers/mail123.rb +82 -0
- data/lib/tempmail_sdk/providers/mail_cx.rb +133 -0
- data/lib/tempmail_sdk/providers/mail_sunls.rb +68 -0
- data/lib/tempmail_sdk/providers/mail_tm.rb +99 -0
- data/lib/tempmail_sdk/providers/mailcatch.rb +91 -0
- data/lib/tempmail_sdk/providers/maildrop.rb +92 -0
- data/lib/tempmail_sdk/providers/maildrop_cc.rb +110 -0
- data/lib/tempmail_sdk/providers/mailforspam.rb +96 -0
- data/lib/tempmail_sdk/providers/mailhole_de.rb +70 -0
- data/lib/tempmail_sdk/providers/mailinator.rb +182 -0
- data/lib/tempmail_sdk/providers/mailmomy.rb +108 -0
- data/lib/tempmail_sdk/providers/mailnesia.rb +141 -0
- data/lib/tempmail_sdk/providers/mffac.rb +101 -0
- data/lib/tempmail_sdk/providers/moakt.rb +296 -0
- data/lib/tempmail_sdk/providers/neighbours.rb +140 -0
- data/lib/tempmail_sdk/providers/neighbours_sh.rb +117 -0
- data/lib/tempmail_sdk/providers/nimail.rb +100 -0
- data/lib/tempmail_sdk/providers/ockito.rb +163 -0
- data/lib/tempmail_sdk/providers/one_sec_mail.rb +46 -0
- data/lib/tempmail_sdk/providers/restmail_net.rb +60 -0
- data/lib/tempmail_sdk/providers/rootsh.rb +167 -0
- data/lib/tempmail_sdk/providers/shitty_email.rb +84 -0
- data/lib/tempmail_sdk/providers/smailpro.rb +103 -0
- data/lib/tempmail_sdk/providers/ta_easy.rb +53 -0
- data/lib/tempmail_sdk/providers/temp_mail_io.rb +67 -0
- data/lib/tempmail_sdk/providers/temp_mail_now.rb +103 -0
- data/lib/tempmail_sdk/providers/temp_mail_org.rb +89 -0
- data/lib/tempmail_sdk/providers/tempemail_co.rb +142 -0
- data/lib/tempmail_sdk/providers/tempemail_info.rb +121 -0
- data/lib/tempmail_sdk/providers/tempemails_net.rb +159 -0
- data/lib/tempmail_sdk/providers/tempfastmail.rb +76 -0
- data/lib/tempmail_sdk/providers/tempgmailer.rb +126 -0
- data/lib/tempmail_sdk/providers/tempgo_email.rb +74 -0
- data/lib/tempmail_sdk/providers/tempinbox.rb +70 -0
- data/lib/tempmail_sdk/providers/tempmail.rb +66 -0
- data/lib/tempmail_sdk/providers/tempmail365.rb +119 -0
- data/lib/tempmail_sdk/providers/tempmail_fish.rb +89 -0
- data/lib/tempmail_sdk/providers/tempmail_fyi.rb +124 -0
- data/lib/tempmail_sdk/providers/tempmail_lol.rb +42 -0
- data/lib/tempmail_sdk/providers/tempmail_lol_v2.rb +57 -0
- data/lib/tempmail_sdk/providers/tempmail_plus.rb +80 -0
- data/lib/tempmail_sdk/providers/tempmailc.rb +80 -0
- data/lib/tempmail_sdk/providers/tempmailpro.rb +89 -0
- data/lib/tempmail_sdk/providers/tempmailten.rb +150 -0
- data/lib/tempmail_sdk/providers/temporam.rb +95 -0
- data/lib/tempmail_sdk/providers/tempp_mails.rb +150 -0
- data/lib/tempmail_sdk/providers/tempy_email.rb +73 -0
- data/lib/tempmail_sdk/providers/ten_minute_mail_net.rb +125 -0
- data/lib/tempmail_sdk/providers/tenminute_one.rb +255 -0
- data/lib/tempmail_sdk/providers/throwawaymail.rb +80 -0
- data/lib/tempmail_sdk/providers/tmail_link.rb +98 -0
- data/lib/tempmail_sdk/providers/uncorreotemporal.rb +84 -0
- data/lib/tempmail_sdk/providers/webmailtemp.rb +108 -0
- data/lib/tempmail_sdk/providers/xkx_me.rb +125 -0
- data/lib/tempmail_sdk/providers/zhujump.rb +256 -0
- data/lib/tempmail_sdk/registry.rb +1097 -0
- data/lib/tempmail_sdk/retry.rb +91 -0
- data/lib/tempmail_sdk/telemetry.rb +134 -0
- data/lib/tempmail_sdk/types.rb +164 -0
- data/lib/tempmail_sdk/version.rb +6 -0
- data/lib/tempmail_sdk/webui.rb +360 -0
- data/lib/tempmail_sdk.rb +76 -0
- metadata +142 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module TempmailSdk
|
|
9
|
+
# 共享 HTTP 客户端
|
|
10
|
+
# 所有 provider 通过此模块发起 HTTP 请求,自动应用全局配置(代理、超时、SSL 等)
|
|
11
|
+
# 基于标准库 Net::HTTP 实现,无第三方依赖
|
|
12
|
+
module Http
|
|
13
|
+
# HTTP 响应的轻量封装,统一暴露状态码/正文/JSON/cookie 等
|
|
14
|
+
class Response
|
|
15
|
+
# @return [Integer] HTTP 状态码
|
|
16
|
+
attr_reader :status_code
|
|
17
|
+
# @return [String] 响应正文
|
|
18
|
+
attr_reader :body
|
|
19
|
+
# @return [Net::HTTPResponse] 原始响应对象
|
|
20
|
+
attr_reader :raw
|
|
21
|
+
|
|
22
|
+
def initialize(raw)
|
|
23
|
+
@raw = raw
|
|
24
|
+
@status_code = raw.code.to_i
|
|
25
|
+
@body = raw.body || ""
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [Boolean] 状态码是否 2xx
|
|
29
|
+
def ok?
|
|
30
|
+
@status_code >= 200 && @status_code < 300
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# 状态码非 2xx 时抛出异常(错误消息含状态码,便于重试判定)
|
|
34
|
+
def raise_for_status
|
|
35
|
+
return self if ok?
|
|
36
|
+
|
|
37
|
+
raise "HTTP request failed: #{@status_code}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 解析 JSON 正文
|
|
41
|
+
# @return [Object]
|
|
42
|
+
def json
|
|
43
|
+
JSON.parse(@body)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 获取单个响应头(大小写不敏感)
|
|
47
|
+
# @param name [String]
|
|
48
|
+
# @return [String, nil]
|
|
49
|
+
def header(name)
|
|
50
|
+
@raw[name]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 获取全部 Set-Cookie 头
|
|
54
|
+
# @return [Array<String>]
|
|
55
|
+
def set_cookies
|
|
56
|
+
@raw.get_fields("set-cookie") || []
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 从 Set-Cookie 中提取指定名称的 cookie 值
|
|
60
|
+
# @param name [String]
|
|
61
|
+
# @return [String, nil]
|
|
62
|
+
def cookie_value(name)
|
|
63
|
+
set_cookies.each do |line|
|
|
64
|
+
line.split(";").first.to_s.strip.split("=", 2).then do |k, v|
|
|
65
|
+
return v if k == name
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# 复用持久连接时,陈旧连接被对端关闭会抛出的错误类型。
|
|
73
|
+
# 原实现每次新建连接,故对这些错误重建连接重试一次,语义等价且幂等。
|
|
74
|
+
CONN_RESET_ERRORS = [EOFError, Errno::ECONNRESET, Errno::EPIPE,
|
|
75
|
+
Errno::ECONNABORTED, IOError].freeze
|
|
76
|
+
|
|
77
|
+
# 持久连接池:key => { http: Net::HTTP|nil, mutex: Mutex }
|
|
78
|
+
# 按 host/port/scheme/proxy/insecure 归类,复用 keep-alive 连接,避免每次请求重建 TCP+TLS。
|
|
79
|
+
@pool = {}
|
|
80
|
+
@pool_mutex = Mutex.new
|
|
81
|
+
@pool_config_version = -1
|
|
82
|
+
|
|
83
|
+
module_function
|
|
84
|
+
|
|
85
|
+
# 生成随机 IPv4 地址(每段 1-254),用于伪造来源 IP 请求头
|
|
86
|
+
def random_ipv4
|
|
87
|
+
Array.new(4) { rand(1..254) }.join(".")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# 生成一组伪造来源 IP 的请求头,所有头使用同一个随机 IP
|
|
91
|
+
def spoof_ip_headers
|
|
92
|
+
ip = random_ipv4
|
|
93
|
+
{
|
|
94
|
+
"X-Forwarded-For" => ip,
|
|
95
|
+
"X-Real-IP" => ip,
|
|
96
|
+
"X-Originating-IP" => ip
|
|
97
|
+
}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# 将伪造来源 IP 请求头合并到用户指定的 headers 中(每次请求生成新的随机 IP)
|
|
101
|
+
def merge_spoof_headers(headers)
|
|
102
|
+
merged = spoof_ip_headers
|
|
103
|
+
merged.merge!(headers) if headers
|
|
104
|
+
merged
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# 带全局配置的 GET 请求(自动注入伪造来源 IP 请求头)
|
|
108
|
+
# @param url [String]
|
|
109
|
+
# @param headers [Hash, nil]
|
|
110
|
+
# @param timeout [Integer, nil]
|
|
111
|
+
# @return [Response]
|
|
112
|
+
def get(url, headers: nil, timeout: nil)
|
|
113
|
+
request(:get, url, headers: headers, timeout: timeout)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# 带全局配置的 POST 请求
|
|
117
|
+
# @param json [Object, nil] 作为 JSON 序列化的请求体
|
|
118
|
+
# @param body [String, nil] 原始请求体
|
|
119
|
+
def post(url, headers: nil, timeout: nil, json: nil, body: nil)
|
|
120
|
+
request(:post, url, headers: headers, timeout: timeout, json: json, body: body)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# 通用请求执行(复用持久连接池)
|
|
124
|
+
def request(method, url, headers: nil, timeout: nil, json: nil, body: nil)
|
|
125
|
+
config = Config.get_config
|
|
126
|
+
uri = URI.parse(url)
|
|
127
|
+
effective_timeout = timeout || config.timeout || 15
|
|
128
|
+
req = build_request(method, uri, config, headers, json, body)
|
|
129
|
+
|
|
130
|
+
entry = acquire_entry(uri, config)
|
|
131
|
+
# 同一连接同一时刻仅允许一个请求,保证 socket 读写不交错
|
|
132
|
+
entry[:mutex].synchronize do
|
|
133
|
+
perform_with_reuse(entry, uri, config, effective_timeout, req)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# 获取(或初始化)目标主机的池条目;配置变更时整体重建连接池
|
|
138
|
+
def acquire_entry(uri, config)
|
|
139
|
+
key = pool_key(uri)
|
|
140
|
+
@pool_mutex.synchronize do
|
|
141
|
+
version = Config.config_version
|
|
142
|
+
if version != @pool_config_version
|
|
143
|
+
@pool.each_value { |e| safe_finish(e[:http]) }
|
|
144
|
+
@pool.clear
|
|
145
|
+
@pool_config_version = version
|
|
146
|
+
end
|
|
147
|
+
@pool[key] ||= { http: nil, mutex: Mutex.new }
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# 使用/建立持久连接执行请求;陈旧连接被对端关闭时重建一次
|
|
152
|
+
def perform_with_reuse(entry, uri, config, timeout, req)
|
|
153
|
+
http = entry[:http]
|
|
154
|
+
if http.nil? || !http.started?
|
|
155
|
+
http = start_http(uri, config, timeout)
|
|
156
|
+
entry[:http] = http
|
|
157
|
+
else
|
|
158
|
+
http.open_timeout = timeout
|
|
159
|
+
http.read_timeout = timeout
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
begin
|
|
163
|
+
Response.new(http.request(req))
|
|
164
|
+
rescue *CONN_RESET_ERRORS
|
|
165
|
+
safe_finish(http)
|
|
166
|
+
http = start_http(uri, config, timeout)
|
|
167
|
+
entry[:http] = http
|
|
168
|
+
Response.new(http.request(req))
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# 池键:区分协议/主机/端口即可(配置变更整体清池)
|
|
173
|
+
def pool_key(uri)
|
|
174
|
+
"#{uri.scheme}://#{uri.host}:#{uri.port}"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# 建立并启动持久连接(启用 keep-alive)
|
|
178
|
+
def start_http(uri, config, timeout)
|
|
179
|
+
http = build_http(uri, config, timeout)
|
|
180
|
+
http.keep_alive_timeout = 30
|
|
181
|
+
http.start
|
|
182
|
+
http
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# 安全关闭连接,忽略关闭期异常
|
|
186
|
+
def safe_finish(http)
|
|
187
|
+
http&.finish if http&.started?
|
|
188
|
+
rescue StandardError
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# 构造 Net::HTTP 实例(应用代理、TLS、超时)
|
|
193
|
+
def build_http(uri, config, timeout)
|
|
194
|
+
http = if config.proxy && !config.proxy.empty?
|
|
195
|
+
p = URI.parse(config.proxy)
|
|
196
|
+
Net::HTTP.new(uri.host, uri.port, p.host, p.port, p.user, p.password)
|
|
197
|
+
else
|
|
198
|
+
Net::HTTP.new(uri.host, uri.port)
|
|
199
|
+
end
|
|
200
|
+
http.use_ssl = (uri.scheme == "https")
|
|
201
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if config.insecure && http.use_ssl?
|
|
202
|
+
http.open_timeout = timeout
|
|
203
|
+
http.read_timeout = timeout
|
|
204
|
+
http
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# 构造请求对象并写入请求头/请求体
|
|
208
|
+
def build_request(method, uri, config, headers, json, body)
|
|
209
|
+
klass = method == :post ? Net::HTTP::Post : Net::HTTP::Get
|
|
210
|
+
req = klass.new(uri.request_uri)
|
|
211
|
+
|
|
212
|
+
merged = merge_spoof_headers(headers)
|
|
213
|
+
config.headers&.each { |k, v| req[k] = v }
|
|
214
|
+
merged.each { |k, v| req[k] = v }
|
|
215
|
+
|
|
216
|
+
if json
|
|
217
|
+
req.body = JSON.generate(json)
|
|
218
|
+
req["Content-Type"] ||= "application/json"
|
|
219
|
+
elsif body
|
|
220
|
+
req.body = body
|
|
221
|
+
end
|
|
222
|
+
req
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
|
|
5
|
+
module TempmailSdk
|
|
6
|
+
# SDK 日志模块
|
|
7
|
+
# 默认静默不输出,用户可通过 set_log_level 启用
|
|
8
|
+
module Log
|
|
9
|
+
LOG_SILENT = 100 # 关闭所有日志
|
|
10
|
+
LOG_ERROR = Logger::ERROR
|
|
11
|
+
LOG_WARN = Logger::WARN
|
|
12
|
+
LOG_INFO = Logger::INFO
|
|
13
|
+
LOG_DEBUG = Logger::DEBUG
|
|
14
|
+
|
|
15
|
+
@logger = Logger.new($stderr)
|
|
16
|
+
@logger.level = LOG_SILENT
|
|
17
|
+
@logger.formatter = proc { |severity, _time, _progname, msg| "#{severity} #{msg}\n" }
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
# 设置日志级别,默认 LOG_SILENT(不输出)
|
|
21
|
+
# @param level [Integer]
|
|
22
|
+
def set_log_level(level)
|
|
23
|
+
@logger.level = level
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 设置自定义 logger 实例
|
|
27
|
+
# @param logger [Logger]
|
|
28
|
+
def set_logger(logger)
|
|
29
|
+
@logger = logger
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Logger] 当前 SDK 使用的 logger
|
|
33
|
+
def logger
|
|
34
|
+
@logger
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module TempmailSdk
|
|
6
|
+
# 邮件数据标准化模块
|
|
7
|
+
# 将各提供商返回的原始邮件数据(Hash,键为字符串)标准化为统一的 Email 格式
|
|
8
|
+
module Normalize
|
|
9
|
+
# 各字段的候选键顺序(frozen 常量,避免每次调用重复分配数组)
|
|
10
|
+
ID_KEYS = %w[id eid _id mailboxId messageId mail_id].freeze
|
|
11
|
+
FROM_KEYS = %w[from_addr from_address fromAddress mail_sender sender
|
|
12
|
+
address_from from_email from messageFrom].freeze
|
|
13
|
+
TO_KEYS = %w[to to_address toAddress name_to email_address address].freeze
|
|
14
|
+
SUBJECT_KEYS = %w[subject e_subject mail_title].freeze
|
|
15
|
+
TEXT_KEYS = %w[text text_body preview_text mail_body_text body content
|
|
16
|
+
body_text text_content description].freeze
|
|
17
|
+
HTML_KEYS = %w[html html_body html_content body_html mail_body_html].freeze
|
|
18
|
+
DATE_STR_KEYS = %w[received_at receivedAt created_at createdAt date].freeze
|
|
19
|
+
DATE_NUM_KEYS = %w[timestamp e_date].freeze
|
|
20
|
+
IS_READ_BOOL_KEYS = %w[seen read isRead].freeze
|
|
21
|
+
IS_READ_MIXED_KEYS = %w[is_read is_seen].freeze
|
|
22
|
+
|
|
23
|
+
# 日期解析用预编译正则(避免方法内每次新建 Regexp)
|
|
24
|
+
TRAILING_Z_RE = /Z\z/.freeze
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# 将各提供商返回的原始邮件数据标准化为统一的 Email 格式
|
|
29
|
+
# 不同渠道字段名各异,此函数通过多字段候选策略统一映射
|
|
30
|
+
# @param raw [Hash] 原始邮件数据(字符串键)
|
|
31
|
+
# @param recipient_email [String] 收件人邮箱(无匹配字段时回退)
|
|
32
|
+
# @return [Email]
|
|
33
|
+
def normalize_email(raw, recipient_email = "")
|
|
34
|
+
raw = stringify_keys(raw)
|
|
35
|
+
text = normalize_text(raw)
|
|
36
|
+
html = normalize_html(raw)
|
|
37
|
+
|
|
38
|
+
# 修正 text/html 错配
|
|
39
|
+
if !text.empty? && html.empty? && html_content?(text)
|
|
40
|
+
html = text
|
|
41
|
+
text = ""
|
|
42
|
+
end
|
|
43
|
+
text = HtmlUtils.html_to_text(html) if text.empty? && !html.empty?
|
|
44
|
+
html = "<html><body><pre>#{HtmlUtils.escape(text)}</pre></body></html>" if !text.empty? && html.empty?
|
|
45
|
+
|
|
46
|
+
Email.new(
|
|
47
|
+
id: normalize_id(raw),
|
|
48
|
+
from_addr: normalize_from(raw),
|
|
49
|
+
to: normalize_to(raw, recipient_email),
|
|
50
|
+
subject: normalize_subject(raw),
|
|
51
|
+
text: text,
|
|
52
|
+
html: html,
|
|
53
|
+
date: normalize_date(raw),
|
|
54
|
+
is_read: normalize_is_read(raw),
|
|
55
|
+
attachments: normalize_attachments(raw["attachments"])
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# 将 Hash 的符号键统一转为字符串键(浅层)
|
|
60
|
+
def stringify_keys(raw)
|
|
61
|
+
return {} unless raw.is_a?(Hash)
|
|
62
|
+
|
|
63
|
+
raw.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 检测内容是否为 HTML(只取前 200 字符)
|
|
67
|
+
def html_content?(content)
|
|
68
|
+
prefix = content[0, 200].to_s.strip.downcase
|
|
69
|
+
return true if prefix.start_with?("<!doctype html", "<html", "<body")
|
|
70
|
+
|
|
71
|
+
trimmed = content.strip.downcase
|
|
72
|
+
return true if trimmed.include?("<div") && trimmed.include?("</div>")
|
|
73
|
+
return true if trimmed.include?("<table") && trimmed.include?("</table>")
|
|
74
|
+
return true if trimmed.include?("<p") && trimmed.include?("</p>") && trimmed.include?("<")
|
|
75
|
+
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# 从 Hash 中按优先级顺序提取字符串值
|
|
80
|
+
def get_str(raw, *keys)
|
|
81
|
+
get_str_keys(raw, keys)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# 从 Hash 中按给定候选键数组(可复用 frozen 常量)提取字符串值
|
|
85
|
+
def get_str_keys(raw, keys)
|
|
86
|
+
keys.each do |key|
|
|
87
|
+
val = raw[key]
|
|
88
|
+
return val.to_s unless val.nil?
|
|
89
|
+
end
|
|
90
|
+
""
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def normalize_id(raw)
|
|
94
|
+
get_str_keys(raw, ID_KEYS)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def normalize_from(raw)
|
|
98
|
+
get_str_keys(raw, FROM_KEYS)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def normalize_to(raw, recipient_email)
|
|
102
|
+
result = get_str_keys(raw, TO_KEYS)
|
|
103
|
+
result.empty? ? recipient_email : result
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def normalize_subject(raw)
|
|
107
|
+
get_str_keys(raw, SUBJECT_KEYS)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_text(raw)
|
|
111
|
+
get_str_keys(raw, TEXT_KEYS)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def normalize_html(raw)
|
|
115
|
+
get_str_keys(raw, HTML_KEYS)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# 提取并统一日期格式为 ISO 8601
|
|
119
|
+
def normalize_date(raw)
|
|
120
|
+
DATE_STR_KEYS.each do |key|
|
|
121
|
+
val = raw[key]
|
|
122
|
+
next if val.nil?
|
|
123
|
+
|
|
124
|
+
return parse_date_value(val)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
DATE_NUM_KEYS.each do |key|
|
|
128
|
+
val = raw[key]
|
|
129
|
+
next unless val.is_a?(Numeric) && val.positive?
|
|
130
|
+
|
|
131
|
+
secs = key == "timestamp" && val < 1e12 ? val : val / 1000.0
|
|
132
|
+
return Time.at(secs).utc.iso8601
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
""
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# 解析单个日期值(字符串/秒/毫秒时间戳)
|
|
139
|
+
def parse_date_value(val)
|
|
140
|
+
if val.is_a?(String) && !val.empty?
|
|
141
|
+
begin
|
|
142
|
+
return Time.iso8601(val.sub(TRAILING_Z_RE, "+00:00")).iso8601
|
|
143
|
+
rescue ArgumentError
|
|
144
|
+
begin
|
|
145
|
+
return Time.strptime("#{val} UTC", "%Y-%m-%d %H:%M:%S %Z").utc.iso8601
|
|
146
|
+
rescue ArgumentError
|
|
147
|
+
return val
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
elsif val.is_a?(Numeric) && val.positive?
|
|
151
|
+
secs = val > 1e12 ? val / 1000.0 : val
|
|
152
|
+
return Time.at(secs).utc.iso8601
|
|
153
|
+
end
|
|
154
|
+
""
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# 提取已读状态,支持 bool / int(0|1) / str('0'|'1')
|
|
158
|
+
def normalize_is_read(raw)
|
|
159
|
+
IS_READ_BOOL_KEYS.each do |key|
|
|
160
|
+
val = raw[key]
|
|
161
|
+
return val if val == true || val == false
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
IS_READ_MIXED_KEYS.each do |key|
|
|
165
|
+
val = raw[key]
|
|
166
|
+
next if val.nil?
|
|
167
|
+
|
|
168
|
+
return val if [true, false].include?(val)
|
|
169
|
+
return val.to_i != 0 if val.is_a?(Numeric)
|
|
170
|
+
return val == "1" if val.is_a?(String)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# 提取并标准化附件列表
|
|
177
|
+
def normalize_attachments(attachments)
|
|
178
|
+
return [] unless attachments.is_a?(Array)
|
|
179
|
+
|
|
180
|
+
attachments.filter_map do |a|
|
|
181
|
+
a = stringify_keys(a)
|
|
182
|
+
next unless a.is_a?(Hash)
|
|
183
|
+
|
|
184
|
+
EmailAttachment.new(
|
|
185
|
+
filename: a["filename"] || a["name"] || "",
|
|
186
|
+
size: a["size"] || a["filesize"],
|
|
187
|
+
content_type: a["contentType"] || a["content_type"] || a["mimeType"] || a["mime_type"],
|
|
188
|
+
url: a["url"] || a["download_url"] || a["downloadUrl"]
|
|
189
|
+
)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TempmailSdk
|
|
4
|
+
module Providers
|
|
5
|
+
# anonbox.net (CCC) — GET /en/ 解析 HTML,HTTP mbox 取信
|
|
6
|
+
module Anonbox
|
|
7
|
+
CHANNEL = "anonbox"
|
|
8
|
+
PAGE_URL = "https://anonbox.net/en/"
|
|
9
|
+
BASE = "https://anonbox.net"
|
|
10
|
+
|
|
11
|
+
MAIL_LINK_RE = %r{<a href="https://anonbox\.net/([a-z0-9-]+)/([A-Za-z0-9._~-]+)">https://anonbox\.net/[^"]+</a>}i
|
|
12
|
+
DD_RE = /<dd([^>]*)>([\s\S]*?)<\/dd>/mi
|
|
13
|
+
DISPLAY_NONE_RE = /display\s*:\s*none/i
|
|
14
|
+
P_RE = /<p>([\s\S]*?)<\/p>/mi
|
|
15
|
+
SPAN_RE = /<span\b[^>]*>[\s\S]*?<\/span>/mi
|
|
16
|
+
TAG_RE = /<[^>]+>/
|
|
17
|
+
EXPIRES_RE = /Your mail address is valid until:<\/dt>\s*<dd><p>([^<]+)<\/p>/mi
|
|
18
|
+
MBOX_SPLIT_RE = /\r?\n(?=From )/
|
|
19
|
+
|
|
20
|
+
HEADERS = {
|
|
21
|
+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
22
|
+
"Accept" => "text/html,application/xhtml+xml"
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# 去除 HTML 标签并还原常见实体
|
|
28
|
+
def strip_tags(html_str)
|
|
29
|
+
s = html_str.gsub(TAG_RE, "")
|
|
30
|
+
s.gsub(" ", " ")
|
|
31
|
+
.gsub("&", "&")
|
|
32
|
+
.gsub("<", "<")
|
|
33
|
+
.gsub(">", ">")
|
|
34
|
+
.strip
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# 简单哈希,用于生成邮件 ID
|
|
38
|
+
def simple_hash(s)
|
|
39
|
+
h = 0
|
|
40
|
+
s.each_char { |c| h = (h * 31 + c.ord) & 0xFFFFFFFF }
|
|
41
|
+
return "0" if h == 0
|
|
42
|
+
|
|
43
|
+
digits = "0123456789abcdefghijklmnopqrstuvwxyz"
|
|
44
|
+
out = []
|
|
45
|
+
n = h
|
|
46
|
+
while n > 0
|
|
47
|
+
out.unshift(digits[n % 36])
|
|
48
|
+
n /= 36
|
|
49
|
+
end
|
|
50
|
+
out.join
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# 从首页 HTML 中解析邮箱地址、token、过期时间
|
|
54
|
+
def parse_en_page(html_str)
|
|
55
|
+
m = html_str.match(MAIL_LINK_RE)
|
|
56
|
+
raise "anonbox: mailbox link not found" unless m
|
|
57
|
+
|
|
58
|
+
inbox = m[1]
|
|
59
|
+
secret = m[2]
|
|
60
|
+
token = "#{inbox}/#{secret}"
|
|
61
|
+
|
|
62
|
+
# 从 <dd> 中提取邮箱地址
|
|
63
|
+
address_html = nil
|
|
64
|
+
html_str.scan(DD_RE).each do |attrs, inner|
|
|
65
|
+
next if attrs.match?(DISPLAY_NONE_RE)
|
|
66
|
+
|
|
67
|
+
pm = inner.match(P_RE)
|
|
68
|
+
next unless pm
|
|
69
|
+
|
|
70
|
+
p_inner = pm[1].gsub(SPAN_RE, "")
|
|
71
|
+
display = strip_tags(p_inner)
|
|
72
|
+
next unless display.include?("@")
|
|
73
|
+
|
|
74
|
+
at = display.rindex("@")
|
|
75
|
+
local = display[0...at].strip
|
|
76
|
+
domain = display[(at + 1)..].strip.downcase
|
|
77
|
+
next if domain == "googlemail.com"
|
|
78
|
+
|
|
79
|
+
expected_domain = "#{inbox}.anonbox.net".downcase
|
|
80
|
+
next if domain != expected_domain
|
|
81
|
+
next if local.empty?
|
|
82
|
+
|
|
83
|
+
address_html = display
|
|
84
|
+
break
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
raise "anonbox: address paragraph not found" unless address_html
|
|
88
|
+
|
|
89
|
+
at = address_html.index("@")
|
|
90
|
+
raise "anonbox: bad address" unless at
|
|
91
|
+
|
|
92
|
+
local = address_html[0...at].strip
|
|
93
|
+
raise "anonbox: empty local part" if local.empty?
|
|
94
|
+
|
|
95
|
+
email = "#{local}@#{inbox}.anonbox.net"
|
|
96
|
+
em = html_str.match(EXPIRES_RE)
|
|
97
|
+
exp = em ? em[1].strip : nil
|
|
98
|
+
[email, token, exp]
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# 解码 quoted-printable 编码
|
|
102
|
+
def decode_qp_if_needed(body, header_block)
|
|
103
|
+
te = header_block.match(/(?i)^content-transfer-encoding:\s*([^\s]+)/m)
|
|
104
|
+
enc = te ? te[1].downcase.strip : ""
|
|
105
|
+
return body.rstrip unless enc == "quoted-printable"
|
|
106
|
+
|
|
107
|
+
soft = body.gsub(/=\r?\n/, "")
|
|
108
|
+
result = []
|
|
109
|
+
bytes = soft.bytes
|
|
110
|
+
i = 0
|
|
111
|
+
while i < bytes.length
|
|
112
|
+
if bytes[i] == 61 && i + 2 < bytes.length # '='
|
|
113
|
+
hex = [bytes[i + 1], bytes[i + 2]].pack("C*")
|
|
114
|
+
begin
|
|
115
|
+
result << hex.to_i(16)
|
|
116
|
+
i += 3
|
|
117
|
+
next
|
|
118
|
+
rescue ArgumentError
|
|
119
|
+
# 非法十六进制,作为普通字符处理
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
result << bytes[i]
|
|
123
|
+
i += 1
|
|
124
|
+
end
|
|
125
|
+
result.pack("C*").force_encoding("UTF-8").rstrip
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# 解析 mbox 格式单条邮件
|
|
129
|
+
def mbox_block_to_raw(block, recipient)
|
|
130
|
+
normalized = block.gsub("\r\n", "\n")
|
|
131
|
+
lines = normalized.split("\n")
|
|
132
|
+
i = 0
|
|
133
|
+
i = 1 if lines.first&.start_with?("From ")
|
|
134
|
+
|
|
135
|
+
headers = {}
|
|
136
|
+
cur_key = ""
|
|
137
|
+
while i < lines.length
|
|
138
|
+
line = lines[i]
|
|
139
|
+
if line == ""
|
|
140
|
+
i += 1
|
|
141
|
+
break
|
|
142
|
+
end
|
|
143
|
+
if (line[0] == " " || line[0] == "\t") && !cur_key.empty?
|
|
144
|
+
headers[cur_key] = "#{headers[cur_key]} #{line.strip}"
|
|
145
|
+
else
|
|
146
|
+
idx = line.index(":")
|
|
147
|
+
if idx && idx > 0
|
|
148
|
+
cur_key = line[0...idx].strip.downcase
|
|
149
|
+
headers[cur_key] = line[(idx + 1)..].strip
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
i += 1
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
body = lines[i..].join("\n")
|
|
156
|
+
ct = (headers["content-type"] || "").downcase
|
|
157
|
+
text = ""
|
|
158
|
+
html = ""
|
|
159
|
+
|
|
160
|
+
if ct.include?("multipart/")
|
|
161
|
+
bm = headers["content-type"]&.match(/(?i)boundary="?([^";\s]+)"?/)
|
|
162
|
+
if bm
|
|
163
|
+
boundary = Regexp.escape(bm[1])
|
|
164
|
+
parts = body.split(/\r?\n--#{boundary}(?:--)?\r?\n/)
|
|
165
|
+
parts.each do |part|
|
|
166
|
+
part = part.strip
|
|
167
|
+
next if part.empty? || part == "--"
|
|
168
|
+
|
|
169
|
+
sep = part.index("\n\n")
|
|
170
|
+
next unless sep
|
|
171
|
+
|
|
172
|
+
ph = part[0...sep]
|
|
173
|
+
pb = part[(sep + 2)..]
|
|
174
|
+
pct_m = ph.match(/(?i)^content-type:\s*([^\s;]+)/m)
|
|
175
|
+
pct = pct_m ? pct_m[1].downcase : ""
|
|
176
|
+
|
|
177
|
+
if pct == "text/plain"
|
|
178
|
+
text = decode_qp_if_needed(pb, ph)
|
|
179
|
+
elsif pct == "text/html"
|
|
180
|
+
html = decode_qp_if_needed(pb, ph)
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
if text.empty? && html.empty?
|
|
187
|
+
if ct.include?("text/html")
|
|
188
|
+
html = decode_qp_if_needed(body, headers["content-type"] || "")
|
|
189
|
+
else
|
|
190
|
+
text = decode_qp_if_needed(body, headers["content-type"] || "")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
date_str = headers["date"] || ""
|
|
195
|
+
unless date_str.empty?
|
|
196
|
+
begin
|
|
197
|
+
date_str = Time.parse(date_str.strip).iso8601
|
|
198
|
+
rescue ArgumentError, TypeError
|
|
199
|
+
# 保持原值
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
date_str = Time.now.utc.iso8601 if date_str.empty?
|
|
203
|
+
|
|
204
|
+
msg_id = headers["message-id"] || simple_hash(block[0, 512])
|
|
205
|
+
{
|
|
206
|
+
"id" => msg_id,
|
|
207
|
+
"from" => headers["from"] || "",
|
|
208
|
+
"to" => headers["to"] || recipient,
|
|
209
|
+
"subject" => headers["subject"] || "",
|
|
210
|
+
"body_text" => text,
|
|
211
|
+
"body_html" => html,
|
|
212
|
+
"date" => date_str,
|
|
213
|
+
"isRead" => false,
|
|
214
|
+
"attachments" => []
|
|
215
|
+
}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# 创建 anonbox 临时邮箱
|
|
219
|
+
# @return [EmailInfo]
|
|
220
|
+
def generate_email
|
|
221
|
+
r = Http.get(PAGE_URL, headers: HEADERS, timeout: 15)
|
|
222
|
+
r.raise_for_status
|
|
223
|
+
email, token, exp = parse_en_page(r.body)
|
|
224
|
+
EmailInfo.new(channel: CHANNEL, email: email, token: token, created_at: exp)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# 获取邮件列表
|
|
228
|
+
# @param token [String] inbox/secret 路径
|
|
229
|
+
# @param email [String]
|
|
230
|
+
# @return [Array<Email>]
|
|
231
|
+
def get_emails(token, email)
|
|
232
|
+
raise "token is required for anonbox channel" if token.nil? || token.to_s.empty?
|
|
233
|
+
|
|
234
|
+
path = token.end_with?("/") ? token : "#{token}/"
|
|
235
|
+
url = "#{BASE}/#{path}"
|
|
236
|
+
r = Http.get(url, headers: HEADERS.merge("Accept" => "text/plain,*/*"), timeout: 15)
|
|
237
|
+
return [] if r.status_code == 404
|
|
238
|
+
|
|
239
|
+
r.raise_for_status
|
|
240
|
+
raw = r.body.strip
|
|
241
|
+
return [] if raw.empty?
|
|
242
|
+
|
|
243
|
+
blocks = raw.split(MBOX_SPLIT_RE).map(&:strip).reject(&:empty?)
|
|
244
|
+
blocks.map { |b| Normalize.normalize_email(mbox_block_to_raw(b, email), email) }
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
end
|