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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/tempmail_sdk/channels.rb +29 -7
- data/lib/tempmail_sdk/channels_data.rb +29 -7
- data/lib/tempmail_sdk/providers/clawdemail.rb +122 -0
- data/lib/tempmail_sdk/providers/crazymailing.rb +114 -0
- data/lib/tempmail_sdk/providers/firetempmail.rb +86 -0
- data/lib/tempmail_sdk/providers/flybymail.rb +93 -0
- data/lib/tempmail_sdk/providers/generator_email.rb +146 -0
- data/lib/tempmail_sdk/providers/huskmail.rb +132 -0
- data/lib/tempmail_sdk/providers/internxt.rb +176 -0
- data/lib/tempmail_sdk/providers/linshi_xyz.rb +79 -0
- data/lib/tempmail_sdk/providers/mailmomy.rb +1 -1
- data/lib/tempmail_sdk/providers/mailticking.rb +139 -0
- data/lib/tempmail_sdk/providers/mtempmail.rb +140 -0
- data/lib/tempmail_sdk/providers/nowtempmail.rb +112 -0
- data/lib/tempmail_sdk/providers/noxen_de5_net.rb +392 -0
- data/lib/tempmail_sdk/providers/nukemail.rb +188 -0
- data/lib/tempmail_sdk/providers/nullmail.rb +95 -0
- data/lib/tempmail_sdk/providers/shadowmail.rb +174 -0
- data/lib/tempmail_sdk/providers/shitpost_email.rb +85 -0
- data/lib/tempmail_sdk/providers/smails.rb +128 -0
- data/lib/tempmail_sdk/providers/temp_mail_gg.rb +362 -0
- data/lib/tempmail_sdk/providers/tempmail100.rb +148 -0
- data/lib/tempmail_sdk/providers/tempmail_ee.rb +373 -0
- data/lib/tempmail_sdk/providers/tempmailportal.rb +131 -0
- data/lib/tempmail_sdk/providers/tempmails_io.rb +89 -0
- data/lib/tempmail_sdk/providers/tempmailto.rb +312 -0
- data/lib/tempmail_sdk/providers/temporarymail_com.rb +184 -0
- data/lib/tempmail_sdk/providers/tenmin_app.rb +100 -0
- data/lib/tempmail_sdk/providers/thirty_min_email.rb +90 -0
- data/lib/tempmail_sdk/providers/tmpkit.rb +129 -0
- data/lib/tempmail_sdk/providers/zerodrop.rb +90 -0
- data/lib/tempmail_sdk/registry.rb +129 -29
- data/lib/tempmail_sdk/types.rb +13 -0
- data/lib/tempmail_sdk/version.rb +1 -1
- data/lib/tempmail_sdk.rb +1 -1
- metadata +31 -7
- data/lib/tempmail_sdk/providers/gonebox_email.rb +0 -89
- data/lib/tempmail_sdk/providers/mohmal.rb +0 -208
- data/lib/tempmail_sdk/providers/temp_mail_now.rb +0 -103
- 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
|