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.
Files changed (73) 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/altmails.rb +99 -0
  6. data/lib/tempmail_sdk/providers/apihz.rb +138 -0
  7. data/lib/tempmail_sdk/providers/awamail.rb +95 -0
  8. data/lib/tempmail_sdk/providers/best_temp_mail.rb +77 -0
  9. data/lib/tempmail_sdk/providers/chatgpt_org_uk.rb +129 -0
  10. data/lib/tempmail_sdk/providers/clawdemail.rb +122 -0
  11. data/lib/tempmail_sdk/providers/crazymailing.rb +114 -0
  12. data/lib/tempmail_sdk/providers/disposablemail.rb +125 -0
  13. data/lib/tempmail_sdk/providers/disposablemail_app.rb +66 -0
  14. data/lib/tempmail_sdk/providers/email10min.rb +152 -0
  15. data/lib/tempmail_sdk/providers/emailnator.rb +132 -0
  16. data/lib/tempmail_sdk/providers/emailtemp_org.rb +131 -0
  17. data/lib/tempmail_sdk/providers/expressinboxhub.rb +118 -0
  18. data/lib/tempmail_sdk/providers/fakemail.rb +143 -0
  19. data/lib/tempmail_sdk/providers/firetempmail.rb +86 -0
  20. data/lib/tempmail_sdk/providers/flybymail.rb +93 -0
  21. data/lib/tempmail_sdk/providers/generator_email.rb +146 -0
  22. data/lib/tempmail_sdk/providers/haribu.rb +29 -3
  23. data/lib/tempmail_sdk/providers/huskmail.rb +132 -0
  24. data/lib/tempmail_sdk/providers/internxt.rb +176 -0
  25. data/lib/tempmail_sdk/providers/linshi_xyz.rb +79 -0
  26. data/lib/tempmail_sdk/providers/linshiyouxiang_net.rb +85 -0
  27. data/lib/tempmail_sdk/providers/mail_sunls.rb +42 -1
  28. data/lib/tempmail_sdk/providers/mail_td.rb +161 -0
  29. data/lib/tempmail_sdk/providers/mailcat_ai.rb +52 -0
  30. data/lib/tempmail_sdk/providers/maildrop.rb +79 -4
  31. data/lib/tempmail_sdk/providers/mailgolem.rb +96 -0
  32. data/lib/tempmail_sdk/providers/mailinator.rb +9 -2
  33. data/lib/tempmail_sdk/providers/mailmomy.rb +1 -1
  34. data/lib/tempmail_sdk/providers/mailtemp_cc.rb +91 -0
  35. data/lib/tempmail_sdk/providers/mailticking.rb +139 -0
  36. data/lib/tempmail_sdk/providers/minuteinbox.rb +174 -0
  37. data/lib/tempmail_sdk/providers/moakt.rb +29 -3
  38. data/lib/tempmail_sdk/providers/mtempmail.rb +140 -0
  39. data/lib/tempmail_sdk/providers/mytempmail_cc.rb +76 -0
  40. data/lib/tempmail_sdk/providers/nowtempmail.rb +112 -0
  41. data/lib/tempmail_sdk/providers/noxen_de5_net.rb +392 -0
  42. data/lib/tempmail_sdk/providers/nukemail.rb +188 -0
  43. data/lib/tempmail_sdk/providers/nullmail.rb +95 -0
  44. data/lib/tempmail_sdk/providers/openinbox.rb +102 -0
  45. data/lib/tempmail_sdk/providers/shadowmail.rb +174 -0
  46. data/lib/tempmail_sdk/providers/shitpost_email.rb +85 -0
  47. data/lib/tempmail_sdk/providers/smail_pw.rb +156 -0
  48. data/lib/tempmail_sdk/providers/smails.rb +128 -0
  49. data/lib/tempmail_sdk/providers/socketio_mail.rb +25 -0
  50. data/lib/tempmail_sdk/providers/temp_mail_gg.rb +362 -0
  51. data/lib/tempmail_sdk/providers/tempgbox.rb +118 -0
  52. data/lib/tempmail_sdk/providers/tempmail100.rb +148 -0
  53. data/lib/tempmail_sdk/providers/tempmail_cn.rb +111 -0
  54. data/lib/tempmail_sdk/providers/tempmail_ee.rb +373 -0
  55. data/lib/tempmail_sdk/providers/tempmailportal.rb +131 -0
  56. data/lib/tempmail_sdk/providers/tempmails_io.rb +89 -0
  57. data/lib/tempmail_sdk/providers/tempmailto.rb +312 -0
  58. data/lib/tempmail_sdk/providers/temporarymail_com.rb +184 -0
  59. data/lib/tempmail_sdk/providers/tenmin_app.rb +100 -0
  60. data/lib/tempmail_sdk/providers/tenminutemail_net.rb +183 -0
  61. data/lib/tempmail_sdk/providers/thirty_min_email.rb +90 -0
  62. data/lib/tempmail_sdk/providers/tmpkit.rb +129 -0
  63. data/lib/tempmail_sdk/providers/twentyfourmail_chacuo.rb +83 -0
  64. data/lib/tempmail_sdk/providers/vip_215.rb +171 -0
  65. data/lib/tempmail_sdk/providers/zerodrop.rb +90 -0
  66. data/lib/tempmail_sdk/registry.rb +285 -23
  67. data/lib/tempmail_sdk/types.rb +13 -0
  68. data/lib/tempmail_sdk/version.rb +1 -1
  69. data/lib/tempmail_sdk.rb +1 -1
  70. metadata +58 -6
  71. data/lib/tempmail_sdk/providers/gonebox_email.rb +0 -89
  72. data/lib/tempmail_sdk/providers/temp_mail_now.rb +0 -103
  73. data/lib/tempmail_sdk/providers/webmailtemp.rb +0 -108
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TempmailSdk
4
+ module Providers
5
+ # TempMail100 渠道实现(tempmail100.com)
6
+ #
7
+ # POST /init 建箱(空 body,响应 code/data.token(JWT),无 Cookie),
8
+ # POST /web/generate 建随机地址(Header Authorization: <token>,响应 data.address),
9
+ # GET /web/emails 读信列表(Header Authorization: <token>,响应 data.list[]/data.total)。
10
+ #
11
+ # 平台限制(实测确认,非 SDK 缺陷):
12
+ # 列表元素 content 恒为空字符串,详情端点对真实 token 返回 HTTP 200 + 空 body,
13
+ # 因此正文永久不可得,本渠道客观为「列表-only」:subject/fromAddress/fromName/
14
+ # timestamp/read 可正确输出,正文恒为空属平台限制。
15
+ module Tempmail100
16
+ CHANNEL = "tempmail100"
17
+ BASE_URL = "https://tempmail100.com"
18
+
19
+ HEADERS = {
20
+ "Accept" => "application/json",
21
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " \
22
+ "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36"
23
+ }.freeze
24
+
25
+ module_function
26
+
27
+ # 设置 tempmail100 请求的通用请求头
28
+ # 前端使用 Authorization: <token> 不带 Bearer。
29
+ # @param token [String] JWT
30
+ # @return [Hash]
31
+ def auth_headers(token)
32
+ hdrs = HEADERS.dup
33
+ hdrs["Authorization"] = token unless token.to_s.empty?
34
+ hdrs
35
+ end
36
+
37
+ # 创建 tempmail100.com 临时邮箱
38
+ # 流程:POST /init 取得 JWT token,再 POST /web/generate 创建地址。
39
+ # @return [EmailInfo]
40
+ def generate_email
41
+ # 第一步:初始化取得 token
42
+ resp = Http.post("#{BASE_URL}/init", headers: auth_headers(""), timeout: 15)
43
+ raise "tempmail100: 初始化失败 http #{resp.status_code}" unless resp.ok?
44
+
45
+ init = resp.json
46
+ raise "tempmail100: 初始化响应非对象" unless init.is_a?(Hash)
47
+
48
+ token = init.dig("data", "token").to_s.strip
49
+ raise "tempmail100: 初始化响应异常" if init["code"] != 0 || token.empty?
50
+
51
+ # 第二步:创建随机地址(Authorization: <token> 不带 Bearer)
52
+ resp2 = Http.post("#{BASE_URL}/web/generate",
53
+ headers: auth_headers(token), timeout: 15)
54
+ raise "tempmail100: 创建地址失败 http #{resp2.status_code}" unless resp2.ok?
55
+
56
+ gen = resp2.json
57
+ raise "tempmail100: 创建地址响应非对象" unless gen.is_a?(Hash)
58
+
59
+ address = gen.dig("data", "address").to_s.strip
60
+ unless gen["code"] == 0 && !address.empty? && address.include?("@")
61
+ raise "tempmail100: 创建地址响应异常"
62
+ end
63
+
64
+ EmailInfo.new(channel: CHANNEL, email: address, token: token)
65
+ end
66
+
67
+ # 获取 tempmail100.com 邮件列表
68
+ # 流程:GET /web/emails(Authorization: <token> 不带 Bearer)返回 data.list/data.total。
69
+ # @param email [String] 邮箱地址
70
+ # @param token [String] 初始化返回的 JWT 认证令牌
71
+ # @return [Array<Email>]
72
+ def get_emails(email, token)
73
+ addr = email.to_s.strip
74
+ resp = Http.get("#{BASE_URL}/web/emails",
75
+ headers: auth_headers(token), timeout: 15)
76
+ raise "tempmail100: 获取邮件列表失败 http #{resp.status_code}" unless resp.ok?
77
+
78
+ data = resp.json
79
+ raise "tempmail100: 获取邮件列表响应非对象" unless data.is_a?(Hash)
80
+
81
+ unless data["code"] == 0
82
+ raise "tempmail100: 获取邮件列表响应异常: #{data["message"]}"
83
+ end
84
+ inner = data["data"]
85
+ list = inner.is_a?(Hash) ? inner["list"] : nil
86
+ return [] unless list.is_a?(Array)
87
+
88
+ list.filter_map do |m|
89
+ next unless m.is_a?(Hash)
90
+
91
+ Normalize.normalize_email(normalize_item(m, addr), addr)
92
+ end
93
+ end
94
+
95
+ # 将 /web/emails 列表元素归一为统一邮件结构。
96
+ # fromName+fromAddress 组合为 "Name <address>" 填入 from;
97
+ # timestamp 为毫秒值(>1e12 时按 UnixMilli 解析);read 为布尔已读标记。
98
+ # @param item [Hash] 列表元素
99
+ # @param email [String] 收件人地址
100
+ # @return [Hash]
101
+ def normalize_item(item, email)
102
+ from_name = str_of(item["fromName"]).strip
103
+ from_address = str_of(item["fromAddress"]).strip
104
+ unless from_name.empty? || from_name.casecmp(from_address).zero? || !from_address.include?("@")
105
+ from_address = "#{from_name} <#{from_address}>"
106
+ end
107
+
108
+ {
109
+ "id" => str_of(item["uuid"]),
110
+ "from" => from_address,
111
+ "to" => str_of(item["toAddress"]).strip,
112
+ "subject" => str_of(item["subject"]),
113
+ "content" => str_of(item["content"]),
114
+ "timestamp" => item["timestamp"],
115
+ "isRead" => read_of(item["read"])
116
+ }
117
+ end
118
+
119
+ # 将接口字段值安全转换为字符串,nil 或非标量返回空串
120
+ # @param v [Object]
121
+ # @return [String]
122
+ def str_of(v)
123
+ case v
124
+ when String then v
125
+ when Numeric then v.to_i.to_s
126
+ when true then "true"
127
+ when false then "false"
128
+ else ""
129
+ end
130
+ end
131
+
132
+ # 将 read 字段归一为布尔已读标记,兼容 bool / 数字(0|1) / string("true"|"1")
133
+ # @param v [Object]
134
+ # @return [Boolean]
135
+ def read_of(v)
136
+ case v
137
+ when true then true
138
+ when false then false
139
+ when Numeric then v != 0
140
+ when String
141
+ s = v.strip
142
+ s.casecmp("true").zero? || s == "1"
143
+ else false
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+
6
+ module TempmailSdk
7
+ module Providers
8
+ # tempmail.cn 渠道实现(HTTP API 模式)
9
+ #
10
+ # 流程:WebSocket 创建邮箱(request mailbox 事件),获取 shortID
11
+ # GET /api/mails/{email} 获取邮件列表(HTTP API)
12
+ # Ruby 端使用 HTTP API 拉取邮件,不实现 WebSocket 推送
13
+ # token 不需要(无状态 HTTP API)
14
+ module TempmailCn
15
+ CHANNEL = "tempmail-cn"
16
+ DEFAULT_HOST = "tempmail.cn"
17
+
18
+ module_function
19
+
20
+ # 规范化 host(去除协议前缀、路径、@ 符号等)
21
+ # @param domain [String, nil]
22
+ # @return [String]
23
+ def normalize_host(domain)
24
+ return DEFAULT_HOST if domain.nil? || domain.to_s.strip.empty?
25
+
26
+ host = domain.to_s.strip
27
+ if host.start_with?("http://", "https://")
28
+ begin
29
+ uri = URI.parse(host)
30
+ host = uri.host.to_s unless uri.host.to_s.empty?
31
+ rescue URI::InvalidURIError
32
+ nil
33
+ end
34
+ elsif host.include?("/")
35
+ host = host.split("/").first.to_s
36
+ end
37
+ host = host.split("@").last.to_s if host.include?("@")
38
+ host = host.strip.gsub(/\A\.+|\.+\z/, "")
39
+ host.empty? ? DEFAULT_HOST : host
40
+ end
41
+
42
+ # 生成随机本地部分(小写字母 + 数字)
43
+ # @param length [Integer]
44
+ # @return [String]
45
+ def random_local(length = 10)
46
+ chars = ("a".."z").to_a + ("0".."9").to_a
47
+ Array.new(length) { chars.sample }.join
48
+ end
49
+
50
+ # 创建 tempmail.cn 临时邮箱
51
+ # @param domain [String, nil]
52
+ # @return [EmailInfo]
53
+ def generate_email(domain = nil)
54
+ host = normalize_host(domain)
55
+ # 直接生成随机邮箱(HTTP 模式,不走 WebSocket)
56
+ email = "#{random_local}@#{host}"
57
+ EmailInfo.new(channel: CHANNEL, email: email)
58
+ end
59
+
60
+ # 获取 tempmail.cn 邮件列表
61
+ # @param email [String]
62
+ # @return [Array<Email>]
63
+ def get_emails(email)
64
+ email = email.to_s.strip
65
+ raise "tempmail-cn: 邮箱地址为空" if email.empty?
66
+
67
+ at_idx = email.index("@")
68
+ host = at_idx ? email[(at_idx + 1)..] : DEFAULT_HOST
69
+ host = normalize_host(host)
70
+
71
+ api_url = "https://#{host}/api/mails/#{URI.encode_uri_component(email)}"
72
+ hdrs = {
73
+ "User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
74
+ "(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0",
75
+ "Accept" => "application/json",
76
+ "Accept-Language" => "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
77
+ "Cache-Control" => "no-cache",
78
+ "DNT" => "1",
79
+ "Pragma" => "no-cache",
80
+ "Referer" => "https://#{host}/"
81
+ }
82
+
83
+ resp = Http.get(api_url, headers: hdrs, timeout: 15)
84
+ resp.raise_for_status
85
+
86
+ data = resp.json
87
+ mails = data.is_a?(Hash) ? Array(data["mails"]) : []
88
+
89
+ mails.filter_map do |raw|
90
+ next unless raw.is_a?(Hash)
91
+
92
+ id = raw["id"].to_s
93
+ id = raw["_id"].to_s if id.empty?
94
+ flat = {
95
+ "id" => id,
96
+ "from" => raw["from"].to_s,
97
+ "to" => email,
98
+ "subject" => raw["subject"].to_s,
99
+ "text" => raw["text"].to_s,
100
+ "html" => raw["html"].to_s,
101
+ "date" => raw["date"].to_s,
102
+ "isRead" => false,
103
+ "attachments" => raw["attachments"]
104
+ }
105
+ flat["text"] = raw["body"].to_s if flat["text"].empty?
106
+ Normalize.normalize_email(flat, email)
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,373 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "cgi"
5
+ require "time"
6
+ require "uri"
7
+
8
+ module TempmailSdk
9
+ module Providers
10
+ # Tempmail.ee 渠道实现(tempmail.ee)
11
+ #
12
+ # 平台读信端 /api/mails 的 403 Access denied 是「会话 Cookie 绑定校验」:
13
+ # temp_email 与 temp_mail_session 共同构成读信凭据,缺一即 403。
14
+ # 只要在同一事务内完成 change → 提取 Set-Cookie → 读信,即可真实读到邮件。
15
+ #
16
+ # 已验证通行配方:
17
+ # - POST /api/mailbox/change 若不带 sec-ch-ua 头会被平台拒绝
18
+ # (403 Browser request required),带齐即 200,并下发会话 Cookie;
19
+ # - POST /api/mails 带完整 sec-ch-ua 三件套 + 会话 Cookie 即 200。
20
+ # 本端无全局 Cookie 罐,会话凭据由本渠道以显式 Cookie 请求头逐请求携带。
21
+ module TempmailEe
22
+ CHANNEL = "tempmail-ee"
23
+ BASE_URL = "https://tempmail.ee"
24
+
25
+ # 与 sec-ch-ua 品牌版本一致的固定 UA(Chrome 154 / Linux)
26
+ USER_AGENT = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " \
27
+ "(KHTML, like Gecko) Chrome/154.0.0.0 Safari/537.36"
28
+
29
+ # Token 前缀,用于识别本渠道会话凭据串
30
+ TOKEN_PREFIX = "tempmail-ee|"
31
+
32
+ SEC_CH_HEADERS = {
33
+ "Sec-Ch-Ua" => '"Chromium";v="154", "Google Chrome";v="154", "Not.A/Brand";v="99"',
34
+ "Sec-Ch-Ua-Mobile" => "?0",
35
+ "Sec-Ch-Ua-Platform" => '"Linux"'
36
+ }.freeze
37
+
38
+ # 建箱提交的浏览器指纹(与官方前端一致)
39
+ BROWSER_INTEGRITY = {
40
+ "webdriver" => false, "languagesMissing" => false, "languageMissing" => false,
41
+ "pluginsMissing" => false, "pluginsUndefined" => false, "outerSizeMissing" => false,
42
+ "innerSizeMissing" => false, "screenMissing" => false, "screenDepthMissing" => false,
43
+ "timezoneMissing" => false, "timezoneOffsetMissing" => false,
44
+ "userAgentDataPresent" => true, "userAgentMissing" => false,
45
+ "platformClass" => "Linux", "mobile" => false, "collectionFailed" => false
46
+ }.freeze
47
+
48
+ module_function
49
+
50
+ # 组装浏览器特征安全头(同站 fetch 全套)
51
+ # @param with_sec_ch [Boolean] 是否携带 sec-ch-ua 三件套(change 必须)
52
+ # @return [Hash]
53
+ def browser_headers(with_sec_ch)
54
+ hdrs = {
55
+ "Accept" => "application/json",
56
+ "Content-Type" => "application/json",
57
+ "X-Requested-With" => "XMLHttpRequest",
58
+ "Origin" => BASE_URL,
59
+ "Referer" => "#{BASE_URL}/",
60
+ "Sec-Fetch-Site" => "same-origin",
61
+ "Sec-Fetch-Mode" => "cors",
62
+ "Sec-Fetch-Dest" => "empty",
63
+ "Accept-Language" => "en-US,en;q=0.9",
64
+ "User-Agent" => USER_AGENT
65
+ }
66
+ hdrs.merge!(SEC_CH_HEADERS) if with_sec_ch
67
+ hdrs
68
+ end
69
+
70
+ # 创建 tempmail.ee 临时邮箱
71
+ # 事务会话:GET / 面熟首访 → POST /api/mailbox/change(sec-ch-ua 全套头)
72
+ # → 提取 Set-Cookie 中的 temp_mail_session,随 token 透传给读信。
73
+ # @return [EmailInfo]
74
+ def generate_email
75
+ # 步骤 1:GET / 建立会话面熟(响应 Cookie 不参与后续,凭据以 change 为准)
76
+ begin
77
+ Http.get(BASE_URL,
78
+ headers: {
79
+ "User-Agent" => USER_AGENT,
80
+ "Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9," \
81
+ "image/avif,image/webp,*/*;q=0.8"
82
+ },
83
+ timeout: 15)
84
+ rescue StandardError
85
+ nil
86
+ end
87
+
88
+ # 步骤 2:POST /api/mailbox/change 提交指纹换新邮箱(必须带 sec-ch-ua)
89
+ body = { "turnstileToken" => nil, "browserIntegrity" => BROWSER_INTEGRITY }
90
+ ch = Http.post("#{BASE_URL}/api/mailbox/change",
91
+ headers: browser_headers(true), json: body, timeout: 15)
92
+ data = ch.json
93
+ email = data.is_a?(Hash) && data["success"] ? data["newEmail"].to_s.strip : ""
94
+ raise "tempmail-ee: 建箱响应缺少邮箱(status #{ch.status_code})" if email.empty?
95
+
96
+ # 步骤 3:从 Set-Cookie 接管会话凭据,供读信校验使用
97
+ session = ch.cookie_value("temp_mail_session").to_s.strip
98
+ raise "tempmail-ee: 建箱成功但未下发会话 Cookie,无法读信" if session.empty?
99
+
100
+ expires_at = data["expiresAt"].to_s
101
+ if expires_at.empty?
102
+ expires_at = ((Time.now.to_f + 60 * 60) * 1000).to_i
103
+ end
104
+ EmailInfo.new(channel: CHANNEL, email: email,
105
+ token: "#{TOKEN_PREFIX}temp_email=#{email}; temp_mail_session=#{session}",
106
+ expires_at: expires_at)
107
+ end
108
+
109
+ # 解析读信凭据(防御 token 与 email 不一致,以请求邮箱为准重拼 Cookie)
110
+ # @param token [String] Generate 时下发的渠道凭据串
111
+ # @param email [String] 请求读取的邮箱
112
+ # @return [String] 显式 Cookie 请求头(temp_email + temp_mail_session)
113
+ def resolve_cookie(token, email)
114
+ raise "tempmail-ee: 会话凭据缺失或已失效,请重新 Generate 获取新邮箱" unless token.to_s.start_with?(TOKEN_PREFIX)
115
+
116
+ cred = token.to_s[TOKEN_PREFIX.length..]
117
+ session = ""
118
+ cred.split(";").each do |part|
119
+ kv = part.strip
120
+ session = kv[("temp_mail_session=".length)..] if kv.start_with?("temp_mail_session=")
121
+ end
122
+ raise "tempmail-ee: 会话凭据缺失或已失效,请重新 Generate 获取新邮箱" if session.empty?
123
+
124
+ "temp_email=#{email}; temp_mail_session=#{session}"
125
+ end
126
+
127
+ # 读取 tempmail.ee 收件箱
128
+ # POST /api/mails(显式会话 Cookie + sec-ch-ua);
129
+ # 列表只含元数据(id/fromAddress/toAddress/subject/createdAt/isRead),
130
+ # 正文逐封 GET /api/mails/{id},content 为 MIME multipart 原文(HTML 实体转义版)。
131
+ # @param email [String] 邮箱地址
132
+ # @param token [String] 渠道会话凭据串
133
+ # @return [Array<Email>]
134
+ def get_emails(email, token)
135
+ addr = email.to_s.strip
136
+ raise "tempmail-ee: 邮箱为空" if addr.empty?
137
+ raise "tempmail-ee: token 为空" if token.to_s.strip.empty?
138
+
139
+ cookie = resolve_cookie(token, addr)
140
+ hdrs = browser_headers(true).merge("Cookie" => cookie)
141
+
142
+ resp = Http.post("#{BASE_URL}/api/mails",
143
+ headers: hdrs, json: { "email" => addr }, timeout: 15)
144
+ if resp.status_code == 403
145
+ raise "tempmail-ee inbox: http 403(会话 Cookie 校验失败,邮箱可能已过期,请重新 Generate)"
146
+ end
147
+ resp.raise_for_status
148
+ data = resp.json
149
+ mails = data.is_a?(Hash) ? data["mails"] : nil
150
+ return [] unless mails.is_a?(Array)
151
+
152
+ mails.filter_map do |row|
153
+ next unless row.is_a?(Hash)
154
+
155
+ mail = row_skeleton(row, addr)
156
+ next if mail.nil?
157
+
158
+ fetch_detail(mail, cookie)
159
+ mail
160
+ end
161
+ end
162
+
163
+ # 将列表行(仅元数据)转换为 Email 骨架;id 缺失视为无效行
164
+ # @param row [Hash] 列表元素(id/fromAddress/toAddress/subject/createdAt/isRead)
165
+ # @param email [String] 当前邮箱
166
+ # @return [Email, nil]
167
+ def row_skeleton(row, email)
168
+ id = row["id"].to_s.strip
169
+ return nil if id.empty?
170
+
171
+ to_addr = row["toAddress"].to_s.strip
172
+ to_addr = email if to_addr.empty?
173
+ created = row["createdAt"].to_s.strip
174
+ created = Time.now.utc.iso8601 if created.empty?
175
+ Email.new(
176
+ id: id,
177
+ from_addr: row["fromAddress"].to_s.strip,
178
+ to: to_addr,
179
+ subject: row["subject"].to_s.strip,
180
+ date: created,
181
+ is_read: row["isRead"] == true || row["isRead"] == 1 || row["isRead"].to_s == "1"
182
+ )
183
+ end
184
+
185
+ # 拉取单封详情并填充正文;失败不阻塞列表其余邮件
186
+ # GET /api/mails/{id}(带显式会话 Cookie)
187
+ # @param mail [Email] 列表骨架
188
+ # @param cookie [String] 会话 Cookie 头
189
+ def fetch_detail(mail, cookie)
190
+ hdrs = browser_headers(false).merge("Cookie" => cookie)
191
+ resp = Http.get("#{BASE_URL}/api/mails/#{URI.encode_www_form_component(mail.id)}",
192
+ headers: hdrs, timeout: 15)
193
+ return unless resp.ok?
194
+
195
+ detail = begin
196
+ resp.json
197
+ rescue StandardError
198
+ return
199
+ end
200
+ return unless detail.is_a?(Hash)
201
+
202
+ content = detail["content"].to_s
203
+ if content.empty?
204
+ # content 缺失时回退兜底候选键,避免平台字段演进后正文丢失
205
+ content = detail["text"].to_s
206
+ content = detail["body"].to_s if content.empty?
207
+ content = detail["html"].to_s if content.empty?
208
+ return if content.empty?
209
+ end
210
+
211
+ text, html = parse_content(content)
212
+ mail.text = text if mail.text.empty?
213
+ mail.html = html if mail.html.empty?
214
+ mail.from_addr = detail["fromAddress"].to_s if mail.from_addr.empty?
215
+ mail.subject = detail["subject"].to_s if mail.subject.empty?
216
+ end
217
+
218
+ # 解析详情 content(MIME multipart 原文,HTML 实体已转义)
219
+ # 1) 反转义;2) 按首个 boundary 行切块解码;3) text/data-html 互为兜底合成。
220
+ # @param raw [String] 原始 content 字符串
221
+ # @return [Array(String, String)] [纯文本正文, HTML 正文]
222
+ def parse_content(raw)
223
+ payload = CGI.unescapeHTML(raw.to_s)
224
+ payload = payload.gsub("&#61;", "=").gsub("&#43;", "+").gsub("&#47;", "/")
225
+ payload = payload.gsub("\r\n", "\n")
226
+
227
+ lines = payload.split("\n", -1)
228
+ boundary = boundary_of(lines)
229
+ text = ""
230
+ html = ""
231
+ if boundary.empty?
232
+ text = singleton_part(payload)
233
+ else
234
+ text, html = parse_parts(lines, boundary)
235
+ end
236
+ if text.empty? && html.empty?
237
+ text = singleton_part(payload)
238
+ end
239
+ if text.empty? && !html.empty?
240
+ text = HtmlUtils.html_to_text(html)
241
+ end
242
+ if html.empty? && !text.empty?
243
+ html = "<html><body><pre>#{HtmlUtils.escape(text)}</pre></body></html>"
244
+ end
245
+ [text, html]
246
+ end
247
+
248
+ # 扫描首块寻找 multipart 边界行("--" 前缀)
249
+ # @param lines [Array<String>]
250
+ # @return [String] 边界串(无则空串)
251
+ def boundary_of(lines)
252
+ lines.first(120).each do |line|
253
+ return line[2..].to_s.strip if line.start_with?("--") && line.length > 2
254
+ end
255
+ ""
256
+ end
257
+
258
+ # 按 boundary 拆分 multipart 并解码归并 text/html 两个 part
259
+ # @param lines [Array<String>]
260
+ # @param boundary [String] 边界串
261
+ # @return [Array(String, String)] [纯文本正文, HTML 正文]
262
+ def parse_parts(lines, boundary)
263
+ text = ""
264
+ html = ""
265
+ i = 0
266
+ while i < lines.length
267
+ line = lines[i]
268
+ unless line.start_with?("--#{boundary}")
269
+ i += 1
270
+ next
271
+ end
272
+ break if line.start_with?("--#{boundary}--")
273
+
274
+ headers = {}
275
+ j = i + 1
276
+ # part 头部:直到首个空行
277
+ while j < lines.length && lines[j] != "" && !lines[j].start_with?("--#{boundary}")
278
+ ln = lines[j]
279
+ if (k = ln.index(":")) && k.positive?
280
+ headers[ln[0...k].strip.downcase] = ln[(k + 1)..].to_s.strip
281
+ end
282
+ j += 1
283
+ end
284
+ j += 1 if j < lines.length && lines[j] == ""
285
+ # part 正文:到下一个边界行为止
286
+ body = []
287
+ while j < lines.length && !lines[j].start_with?("--#{boundary}")
288
+ body << lines[j]
289
+ j += 1
290
+ end
291
+ text, html = merge_part(body, headers, text, html)
292
+ i = j
293
+ end
294
+ [text, html]
295
+ end
296
+
297
+ # 单个 part 的解码归并:按 content-type 归类,缺省回退 text 槽
298
+ # @param body [Array<String>] part 正文行
299
+ # @param headers [Hash] part 头部
300
+ # @param text [String] 已归并纯文本
301
+ # @param html [String] 已归并 HTML
302
+ # @return [Array(String, String)]
303
+ def merge_part(body, headers, text, html)
304
+ ct = headers["content-type"].to_s.downcase.split(";").first
305
+ cte = headers["content-transfer-encoding"].to_s.downcase
306
+ content = decode_part(body.join("\n"), cte)
307
+ if ct.include?("text/plain")
308
+ text = content if text.empty?
309
+ elsif ct.include?("text/html")
310
+ html = content if html.empty?
311
+ elsif text.empty?
312
+ text = content
313
+ end
314
+ [text, html]
315
+ end
316
+
317
+ # 单 part(无边界或拆不出内容)降级解析:
318
+ # 头部区隔(首个空行之后)取 body,并识别 Content-Transfer-Encoding 解码
319
+ # @param payload [String]
320
+ # @return [String]
321
+ def singleton_part(payload)
322
+ body = payload.strip
323
+ return body unless body.include?("\n")
324
+
325
+ lines = payload.split("\n", -1)
326
+ # 头部以 RFC822 形式出现时,正文从首个空行后开始
327
+ lines.each_with_index do |ln, idx|
328
+ if ln.strip.empty?
329
+ body = lines[(idx + 1)..].join("\n")
330
+ break
331
+ end
332
+ break if idx > 40 || (idx >= 3 && !ln.include?(":"))
333
+ end
334
+ lower = payload.downcase
335
+ cte = "base64" if lower.include?("base64")
336
+ cte = "quoted-printable" if lower.include?("quoted-printable")
337
+ decode_part(body, cte.to_s)
338
+ end
339
+
340
+ # 按 Content-Transfer-Encoding 解码 part 内容
341
+ # @param data [String]
342
+ # @param cte [String]
343
+ # @return [String]
344
+ def decode_part(data, cte)
345
+ data = data.to_s.strip
346
+ case cte
347
+ when "base64"
348
+ joined = data.gsub(/[\r\n\t ]/, "")
349
+ begin
350
+ Base64.strict_decode64(joined).strip.force_encoding("UTF-8")
351
+ rescue ArgumentError
352
+ data
353
+ end
354
+ when "quoted-printable"
355
+ decode_quoted_printable(data)
356
+ else
357
+ data
358
+ end
359
+ end
360
+
361
+ # 最小 quoted-printable 解码(=XX 十六进制转字节,行尾 = 软换行)
362
+ # @param data [String]
363
+ # @return [String]
364
+ def decode_quoted_printable(data)
365
+ out = data.gsub(/=\r?\n/, "")
366
+ out = out.gsub(/=([0-9A-Fa-f]{2})/) { [$1].pack("H2") }
367
+ out.force_encoding("UTF-8")
368
+ rescue StandardError
369
+ data
370
+ end
371
+ end
372
+ end
373
+ end