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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/lib/tempmail_sdk/providers/altmails.rb +99 -0
  3. data/lib/tempmail_sdk/providers/apihz.rb +138 -0
  4. data/lib/tempmail_sdk/providers/awamail.rb +95 -0
  5. data/lib/tempmail_sdk/providers/best_temp_mail.rb +77 -0
  6. data/lib/tempmail_sdk/providers/chatgpt_org_uk.rb +129 -0
  7. data/lib/tempmail_sdk/providers/disposablemail.rb +125 -0
  8. data/lib/tempmail_sdk/providers/disposablemail_app.rb +66 -0
  9. data/lib/tempmail_sdk/providers/email10min.rb +152 -0
  10. data/lib/tempmail_sdk/providers/emailnator.rb +132 -0
  11. data/lib/tempmail_sdk/providers/emailtemp_org.rb +131 -0
  12. data/lib/tempmail_sdk/providers/expressinboxhub.rb +118 -0
  13. data/lib/tempmail_sdk/providers/fakemail.rb +143 -0
  14. data/lib/tempmail_sdk/providers/haribu.rb +29 -3
  15. data/lib/tempmail_sdk/providers/linshiyouxiang_net.rb +85 -0
  16. data/lib/tempmail_sdk/providers/mail_sunls.rb +42 -1
  17. data/lib/tempmail_sdk/providers/mail_td.rb +161 -0
  18. data/lib/tempmail_sdk/providers/mailcat_ai.rb +52 -0
  19. data/lib/tempmail_sdk/providers/maildrop.rb +79 -4
  20. data/lib/tempmail_sdk/providers/mailgolem.rb +96 -0
  21. data/lib/tempmail_sdk/providers/mailinator.rb +9 -2
  22. data/lib/tempmail_sdk/providers/mailtemp_cc.rb +91 -0
  23. data/lib/tempmail_sdk/providers/minuteinbox.rb +174 -0
  24. data/lib/tempmail_sdk/providers/moakt.rb +29 -3
  25. data/lib/tempmail_sdk/providers/mohmal.rb +208 -0
  26. data/lib/tempmail_sdk/providers/mytempmail_cc.rb +76 -0
  27. data/lib/tempmail_sdk/providers/openinbox.rb +102 -0
  28. data/lib/tempmail_sdk/providers/smail_pw.rb +156 -0
  29. data/lib/tempmail_sdk/providers/socketio_mail.rb +25 -0
  30. data/lib/tempmail_sdk/providers/tempgbox.rb +118 -0
  31. data/lib/tempmail_sdk/providers/tempmail_cn.rb +111 -0
  32. data/lib/tempmail_sdk/providers/tenminutemail_net.rb +183 -0
  33. data/lib/tempmail_sdk/providers/twentyfourmail_chacuo.rb +83 -0
  34. data/lib/tempmail_sdk/providers/vip_215.rb +171 -0
  35. data/lib/tempmail_sdk/registry.rb +163 -1
  36. data/lib/tempmail_sdk/version.rb +1 -1
  37. 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
- BODY_RE = /<div\s+(?:id|class)\s*=\s*["'](?:mail_icerik|icerik|mail-content|message-body)["'][^>]*>([\s\S]*?)<\/div>/mi
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
- m = resp.body.match(BODY_RE)
116
- m ? m[1].strip : ""
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
- Normalize.normalize_email(raw, addr)
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: row["id"].to_s, from_addr: (row["from_addr"] || "").strip, to: addr,
84
- subject: (row["subject"] || "").strip, text: (row["description"] || "").strip,
85
- html: "", date: cx_date_to_iso(row["date"] || ""),
86
- is_read: ir == true || ir == 1, attachments: []
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