tempmail_sdk 1.3.3 → 1.3.4
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/lib/tempmail_sdk/providers/altmails.rb +99 -0
- data/lib/tempmail_sdk/providers/apihz.rb +138 -0
- data/lib/tempmail_sdk/providers/awamail.rb +95 -0
- data/lib/tempmail_sdk/providers/best_temp_mail.rb +77 -0
- data/lib/tempmail_sdk/providers/chatgpt_org_uk.rb +129 -0
- data/lib/tempmail_sdk/providers/disposablemail.rb +125 -0
- data/lib/tempmail_sdk/providers/disposablemail_app.rb +66 -0
- data/lib/tempmail_sdk/providers/email10min.rb +152 -0
- data/lib/tempmail_sdk/providers/emailnator.rb +132 -0
- data/lib/tempmail_sdk/providers/emailtemp_org.rb +131 -0
- data/lib/tempmail_sdk/providers/expressinboxhub.rb +118 -0
- data/lib/tempmail_sdk/providers/fakemail.rb +143 -0
- data/lib/tempmail_sdk/providers/haribu.rb +29 -3
- data/lib/tempmail_sdk/providers/linshiyouxiang_net.rb +85 -0
- data/lib/tempmail_sdk/providers/mail_sunls.rb +42 -1
- data/lib/tempmail_sdk/providers/mail_td.rb +161 -0
- data/lib/tempmail_sdk/providers/mailcat_ai.rb +52 -0
- data/lib/tempmail_sdk/providers/maildrop.rb +79 -4
- data/lib/tempmail_sdk/providers/mailgolem.rb +96 -0
- data/lib/tempmail_sdk/providers/mailinator.rb +9 -2
- data/lib/tempmail_sdk/providers/mailtemp_cc.rb +91 -0
- data/lib/tempmail_sdk/providers/minuteinbox.rb +174 -0
- data/lib/tempmail_sdk/providers/moakt.rb +29 -3
- data/lib/tempmail_sdk/providers/mohmal.rb +208 -0
- data/lib/tempmail_sdk/providers/mytempmail_cc.rb +76 -0
- data/lib/tempmail_sdk/providers/openinbox.rb +102 -0
- data/lib/tempmail_sdk/providers/smail_pw.rb +156 -0
- data/lib/tempmail_sdk/providers/socketio_mail.rb +25 -0
- data/lib/tempmail_sdk/providers/tempgbox.rb +118 -0
- data/lib/tempmail_sdk/providers/tempmail_cn.rb +111 -0
- data/lib/tempmail_sdk/providers/tenminutemail_net.rb +183 -0
- data/lib/tempmail_sdk/providers/twentyfourmail_chacuo.rb +83 -0
- data/lib/tempmail_sdk/providers/vip_215.rb +171 -0
- data/lib/tempmail_sdk/registry.rb +163 -1
- data/lib/tempmail_sdk/version.rb +1 -1
- metadata +30 -2
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module TempmailSdk
|
|
7
|
+
module Providers
|
|
8
|
+
# fakemail.net 渠道实现(与 disposablemail.com 共享 PHP API 结构)
|
|
9
|
+
#
|
|
10
|
+
# 流程:
|
|
11
|
+
# 1. GET / 获取 session cookie + HTML 中的 CSRF token(const CSRF = "xxx")
|
|
12
|
+
# 2. GET /index/index?csrf_token={csrf} 创建邮箱,返回 {"email":"...","heslo":"..."}
|
|
13
|
+
# 3. GET /index/refresh 携带 session cookie 获取邮件列表(捷克语字段: predmet/od/kdy/precteno)
|
|
14
|
+
# 4. POST /index/email(body: id={id})获取邮件 HTML 正文
|
|
15
|
+
# token 存储 session cookie 字符串
|
|
16
|
+
module Fakemail
|
|
17
|
+
CHANNEL = "fakemail"
|
|
18
|
+
BASE_URL = "https://www.fakemail.net"
|
|
19
|
+
|
|
20
|
+
CSRF_RE = /const\s+CSRF\s*=\s*"([^"]+)"/.freeze
|
|
21
|
+
|
|
22
|
+
BROWSER_HEADERS = {
|
|
23
|
+
"Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
24
|
+
"User-Agent" => "Mozilla/5.0"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
AJAX_HEADERS = {
|
|
28
|
+
"Accept" => "application/json, text/javascript, */*; q=0.01",
|
|
29
|
+
"Referer" => "#{BASE_URL}/",
|
|
30
|
+
"User-Agent" => "Mozilla/5.0",
|
|
31
|
+
"X-Requested-With" => "XMLHttpRequest"
|
|
32
|
+
}.freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# 合并 Set-Cookie 为 Cookie 字符串
|
|
37
|
+
def merge_cookies(existing, resp)
|
|
38
|
+
jar = {}
|
|
39
|
+
existing.to_s.split(";").each do |part|
|
|
40
|
+
part = part.strip
|
|
41
|
+
next unless part.include?("=")
|
|
42
|
+
|
|
43
|
+
k, v = part.split("=", 2)
|
|
44
|
+
jar[k.strip] = v.to_s
|
|
45
|
+
end
|
|
46
|
+
resp.set_cookies.each do |line|
|
|
47
|
+
first = line.split(";", 2).first.to_s.strip
|
|
48
|
+
next unless first.include?("=")
|
|
49
|
+
|
|
50
|
+
k, v = first.split("=", 2)
|
|
51
|
+
jar[k.strip] = v.to_s
|
|
52
|
+
end
|
|
53
|
+
jar.map { |k, v| "#{k}=#{v}" }.join("; ")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# 去除 UTF-8 BOM
|
|
57
|
+
def clean_json(body)
|
|
58
|
+
body = body.dup.force_encoding("BINARY")
|
|
59
|
+
body = body[3..] if body.start_with?("\xEF\xBB\xBF".b)
|
|
60
|
+
body.force_encoding("UTF-8")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# 创建临时邮箱
|
|
64
|
+
# @return [EmailInfo]
|
|
65
|
+
def generate_email
|
|
66
|
+
r1 = Http.get("#{BASE_URL}/", headers: BROWSER_HEADERS)
|
|
67
|
+
r1.raise_for_status
|
|
68
|
+
cookie = merge_cookies("", r1)
|
|
69
|
+
m = r1.body.match(CSRF_RE)
|
|
70
|
+
raise "fakemail: csrf token not found" unless m
|
|
71
|
+
|
|
72
|
+
csrf = m[1]
|
|
73
|
+
r2 = Http.get("#{BASE_URL}/index/index?csrf_token=#{URI.encode_www_form_component(csrf)}",
|
|
74
|
+
headers: AJAX_HEADERS.merge(cookie.empty? ? {} : { "Cookie" => cookie }))
|
|
75
|
+
r2.raise_for_status
|
|
76
|
+
cookie = merge_cookies(cookie, r2)
|
|
77
|
+
|
|
78
|
+
data = JSON.parse(clean_json(r2.body))
|
|
79
|
+
email = data["email"].to_s.strip
|
|
80
|
+
raise "fakemail: invalid mailbox response" if email.empty? || !email.include?("@")
|
|
81
|
+
|
|
82
|
+
EmailInfo.new(channel: CHANNEL, email: email, token: cookie)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# 获取邮件列表
|
|
86
|
+
# @param token [String] session cookie 字符串
|
|
87
|
+
# @param email [String]
|
|
88
|
+
# @return [Array<Email>]
|
|
89
|
+
def get_emails(token, email)
|
|
90
|
+
raise "fakemail: empty session token" if token.to_s.strip.empty?
|
|
91
|
+
raise "fakemail: empty email" if email.to_s.strip.empty?
|
|
92
|
+
|
|
93
|
+
headers = AJAX_HEADERS.merge("Cookie" => token)
|
|
94
|
+
resp = Http.get("#{BASE_URL}/index/refresh", headers: headers)
|
|
95
|
+
resp.raise_for_status
|
|
96
|
+
|
|
97
|
+
rows = begin
|
|
98
|
+
JSON.parse(clean_json(resp.body))
|
|
99
|
+
rescue JSON::ParserError
|
|
100
|
+
[]
|
|
101
|
+
end
|
|
102
|
+
return [] unless rows.is_a?(Array) && !rows.empty?
|
|
103
|
+
|
|
104
|
+
rows.map do |row|
|
|
105
|
+
id = row["id"].to_s
|
|
106
|
+
detail = fetch_detail(token, id) unless id.empty?
|
|
107
|
+
subject = detail&.dig("predmet").to_s
|
|
108
|
+
subject = row["predmet"].to_s if subject.empty?
|
|
109
|
+
from_addr = detail&.dig("od").to_s
|
|
110
|
+
from_addr = row["od"].to_s if from_addr.empty?
|
|
111
|
+
html_body = detail&.dig("telo").to_s
|
|
112
|
+
Normalize.normalize_email({
|
|
113
|
+
"id" => id,
|
|
114
|
+
"from" => from_addr,
|
|
115
|
+
"to" => email,
|
|
116
|
+
"subject" => subject,
|
|
117
|
+
"html" => html_body,
|
|
118
|
+
"date" => row["kdy"].to_s,
|
|
119
|
+
"isRead" => row["precteno"] == "precteno"
|
|
120
|
+
}, email)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# 获取单封邮件详情
|
|
125
|
+
def fetch_detail(cookie, id)
|
|
126
|
+
return nil if id.empty?
|
|
127
|
+
|
|
128
|
+
form = "id=#{URI.encode_www_form_component(id)}"
|
|
129
|
+
resp = Http.post("#{BASE_URL}/index/email",
|
|
130
|
+
headers: AJAX_HEADERS.merge(
|
|
131
|
+
"Content-Type" => "application/x-www-form-urlencoded",
|
|
132
|
+
"Cookie" => cookie
|
|
133
|
+
),
|
|
134
|
+
body: form)
|
|
135
|
+
return nil unless resp.ok?
|
|
136
|
+
|
|
137
|
+
JSON.parse(clean_json(resp.body))
|
|
138
|
+
rescue StandardError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -32,7 +32,7 @@ module TempmailSdk
|
|
|
32
32
|
SUBJECT_RE = /<span\s+class\s*=\s*["']mail_konu["'][^>]*>([\s\S]*?)<\/span>/mi
|
|
33
33
|
DATE_RE = /<span\s+class\s*=\s*["']mail_zaman["'][^>]*>([\s\S]*?)<\/span>/mi
|
|
34
34
|
MAIL_LINK_RE = /href\s*=\s*["']([^"']*(?:mail|read|view)[^"']*)["']/i
|
|
35
|
-
|
|
35
|
+
BODY_OPEN_RE = /<div\s+(?:id|class)\s*=\s*["'](?:mail_icerik|icerik|mail-content|message-body)["'][^>]*>/mi
|
|
36
36
|
TAG_RE = /<[^>]+>/
|
|
37
37
|
|
|
38
38
|
module_function
|
|
@@ -112,8 +112,34 @@ module TempmailSdk
|
|
|
112
112
|
resp = Http.get(detail_url, headers: hdrs, timeout: 15)
|
|
113
113
|
return "" unless resp.ok?
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
extract_body_html(resp.body)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# 使用栈式深度匹配提取正文 div 的完整内部 HTML,
|
|
119
|
+
# 避免非贪婪正则在嵌套 div 时截断正文。
|
|
120
|
+
def extract_body_html(page)
|
|
121
|
+
m = page.match(BODY_OPEN_RE)
|
|
122
|
+
return "" unless m
|
|
123
|
+
|
|
124
|
+
start = m.end(0)
|
|
125
|
+
pos = start
|
|
126
|
+
depth = 1
|
|
127
|
+
while pos < page.length && depth > 0
|
|
128
|
+
next_open = page.index("<div", pos)
|
|
129
|
+
next_close = page.index("</div>", pos)
|
|
130
|
+
break unless next_close
|
|
131
|
+
|
|
132
|
+
if next_open && next_open < next_close
|
|
133
|
+
depth += 1
|
|
134
|
+
pos = next_open + 4
|
|
135
|
+
else
|
|
136
|
+
depth -= 1
|
|
137
|
+
return page[start...next_close].strip if depth.zero?
|
|
138
|
+
|
|
139
|
+
pos = next_close + 6
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
""
|
|
117
143
|
rescue StandardError
|
|
118
144
|
""
|
|
119
145
|
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module TempmailSdk
|
|
6
|
+
module Providers
|
|
7
|
+
# linshiyouxiang.net 渠道实现
|
|
8
|
+
#
|
|
9
|
+
# 流程:
|
|
10
|
+
# 1. GET / 获取 temp_mail cookie,从 HTML 正则提取 tempMailGlobal(邮箱)和 mailCodeGlobal(校验 code)
|
|
11
|
+
# 2. POST /get-messages {"email":"...","code":"..."} 获取邮件列表
|
|
12
|
+
# token 存储 mailCodeGlobal 的值(HMAC 哈希,后续请求用于校验)
|
|
13
|
+
module LinshiyouxiangNet
|
|
14
|
+
CHANNEL = "linshiyouxiang-net"
|
|
15
|
+
BASE_URL = "https://www.linshiyouxiang.net"
|
|
16
|
+
|
|
17
|
+
EMAIL_RE = /tempMailGlobal\s*=\s*'([^']+)'/.freeze
|
|
18
|
+
CODE_RE = /mailCodeGlobal\s*=\s*'([^']+)'/.freeze
|
|
19
|
+
|
|
20
|
+
BROWSER_HEADERS = {
|
|
21
|
+
"Accept" => "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
22
|
+
"Accept-Language" => "zh-CN,zh;q=0.9,en;q=0.8",
|
|
23
|
+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
|
|
24
|
+
"(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
AJAX_HEADERS = {
|
|
28
|
+
"Accept" => "application/json, text/javascript, */*; q=0.01",
|
|
29
|
+
"Accept-Language" => "zh-CN,zh;q=0.9,en;q=0.8",
|
|
30
|
+
"Content-Type" => "application/json",
|
|
31
|
+
"Origin" => BASE_URL,
|
|
32
|
+
"Referer" => "#{BASE_URL}/",
|
|
33
|
+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
|
|
34
|
+
"(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36",
|
|
35
|
+
"X-Requested-With" => "XMLHttpRequest"
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
module_function
|
|
39
|
+
|
|
40
|
+
# 创建临时邮箱
|
|
41
|
+
# @return [EmailInfo]
|
|
42
|
+
def generate_email
|
|
43
|
+
resp = Http.get("#{BASE_URL}/", headers: BROWSER_HEADERS)
|
|
44
|
+
resp.raise_for_status
|
|
45
|
+
html = resp.body
|
|
46
|
+
|
|
47
|
+
em = html.match(EMAIL_RE)
|
|
48
|
+
raise "linshiyouxiang-net: 未能从首页提取邮箱地址" unless em
|
|
49
|
+
|
|
50
|
+
email = em[1].strip
|
|
51
|
+
raise "linshiyouxiang-net: 提取的邮箱地址为空" if email.empty?
|
|
52
|
+
|
|
53
|
+
code = ""
|
|
54
|
+
if (cm = html.match(CODE_RE))
|
|
55
|
+
code = cm[1].strip
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
EmailInfo.new(channel: CHANNEL, email: email, token: code)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 获取邮件列表
|
|
62
|
+
# POST /get-messages {"email":"...","code":"<token>"}
|
|
63
|
+
# @param email [String]
|
|
64
|
+
# @param token [String] mailCodeGlobal 值
|
|
65
|
+
# @return [Array<Email>]
|
|
66
|
+
def get_emails(email, token)
|
|
67
|
+
raise "linshiyouxiang-net: 邮箱地址为空" if email.to_s.strip.empty?
|
|
68
|
+
|
|
69
|
+
resp = Http.post("#{BASE_URL}/get-messages",
|
|
70
|
+
headers: AJAX_HEADERS,
|
|
71
|
+
json: { "email" => email, "code" => token.to_s })
|
|
72
|
+
resp.raise_for_status
|
|
73
|
+
data = resp.json
|
|
74
|
+
emails_raw = data["emails"]
|
|
75
|
+
return [] unless emails_raw.is_a?(Array) && !emails_raw.empty?
|
|
76
|
+
|
|
77
|
+
emails_raw.filter_map do |raw|
|
|
78
|
+
next unless raw.is_a?(Hash)
|
|
79
|
+
|
|
80
|
+
Normalize.normalize_email(raw, email)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -47,7 +47,41 @@ module TempmailSdk
|
|
|
47
47
|
EmailInfo.new(channel: CHANNEL, email: "#{local}@#{domain}")
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# 通过详情接口获取单封邮件完整正文
|
|
51
|
+
# GET /api/fetch/{id}
|
|
52
|
+
# 失败时返回 nil,调用方回退到列表数据
|
|
53
|
+
def fetch_detail(id)
|
|
54
|
+
mid = id.to_s.strip
|
|
55
|
+
return nil if mid.empty?
|
|
56
|
+
|
|
57
|
+
begin
|
|
58
|
+
resp = Http.get("#{BASE}/api/fetch/#{URI.encode_www_form_component(mid)}",
|
|
59
|
+
headers: HEADERS, timeout: 15)
|
|
60
|
+
return nil if resp.status_code < 200 || resp.status_code >= 300
|
|
61
|
+
|
|
62
|
+
data = resp.json
|
|
63
|
+
data.is_a?(Hash) ? data : nil
|
|
64
|
+
rescue StandardError
|
|
65
|
+
nil
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# 从列表条目提取邮件 ID(支持多字段)
|
|
70
|
+
def extract_id(row)
|
|
71
|
+
%w[id _id mail_id messageId message_id].each do |key|
|
|
72
|
+
v = row[key]
|
|
73
|
+
next if v.nil?
|
|
74
|
+
|
|
75
|
+
return v.to_s.strip if v.is_a?(String) && !v.strip.empty?
|
|
76
|
+
return v.to_i.to_s if v.is_a?(Numeric)
|
|
77
|
+
end
|
|
78
|
+
""
|
|
79
|
+
end
|
|
80
|
+
|
|
50
81
|
# 获取邮件列表
|
|
82
|
+
# 1. GET /api/fetch?to={email} 拉取列表元数据
|
|
83
|
+
# 2. 对每封邮件 GET /api/fetch/{id} 拉取详情(含完整 text/html)
|
|
84
|
+
# 3. 详情失败时保留列表字段作为回退
|
|
51
85
|
# @param email [String]
|
|
52
86
|
# @return [Array<Email>]
|
|
53
87
|
def get_emails(email)
|
|
@@ -60,7 +94,14 @@ module TempmailSdk
|
|
|
60
94
|
return [] unless data.is_a?(Array)
|
|
61
95
|
|
|
62
96
|
data.select { |raw| raw.is_a?(Hash) }.map do |raw|
|
|
63
|
-
|
|
97
|
+
mail_id = extract_id(raw)
|
|
98
|
+
merged = raw.dup
|
|
99
|
+
# 无条件调用详情接口,用详情字段覆盖列表字段
|
|
100
|
+
unless mail_id.empty?
|
|
101
|
+
detail = fetch_detail(mail_id)
|
|
102
|
+
merged.merge!(detail) if detail
|
|
103
|
+
end
|
|
104
|
+
Normalize.normalize_email(merged, addr)
|
|
64
105
|
end
|
|
65
106
|
end
|
|
66
107
|
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module TempmailSdk
|
|
7
|
+
module Providers
|
|
8
|
+
# mail.td 渠道实现(基于 SHA-256 Proof-of-Work 的临时邮箱服务)
|
|
9
|
+
#
|
|
10
|
+
# 流程:
|
|
11
|
+
# 1. GET /api/domains 获取可用域名(过滤 pro_only)
|
|
12
|
+
# 2. 求解 PoW: SHA-256(address + timestamp + nonce) 需满足 difficulty 个前导零位
|
|
13
|
+
# 3. POST /api/accounts 携带 PoW 创建账户 → 返回 JWT + ID
|
|
14
|
+
# 4. GET /api/accounts/{id}/messages?page=1 携带 Bearer JWT 获取邮件
|
|
15
|
+
# token 格式: JSON {"jwt":"...","id":"..."}
|
|
16
|
+
module MailTd
|
|
17
|
+
CHANNEL = "mail-td"
|
|
18
|
+
API_BASE = "https://api.mail.td/api"
|
|
19
|
+
RAND_CHARS = ("a".."z").to_a + ("0".."9").to_a
|
|
20
|
+
|
|
21
|
+
HEADERS = {
|
|
22
|
+
"Accept" => "application/json",
|
|
23
|
+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
|
|
24
|
+
"(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# 生成随机小写字母 + 数字字符串
|
|
30
|
+
def random_string(len)
|
|
31
|
+
Array.new(len) { RAND_CHARS.sample }.join
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 检查哈希是否满足 difficulty 个前导零位
|
|
35
|
+
def leading_zero_bits?(hash_bytes, difficulty)
|
|
36
|
+
full = difficulty / 8
|
|
37
|
+
remain = difficulty % 8
|
|
38
|
+
(0...full).each { |i| return false if hash_bytes.getbyte(i) != 0 }
|
|
39
|
+
if remain.positive? && full < hash_bytes.bytesize
|
|
40
|
+
mask = (0xFF << (8 - remain)) & 0xFF
|
|
41
|
+
return false if (hash_bytes.getbyte(full) & mask) != 0
|
|
42
|
+
end
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 求解 Proof-of-Work
|
|
47
|
+
# 目标:SHA-256(address + timestamp + nonce) 前导零位 >= difficulty
|
|
48
|
+
def solve_pow(address, timestamp, difficulty)
|
|
49
|
+
base = "#{address}#{timestamp}"
|
|
50
|
+
nonce = 0
|
|
51
|
+
while nonce < 100_000_000
|
|
52
|
+
hash = Digest::SHA256.digest("#{base}#{nonce}")
|
|
53
|
+
return nonce.to_s if leading_zero_bits?(hash, difficulty)
|
|
54
|
+
|
|
55
|
+
nonce += 1
|
|
56
|
+
end
|
|
57
|
+
raise "mail-td: PoW 求解超时"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# 获取可用域名列表(过滤 pro_only)
|
|
61
|
+
def fetch_domains
|
|
62
|
+
resp = Http.get("#{API_BASE}/domains", headers: HEADERS)
|
|
63
|
+
raise "mail-td: 域名请求 HTTP #{resp.status_code}" unless resp.ok?
|
|
64
|
+
|
|
65
|
+
data = resp.json
|
|
66
|
+
domains = data["domains"] || []
|
|
67
|
+
free = domains.select { |d| d["pro_only"] == false && !d["domain"].to_s.empty? }
|
|
68
|
+
.map { |d| d["domain"] }
|
|
69
|
+
raise "mail-td: 无可用免费域名" if free.empty?
|
|
70
|
+
|
|
71
|
+
free
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# 创建临时邮箱
|
|
75
|
+
# @return [EmailInfo]
|
|
76
|
+
def generate_email
|
|
77
|
+
domain = fetch_domains.sample
|
|
78
|
+
username = random_string(10)
|
|
79
|
+
address = "#{username}@#{domain}"
|
|
80
|
+
password = random_string(20)
|
|
81
|
+
auth_key = Digest::SHA256.hexdigest(password)
|
|
82
|
+
|
|
83
|
+
addr_lower = address.strip.downcase
|
|
84
|
+
difficulty = 15
|
|
85
|
+
pow_token = ""
|
|
86
|
+
|
|
87
|
+
4.times do
|
|
88
|
+
timestamp = Time.now.to_i
|
|
89
|
+
nonce = solve_pow(addr_lower, timestamp, difficulty)
|
|
90
|
+
pow_obj = { "t" => timestamp, "n" => nonce, "d" => difficulty }
|
|
91
|
+
pow_obj["token"] = pow_token unless pow_token.empty?
|
|
92
|
+
|
|
93
|
+
resp = Http.post("#{API_BASE}/accounts",
|
|
94
|
+
headers: HEADERS.merge("Content-Type" => "application/json"),
|
|
95
|
+
json: { "address" => address, "auth_key" => auth_key, "pow" => pow_obj })
|
|
96
|
+
|
|
97
|
+
result = begin
|
|
98
|
+
resp.json
|
|
99
|
+
rescue StandardError
|
|
100
|
+
{}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if result["status"] == "retry"
|
|
104
|
+
rd = result["required_difficulty"]
|
|
105
|
+
difficulty = rd.is_a?(Numeric) ? rd.to_i : difficulty + 2
|
|
106
|
+
pow_token = result["token"].to_s
|
|
107
|
+
next
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
raise "mail-td: 创建账户 HTTP #{resp.status_code}: #{result['error']}" unless resp.ok?
|
|
111
|
+
|
|
112
|
+
result_addr = result["address"].to_s
|
|
113
|
+
result_token = result["token"].to_s
|
|
114
|
+
result_id = result["id"].to_s
|
|
115
|
+
raise "mail-td: 响应缺少必要字段" if result_addr.empty? || result_token.empty? || result_id.empty?
|
|
116
|
+
|
|
117
|
+
tok = JSON.generate("jwt" => result_token, "id" => result_id)
|
|
118
|
+
return EmailInfo.new(channel: CHANNEL, email: result_addr, token: tok)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
raise "mail-td: PoW 重试次数超限"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# 获取邮件列表
|
|
125
|
+
# @param token [String] JSON {"jwt":"...","id":"..."}
|
|
126
|
+
# @param email [String]
|
|
127
|
+
# @return [Array<Email>]
|
|
128
|
+
def get_emails(token, email)
|
|
129
|
+
raise "mail-td: token 为空" if token.to_s.empty?
|
|
130
|
+
|
|
131
|
+
parsed = JSON.parse(token)
|
|
132
|
+
jwt = parsed["jwt"].to_s
|
|
133
|
+
id = parsed["id"].to_s
|
|
134
|
+
raise "mail-td: token 缺少 jwt 或 id" if jwt.empty? || id.empty?
|
|
135
|
+
|
|
136
|
+
resp = Http.get("#{API_BASE}/accounts/#{id}/messages?page=1",
|
|
137
|
+
headers: HEADERS.merge("Authorization" => "Bearer #{jwt}"))
|
|
138
|
+
raise "mail-td: 邮件请求 HTTP #{resp.status_code}" unless resp.ok?
|
|
139
|
+
|
|
140
|
+
data = resp.json
|
|
141
|
+
messages = data["messages"]
|
|
142
|
+
return [] unless messages.is_a?(Array) && !messages.empty?
|
|
143
|
+
|
|
144
|
+
messages.map do |msg|
|
|
145
|
+
from_addr = msg.dig("from", "address").to_s
|
|
146
|
+
Normalize.normalize_email({
|
|
147
|
+
"id" => msg["id"].to_s,
|
|
148
|
+
"from" => from_addr,
|
|
149
|
+
"to" => email,
|
|
150
|
+
"subject" => msg["subject"].to_s,
|
|
151
|
+
"text" => msg["text"].to_s,
|
|
152
|
+
"html" => msg["html"].to_s,
|
|
153
|
+
"created_at" => msg["created_at"].to_s
|
|
154
|
+
}, email)
|
|
155
|
+
end
|
|
156
|
+
rescue JSON::ParserError
|
|
157
|
+
raise "mail-td: token 格式无效"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TempmailSdk
|
|
4
|
+
module Providers
|
|
5
|
+
# mailcat.ai 渠道实现
|
|
6
|
+
#
|
|
7
|
+
# 流程:POST /mailboxes 创建邮箱(无 body),返回 email + token
|
|
8
|
+
# GET /inbox 获取邮件列表,需要 Authorization: Bearer {token}
|
|
9
|
+
module MailcatAi
|
|
10
|
+
CHANNEL = "mailcat-ai"
|
|
11
|
+
BASE_URL = "https://api.mailcat.ai"
|
|
12
|
+
|
|
13
|
+
HEADERS = {
|
|
14
|
+
"Accept" => "application/json",
|
|
15
|
+
"User-Agent" => "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " \
|
|
16
|
+
"(KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0"
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
# 创建 mailcat.ai 临时邮箱
|
|
22
|
+
# @return [EmailInfo]
|
|
23
|
+
def generate_email
|
|
24
|
+
resp = Http.post("#{BASE_URL}/mailboxes", headers: HEADERS, timeout: 15)
|
|
25
|
+
resp.raise_for_status
|
|
26
|
+
data = resp.json
|
|
27
|
+
email = data.dig("data", "email").to_s.strip
|
|
28
|
+
token = data.dig("data", "token").to_s.strip
|
|
29
|
+
raise "mailcat-ai: 响应缺少 email 或 token" if email.empty? || token.empty?
|
|
30
|
+
|
|
31
|
+
EmailInfo.new(channel: CHANNEL, email: email, token: token)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# 获取 mailcat.ai 邮件列表
|
|
35
|
+
# @param token [String] Bearer token
|
|
36
|
+
# @param email [String] 邮箱地址
|
|
37
|
+
# @return [Array<Email>]
|
|
38
|
+
def get_emails(token, email)
|
|
39
|
+
hdrs = HEADERS.merge("Authorization" => "Bearer #{token.to_s.strip}")
|
|
40
|
+
resp = Http.get("#{BASE_URL}/inbox", headers: hdrs, timeout: 15)
|
|
41
|
+
resp.raise_for_status
|
|
42
|
+
data = resp.json
|
|
43
|
+
items = data.is_a?(Hash) ? Array(data["data"]) : []
|
|
44
|
+
items.filter_map do |raw|
|
|
45
|
+
next unless raw.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
Normalize.normalize_email(raw.merge("to" => email), email)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -62,6 +62,55 @@ module TempmailSdk
|
|
|
62
62
|
EmailInfo.new(channel: CHANNEL, email: email, token: email)
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
# 通过详情接口获取单封邮件完整内容
|
|
66
|
+
# GET /api/email_content.php?id={id}
|
|
67
|
+
# 详情响应字段(从前端代码确认):
|
|
68
|
+
# content: 完整 HTML 正文
|
|
69
|
+
# subject / from_addr / date: 邮件元数据
|
|
70
|
+
# attachment: JSON 字符串数组 [{filename, path, size}]
|
|
71
|
+
def fetch_detail(id)
|
|
72
|
+
mid = id.to_s.strip
|
|
73
|
+
return nil if mid.empty?
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
resp = Http.get("#{BASE}/api/email_content.php?id=#{URI.encode_www_form_component(mid)}",
|
|
77
|
+
headers: HEADERS, timeout: 15)
|
|
78
|
+
return nil if resp.status_code < 200 || resp.status_code >= 300
|
|
79
|
+
|
|
80
|
+
data = resp.body.empty? ? nil : resp.json
|
|
81
|
+
data.is_a?(Hash) ? data : nil
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# 解析详情接口的 attachment 字段(JSON 字符串)为附件数组
|
|
88
|
+
def parse_attachments(raw)
|
|
89
|
+
return [] if raw.nil? || !raw.is_a?(String) || raw.strip.empty?
|
|
90
|
+
|
|
91
|
+
begin
|
|
92
|
+
items = JSON.parse(raw)
|
|
93
|
+
rescue JSON::ParserError
|
|
94
|
+
return []
|
|
95
|
+
end
|
|
96
|
+
return [] unless items.is_a?(Array)
|
|
97
|
+
|
|
98
|
+
items.filter_map do |it|
|
|
99
|
+
next unless it.is_a?(Hash)
|
|
100
|
+
|
|
101
|
+
filename = (it["filename"] || "").to_s.strip
|
|
102
|
+
next if filename.empty?
|
|
103
|
+
|
|
104
|
+
entry = { "filename" => filename }
|
|
105
|
+
entry["size"] = it["size"].to_i if it["size"].is_a?(Numeric)
|
|
106
|
+
entry
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# 获取邮件列表并对每封邮件补拉详情
|
|
111
|
+
# 1. GET /api/emails.php 拉取列表(仅含 description 摘要)
|
|
112
|
+
# 2. 对每封邮件 GET /api/email_content.php?id={id} 拉取详情(含 content 完整 HTML)
|
|
113
|
+
# 3. 详情失败时保留列表 description 作为回退
|
|
65
114
|
# @param email [String]
|
|
66
115
|
# @param token [String, nil]
|
|
67
116
|
# @return [Array<Email>]
|
|
@@ -78,12 +127,38 @@ module TempmailSdk
|
|
|
78
127
|
return [] unless rows.is_a?(Array)
|
|
79
128
|
|
|
80
129
|
rows.select { |row| row.is_a?(Hash) }.map do |row|
|
|
130
|
+
mail_id = row["id"].to_s
|
|
131
|
+
desc = (row["description"] || "").strip
|
|
81
132
|
ir = row["isRead"]
|
|
133
|
+
is_read = ir == true || ir == 1
|
|
134
|
+
|
|
135
|
+
from = (row["from_addr"] || "").strip
|
|
136
|
+
subject = (row["subject"] || "").strip
|
|
137
|
+
date = cx_date_to_iso(row["date"] || "")
|
|
138
|
+
html = ""
|
|
139
|
+
attachments = []
|
|
140
|
+
|
|
141
|
+
# 拉取详情覆盖 html/from/subject/date/attachments
|
|
142
|
+
unless mail_id.empty?
|
|
143
|
+
detail = fetch_detail(mail_id)
|
|
144
|
+
if detail
|
|
145
|
+
d_content = detail["content"]
|
|
146
|
+
html = d_content if d_content.is_a?(String) && !d_content.strip.empty?
|
|
147
|
+
d_from = detail["from_addr"]
|
|
148
|
+
from = d_from.strip if d_from.is_a?(String) && !d_from.strip.empty?
|
|
149
|
+
d_subj = detail["subject"]
|
|
150
|
+
subject = d_subj.strip if d_subj.is_a?(String) && !d_subj.strip.empty?
|
|
151
|
+
d_date = detail["date"]
|
|
152
|
+
date = cx_date_to_iso(d_date) if d_date.is_a?(String) && !d_date.strip.empty?
|
|
153
|
+
attachments = parse_attachments(detail["attachment"])
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
82
157
|
Email.new(
|
|
83
|
-
id:
|
|
84
|
-
subject:
|
|
85
|
-
html:
|
|
86
|
-
is_read:
|
|
158
|
+
id: mail_id, from_addr: from, to: addr,
|
|
159
|
+
subject: subject, text: desc,
|
|
160
|
+
html: html, date: date,
|
|
161
|
+
is_read: is_read, attachments: attachments
|
|
87
162
|
)
|
|
88
163
|
end
|
|
89
164
|
end
|