tempmail_sdk 1.3.4 → 1.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/lib/tempmail_sdk/channels.rb +29 -7
  4. data/lib/tempmail_sdk/channels_data.rb +29 -7
  5. data/lib/tempmail_sdk/providers/clawdemail.rb +122 -0
  6. data/lib/tempmail_sdk/providers/crazymailing.rb +114 -0
  7. data/lib/tempmail_sdk/providers/firetempmail.rb +86 -0
  8. data/lib/tempmail_sdk/providers/flybymail.rb +93 -0
  9. data/lib/tempmail_sdk/providers/generator_email.rb +146 -0
  10. data/lib/tempmail_sdk/providers/huskmail.rb +132 -0
  11. data/lib/tempmail_sdk/providers/internxt.rb +176 -0
  12. data/lib/tempmail_sdk/providers/linshi_xyz.rb +79 -0
  13. data/lib/tempmail_sdk/providers/mailmomy.rb +1 -1
  14. data/lib/tempmail_sdk/providers/mailticking.rb +139 -0
  15. data/lib/tempmail_sdk/providers/mtempmail.rb +140 -0
  16. data/lib/tempmail_sdk/providers/nowtempmail.rb +112 -0
  17. data/lib/tempmail_sdk/providers/noxen_de5_net.rb +392 -0
  18. data/lib/tempmail_sdk/providers/nukemail.rb +188 -0
  19. data/lib/tempmail_sdk/providers/nullmail.rb +95 -0
  20. data/lib/tempmail_sdk/providers/shadowmail.rb +174 -0
  21. data/lib/tempmail_sdk/providers/shitpost_email.rb +85 -0
  22. data/lib/tempmail_sdk/providers/smails.rb +128 -0
  23. data/lib/tempmail_sdk/providers/temp_mail_gg.rb +362 -0
  24. data/lib/tempmail_sdk/providers/tempmail100.rb +148 -0
  25. data/lib/tempmail_sdk/providers/tempmail_ee.rb +373 -0
  26. data/lib/tempmail_sdk/providers/tempmailportal.rb +131 -0
  27. data/lib/tempmail_sdk/providers/tempmails_io.rb +89 -0
  28. data/lib/tempmail_sdk/providers/tempmailto.rb +312 -0
  29. data/lib/tempmail_sdk/providers/temporarymail_com.rb +184 -0
  30. data/lib/tempmail_sdk/providers/tenmin_app.rb +100 -0
  31. data/lib/tempmail_sdk/providers/thirty_min_email.rb +90 -0
  32. data/lib/tempmail_sdk/providers/tmpkit.rb +129 -0
  33. data/lib/tempmail_sdk/providers/zerodrop.rb +90 -0
  34. data/lib/tempmail_sdk/registry.rb +129 -29
  35. data/lib/tempmail_sdk/types.rb +13 -0
  36. data/lib/tempmail_sdk/version.rb +1 -1
  37. data/lib/tempmail_sdk.rb +1 -1
  38. metadata +31 -7
  39. data/lib/tempmail_sdk/providers/gonebox_email.rb +0 -89
  40. data/lib/tempmail_sdk/providers/mohmal.rb +0 -208
  41. data/lib/tempmail_sdk/providers/temp_mail_now.rb +0 -103
  42. data/lib/tempmail_sdk/providers/webmailtemp.rb +0 -108
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module TempmailSdk
6
+ module Providers
7
+ # Mtempmail 渠道实现(mtempmail.com,公共 key 认证)
8
+ #
9
+ # 建箱: POST /api/emails/{apiKey}(空 JSON body)→
10
+ # {"status":true,"data":{"email":"...@domain","domain":"..","ip":"..",
11
+ # "fingerprint":"..","expire_at":"..","created_at":"..","id":..,"email_token":"..."}}
12
+ # 读信: GET /api/messages/{apiKey}/{email} →
13
+ # {"status":true,"mailbox":"..","email_token":"..","messages":[...]}
14
+ # 消息为 mailgun 入站 webhook 风格:to/from 为对象数组,
15
+ # body 为 [{content_type:"text/html",value:".."},...] 分段。
16
+ # 邮箱 24 小时有效(过期时间标注不一致,以服务端为准)。
17
+ module Mtempmail
18
+ CHANNEL = "mtempmail"
19
+ BASE_URL = "https://mtempmail.com"
20
+
21
+ # 公共固定 API key(mtempmail.com 官方提供)
22
+ PUBLIC_KEY = "pub_nRn1hUwpdmZvxQNVWDfoXgKyF7dIm9nRIIIt1qDw"
23
+
24
+ HEADERS = {
25
+ "Accept" => "application/json",
26
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
27
+ "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0"
28
+ }.freeze
29
+
30
+ # 剥离后台拼接的 "• " 前缀(或实收分隔符)
31
+ BULLET_RE = /^[•·]+\s*/.freeze
32
+
33
+ module_function
34
+
35
+ # 清洗主题前导分隔符(后台可能以 "• " 开头拼接微件)
36
+ # @param subject [String]
37
+ # @return [String]
38
+ def clean_subject(subject)
39
+ subject.to_s.strip.sub(BULLET_RE, "")
40
+ end
41
+
42
+ # 拼接正文纯文本(body[].value 按序)
43
+ # @param parts [Array]
44
+ # @return [String]
45
+ def body_text(parts)
46
+ parts.filter_map { |p| p["value"].to_s unless p.nil? || !p.is_a?(Hash) || p["value"].to_s.empty? }
47
+ .join("\n")
48
+ end
49
+
50
+ # 提取首个 text/html 段
51
+ # @param parts [Array]
52
+ # @return [String]
53
+ def body_html(parts)
54
+ parts.each do |p|
55
+ next unless p.is_a?(Hash) && p["content_type"].to_s == "text/html"
56
+
57
+ val = p["value"].to_s
58
+ return val unless val.empty?
59
+ end
60
+ ""
61
+ end
62
+
63
+ # 创建 mtempmail 临时邮箱
64
+ # POST /api/emails/{apiKey}(空 JSON body)
65
+ # @return [EmailInfo]
66
+ def generate_email
67
+ resp = Http.post("#{BASE_URL}/api/emails/#{PUBLIC_KEY}",
68
+ headers: HEADERS.merge("Content-Type" => "application/json"),
69
+ json: {}, timeout: 15)
70
+ resp.raise_for_status
71
+ data = resp.json
72
+ payload = data.is_a?(Hash) ? data["data"] : nil
73
+ email = payload.is_a?(Hash) ? payload["email"].to_s.strip : ""
74
+ if (data.is_a?(Hash) && !data["status"]) || email.empty?
75
+ raise "mtempmail: 建箱响应缺少邮箱"
76
+ end
77
+
78
+ EmailInfo.new(channel: CHANNEL, email: email,
79
+ token: payload["email_token"].to_s,
80
+ expires_at: payload["expire_at"],
81
+ created_at: payload["created_at"])
82
+ end
83
+
84
+ # 读取 mtempmail 收件箱
85
+ # GET /api/messages/{apiKey}/{email};token 元数据仅为校验,不参与请求
86
+ # @param email [String] 邮箱地址
87
+ # @param token [String] email_token(仅校验非空)
88
+ # @return [Array<Email>]
89
+ def get_emails(email, token)
90
+ addr = email.to_s.strip
91
+ raise "mtempmail: 邮箱为空" if addr.empty?
92
+ raise "mtempmail: token 为空" if token.to_s.strip.empty?
93
+
94
+ uri = "#{BASE_URL}/api/messages/#{PUBLIC_KEY}/#{URI.encode_www_form_component(addr)}"
95
+ resp = Http.get(uri, headers: HEADERS, timeout: 15)
96
+ resp.raise_for_status
97
+ data = resp.json
98
+ messages = data.is_a?(Hash) ? data["messages"] : nil
99
+ return [] unless messages.is_a?(Array)
100
+
101
+ messages.filter_map do |m|
102
+ next unless m.is_a?(Hash)
103
+
104
+ to_str = flatten_contact(m["to"])
105
+ row = {
106
+ "id" => m["id"],
107
+ "from" => flatten_contact(m["from"]),
108
+ "to" => to_str.empty? ? addr : to_str,
109
+ "subject" => clean_subject(m["subject"]),
110
+ "date" => m["created_at"] || m["date"]
111
+ }
112
+ body_parts = m["body"]
113
+ if body_parts.is_a?(Array)
114
+ parts = body_parts.select { |p| p.is_a?(Hash) }
115
+ text = body_text(parts)
116
+ row["text"] = text unless text.empty?
117
+ html_part = body_html(parts)
118
+ row["html"] = html_part unless html_part.empty?
119
+ end
120
+ Normalize.normalize_email(row, addr)
121
+ end
122
+ end
123
+
124
+ # 将 mailgun 风格 [{"full":"Sender <a@b.com>"},{...}] 展平为字符串
125
+ # 优先取 full 字段,否则回退 address;空列表返回空串
126
+ # @param contacts [Object]
127
+ # @return [String]
128
+ def flatten_contact(contacts)
129
+ return contacts.to_s unless contacts.is_a?(Array)
130
+ return "" if contacts.empty?
131
+
132
+ first = contacts.first
133
+ return "" unless first.is_a?(Hash)
134
+
135
+ full = first["full"].to_s.strip
136
+ full.empty? ? first["address"].to_s.strip : full
137
+ end
138
+ end
139
+ end
140
+ end
@@ -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