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.
Files changed (100) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +116 -0
  3. data/lib/tempmail_sdk/channels.rb +289 -0
  4. data/lib/tempmail_sdk/channels_data.rb +289 -0
  5. data/lib/tempmail_sdk/client.rb +193 -0
  6. data/lib/tempmail_sdk/config.rb +104 -0
  7. data/lib/tempmail_sdk/html_utils.rb +42 -0
  8. data/lib/tempmail_sdk/http.rb +225 -0
  9. data/lib/tempmail_sdk/logger.rb +38 -0
  10. data/lib/tempmail_sdk/normalize.rb +193 -0
  11. data/lib/tempmail_sdk/providers/anonbox.rb +248 -0
  12. data/lib/tempmail_sdk/providers/anonymmail.rb +92 -0
  13. data/lib/tempmail_sdk/providers/byom.rb +48 -0
  14. data/lib/tempmail_sdk/providers/catchmail.rb +110 -0
  15. data/lib/tempmail_sdk/providers/cleantempmail.rb +49 -0
  16. data/lib/tempmail_sdk/providers/devmail_uk.rb +58 -0
  17. data/lib/tempmail_sdk/providers/dropmail.rb +133 -0
  18. data/lib/tempmail_sdk/providers/dropmail_click.rb +81 -0
  19. data/lib/tempmail_sdk/providers/dropmail_me.rb +163 -0
  20. data/lib/tempmail_sdk/providers/duckmail.rb +63 -0
  21. data/lib/tempmail_sdk/providers/eyepaste.rb +119 -0
  22. data/lib/tempmail_sdk/providers/fake_email_site.rb +48 -0
  23. data/lib/tempmail_sdk/providers/fake_legal.rb +109 -0
  24. data/lib/tempmail_sdk/providers/fmail.rb +87 -0
  25. data/lib/tempmail_sdk/providers/freecustom.rb +135 -0
  26. data/lib/tempmail_sdk/providers/getnada.rb +146 -0
  27. data/lib/tempmail_sdk/providers/gonebox_email.rb +89 -0
  28. data/lib/tempmail_sdk/providers/guerrillamail.rb +62 -0
  29. data/lib/tempmail_sdk/providers/guerrillamail_mirrors.rb +81 -0
  30. data/lib/tempmail_sdk/providers/harakirimail.rb +84 -0
  31. data/lib/tempmail_sdk/providers/haribu.rb +176 -0
  32. data/lib/tempmail_sdk/providers/inboxes.rb +110 -0
  33. data/lib/tempmail_sdk/providers/inboxkitten.rb +90 -0
  34. data/lib/tempmail_sdk/providers/linshiyou.rb +157 -0
  35. data/lib/tempmail_sdk/providers/lroid.rb +170 -0
  36. data/lib/tempmail_sdk/providers/m2u.rb +126 -0
  37. data/lib/tempmail_sdk/providers/mail10s.rb +59 -0
  38. data/lib/tempmail_sdk/providers/mail123.rb +82 -0
  39. data/lib/tempmail_sdk/providers/mail_cx.rb +133 -0
  40. data/lib/tempmail_sdk/providers/mail_sunls.rb +68 -0
  41. data/lib/tempmail_sdk/providers/mail_tm.rb +99 -0
  42. data/lib/tempmail_sdk/providers/mailcatch.rb +91 -0
  43. data/lib/tempmail_sdk/providers/maildrop.rb +92 -0
  44. data/lib/tempmail_sdk/providers/maildrop_cc.rb +110 -0
  45. data/lib/tempmail_sdk/providers/mailforspam.rb +96 -0
  46. data/lib/tempmail_sdk/providers/mailhole_de.rb +70 -0
  47. data/lib/tempmail_sdk/providers/mailinator.rb +182 -0
  48. data/lib/tempmail_sdk/providers/mailmomy.rb +108 -0
  49. data/lib/tempmail_sdk/providers/mailnesia.rb +141 -0
  50. data/lib/tempmail_sdk/providers/mffac.rb +101 -0
  51. data/lib/tempmail_sdk/providers/moakt.rb +296 -0
  52. data/lib/tempmail_sdk/providers/neighbours.rb +140 -0
  53. data/lib/tempmail_sdk/providers/neighbours_sh.rb +117 -0
  54. data/lib/tempmail_sdk/providers/nimail.rb +100 -0
  55. data/lib/tempmail_sdk/providers/ockito.rb +163 -0
  56. data/lib/tempmail_sdk/providers/one_sec_mail.rb +46 -0
  57. data/lib/tempmail_sdk/providers/restmail_net.rb +60 -0
  58. data/lib/tempmail_sdk/providers/rootsh.rb +167 -0
  59. data/lib/tempmail_sdk/providers/shitty_email.rb +84 -0
  60. data/lib/tempmail_sdk/providers/smailpro.rb +103 -0
  61. data/lib/tempmail_sdk/providers/ta_easy.rb +53 -0
  62. data/lib/tempmail_sdk/providers/temp_mail_io.rb +67 -0
  63. data/lib/tempmail_sdk/providers/temp_mail_now.rb +103 -0
  64. data/lib/tempmail_sdk/providers/temp_mail_org.rb +89 -0
  65. data/lib/tempmail_sdk/providers/tempemail_co.rb +142 -0
  66. data/lib/tempmail_sdk/providers/tempemail_info.rb +121 -0
  67. data/lib/tempmail_sdk/providers/tempemails_net.rb +159 -0
  68. data/lib/tempmail_sdk/providers/tempfastmail.rb +76 -0
  69. data/lib/tempmail_sdk/providers/tempgmailer.rb +126 -0
  70. data/lib/tempmail_sdk/providers/tempgo_email.rb +74 -0
  71. data/lib/tempmail_sdk/providers/tempinbox.rb +70 -0
  72. data/lib/tempmail_sdk/providers/tempmail.rb +66 -0
  73. data/lib/tempmail_sdk/providers/tempmail365.rb +119 -0
  74. data/lib/tempmail_sdk/providers/tempmail_fish.rb +89 -0
  75. data/lib/tempmail_sdk/providers/tempmail_fyi.rb +124 -0
  76. data/lib/tempmail_sdk/providers/tempmail_lol.rb +42 -0
  77. data/lib/tempmail_sdk/providers/tempmail_lol_v2.rb +57 -0
  78. data/lib/tempmail_sdk/providers/tempmail_plus.rb +80 -0
  79. data/lib/tempmail_sdk/providers/tempmailc.rb +80 -0
  80. data/lib/tempmail_sdk/providers/tempmailpro.rb +89 -0
  81. data/lib/tempmail_sdk/providers/tempmailten.rb +150 -0
  82. data/lib/tempmail_sdk/providers/temporam.rb +95 -0
  83. data/lib/tempmail_sdk/providers/tempp_mails.rb +150 -0
  84. data/lib/tempmail_sdk/providers/tempy_email.rb +73 -0
  85. data/lib/tempmail_sdk/providers/ten_minute_mail_net.rb +125 -0
  86. data/lib/tempmail_sdk/providers/tenminute_one.rb +255 -0
  87. data/lib/tempmail_sdk/providers/throwawaymail.rb +80 -0
  88. data/lib/tempmail_sdk/providers/tmail_link.rb +98 -0
  89. data/lib/tempmail_sdk/providers/uncorreotemporal.rb +84 -0
  90. data/lib/tempmail_sdk/providers/webmailtemp.rb +108 -0
  91. data/lib/tempmail_sdk/providers/xkx_me.rb +125 -0
  92. data/lib/tempmail_sdk/providers/zhujump.rb +256 -0
  93. data/lib/tempmail_sdk/registry.rb +1097 -0
  94. data/lib/tempmail_sdk/retry.rb +91 -0
  95. data/lib/tempmail_sdk/telemetry.rb +134 -0
  96. data/lib/tempmail_sdk/types.rb +164 -0
  97. data/lib/tempmail_sdk/version.rb +6 -0
  98. data/lib/tempmail_sdk/webui.rb +360 -0
  99. data/lib/tempmail_sdk.rb +76 -0
  100. 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("&nbsp;", " ")
31
+ .gsub("&amp;", "&")
32
+ .gsub("&lt;", "<")
33
+ .gsub("&gt;", ">")
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