diff --git a/README.md b/README.md index 2544960..5186633 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - Node.js 20.18+ - 可用代理,默认读取 `config.json.defaultProxyUrl` -- 一个可用邮箱 provider:`cloudflare`、`gmail` 或 `hotmail` +- 一个可用邮箱 provider:`cloudflare`、`gmail`、`hotmail` 或 `mailnest` ## 快速开始 @@ -124,6 +124,11 @@ Cloudflare: - `cloudflareApiBaseUrl`:邮件 Worker 地址。 - `cloudflareApiKey`:邮件 Worker 的 `x-api-key`。 +MailNest: + +- `mailNestApiKey`:Outlook 邮箱提供商迈巢的`api-key`,获取页面:https://mailnest.top/account。 +- `mailNestProjectCode`:迈巢提供临时与独占两种 Outlook 邮箱。填写该值,即项目代码,则使用对应项目的临时邮箱,不填则使用独占邮箱。项目代码获取页面:https://mailnest.top/buy-email。Codex 的项目代码默认为`chatgpt001`,可直接使用。 + CLIProxyAPI 自动上传: - `cliproxyApiAutoUploadAuth`:授权成功后是否自动上传 auth 文件。 @@ -175,6 +180,20 @@ CLIProxyAPI 自动上传: 程序会随机取一个账号生成别名邮箱,刷新 token,并读取收件箱和垃圾箱里的验证码邮件。刷新后的 `refresh_token` 会回写到 `tokens.txt`。 +### MailNest + +Outlook 邮箱提供商迈巢,提供临时与独占两种 Outlook 邮箱,配置便捷,开箱即用。 + +```json +{ + "provider": "mailnest", + "mailNestApiKey": "", + "mailNestProjectCode": "chatgpt001" +} +``` + +字段含义已在**配置项**章节中阐述。 + ## 授权文件 Codex OAuth 授权文件会保存到 `auth/`,文件名格式为: diff --git a/config.example.json b/config.example.json index e423146..676a3aa 100644 --- a/config.example.json +++ b/config.example.json @@ -8,6 +8,8 @@ "cloudflareEmailDomain": "", "cloudflareApiBaseUrl": "", "cloudflareApiKey": "", + "mailNestApiKey": "", + "mailNestProjectCode": "chatgpt001", "cliproxyApiAutoUploadAuth": false, "cliproxyApiBaseUrl": "http://localhost:8317", "cliproxyApiManagementKey": "" diff --git a/src/config.ts b/src/config.ts index 6fb8689..33cfd9e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,7 +1,7 @@ import {readFileSync} from "node:fs"; import path from "node:path"; -export type MailProviderName = "gmail" | "hotmail" | "cloudflare"; +export type MailProviderName = "gmail" | "hotmail" | "cloudflare" | "mailnest"; interface AppConfigFile { provider?: unknown; @@ -12,6 +12,8 @@ interface AppConfigFile { cloudflareEmailDomain?: unknown; cloudflareApiBaseUrl?: unknown; cloudflareApiKey?: unknown; + mailNestApiKey?: unknown, + mailNestProjectCode?: unknown, defaultProxyUrl?: unknown; cliproxyApiAutoUploadAuth?: unknown; cliproxyApiBaseUrl?: unknown; @@ -27,6 +29,8 @@ export interface AppConfig { cloudflareEmailDomain: string; cloudflareApiBaseUrl: string; cloudflareApiKey: string; + mailNestApiKey: string; + mailNestProjectCode: string; defaultProxyUrl: string; cliproxyApiAutoUploadAuth: boolean; cliproxyApiBaseUrl: string; @@ -43,6 +47,8 @@ const DEFAULT_CONFIG: AppConfig = { cloudflareApiBaseUrl: "", cloudflareApiKey: "", defaultProxyUrl: "http://127.0.0.1:10808", + mailNestApiKey: "", + mailNestProjectCode: "", cliproxyApiAutoUploadAuth: false, cliproxyApiBaseUrl: "http://localhost:8317", cliproxyApiManagementKey: "", @@ -56,7 +62,7 @@ function normalizeNumber(value: unknown, fallback: number): number { } function normalizeProvider(value: unknown): MailProviderName { - if (value === "gmail" || value === "hotmail" || value === "cloudflare") { + if (value === "gmail" || value === "hotmail" || value === "cloudflare" || value === "mailnest") { return value; } return DEFAULT_CONFIG.provider; @@ -115,6 +121,14 @@ function loadConfig(): AppConfig { typeof parsed.cloudflareApiKey === "string" ? parsed.cloudflareApiKey.trim() : DEFAULT_CONFIG.cloudflareApiKey, + mailNestApiKey: + typeof parsed.mailNestApiKey === "string" + ? parsed.mailNestApiKey.trim() + : DEFAULT_CONFIG.mailNestApiKey, + mailNestProjectCode: + typeof parsed.mailNestProjectCode === "string" + ? parsed.mailNestProjectCode.trim() + : DEFAULT_CONFIG.mailNestProjectCode, defaultProxyUrl: typeof parsed.defaultProxyUrl === "string" ? parsed.defaultProxyUrl.trim() diff --git a/src/mail/mailnest.ts b/src/mail/mailnest.ts new file mode 100644 index 0000000..414771d --- /dev/null +++ b/src/mail/mailnest.ts @@ -0,0 +1,141 @@ +import {appConfig} from "../config.js"; +import {Agent, Dispatcher, ProxyAgent, fetch as undiciFetch, type RequestInit as UndiciRequestInit} from "undici"; + + +interface MailNestMailItem { + email: string; + code_match: string; +} + +interface MailNestItems { + code?: string; + data: MailNestMailItem[]; +} + +const MAILNEST_POLL_ATTEMPTS = 12; +const MAILNEST_POLL_INTERVAL_MS = 5000; + +function buildDispatcher(): Dispatcher { + const proxyUrl = String(appConfig.defaultProxyUrl ?? "").trim(); + return proxyUrl + ? new ProxyAgent({ + uri: proxyUrl, + requestTls: {rejectUnauthorized: false}, + }) + : new Agent({ + connect: {rejectUnauthorized: false}, + }); +} + +function ensureApiKeyConfigured(): string { + const apiKey = String(appConfig.mailNestApiKey ?? "").trim(); + if (!apiKey) { + throw new Error("MailNestApiKey 未配置,请先在 https://mailnest.top/account 页面获取"); + } + return apiKey; +} + +function projectCodeApiKeyConfigured(): string { + return String(appConfig.mailNestProjectCode ?? "").trim(); +} + + +async function mailNestFetch(input: string | URL, init = {}) { + return undiciFetch(input, { + ...init, + dispatcher: buildDispatcher(), + } satisfies UndiciRequestInit); +} + + +async function fetchEmail(): Promise { + const apiKey = ensureApiKeyConfigured(); + const code = projectCodeApiKeyConfigured(); + let response; + if (code.length === 0) { + console.log('未配置项目代码 购买独占邮箱') + response = await mailNestFetch(new URL(`https://mailnest.top/api/v1/email/exclusive/buy`), { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + body: JSON.stringify({ + count: 1, + }) + }); + } else { + console.log('配置项目代码 购买临时邮箱') + response = await mailNestFetch(new URL(`https://mailnest.top/api/v1/email/temporary/buy`), { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + body: JSON.stringify({ + project_code: code, + count: 1, + }) + }); + } + const payload = await response.json() as MailNestItems; + console.log(payload); + if (payload.code != '00000') { + throw new Error(`mailNest 邮箱请求失败: ${response.status} body=${await response.text()}`); + } + + if (!Array.isArray(payload?.data)) { + throw new Error(`mailNest 邮箱返回格式异常: ${JSON.stringify(payload)}`); + } + if (payload.data.length == 0) { + throw new Error(`mailNest 没有获取到邮箱: ${JSON.stringify(payload)}`); + } + return payload.data[0].email; +} + + +async function receive(email: string): Promise { + const apiKey = ensureApiKeyConfigured(); + const response = await mailNestFetch(new URL(` https://mailnest.top/api/v1/email/receive`), { + method: "POST", + headers: { + Authorization: `Bearer ${apiKey}`, + Accept: "application/json", + }, + body: JSON.stringify({ + email: email, + }) + }); + const payload = await response.json() as MailNestItems; + console.log(payload) + if (payload.code != '00000') { + throw new Error(`mailNest 邮箱请求失败: ${response.status} body=${await response.text()}`); + } + if (payload.data.length == 0) { + return '' + } + return payload.data[0].code_match; +} + + +export function createMailNestProvider() { + return { + async getEmailAddress() { + return fetchEmail() + }, + async getEmailVerificationCode(email: string) { + await new Promise((resolve) => setTimeout(resolve, MAILNEST_POLL_INTERVAL_MS)); + ensureApiKeyConfigured(); + for (let attempt = 1; attempt <= MAILNEST_POLL_ATTEMPTS; attempt += 1) { + const code = await receive(email); + if (code.length > 0) { + return code; + } + if (attempt < MAILNEST_POLL_ATTEMPTS) { + await new Promise((resolve) => setTimeout(resolve, MAILNEST_POLL_INTERVAL_MS)); + } + } + throw new Error(`mailNest 邮箱中未找到验证码: targetEmail=${email}`); + }, + }; +} diff --git a/src/mailbox.ts b/src/mailbox.ts index d1e9067..5f83d42 100644 --- a/src/mailbox.ts +++ b/src/mailbox.ts @@ -2,6 +2,7 @@ import {appConfig, type MailProviderName} from "./config.js"; import {createCloudflareProvider} from "./mail/cloudflare.js"; import {createGmailProvider} from "./mail/gmail.js"; import {createHotmailProvider} from "./mail/hotmail.js"; +import {createMailNestProvider} from "./mail/mailnest.js"; export interface EmailCodeProvider { getEmailAddress(): Promise; @@ -22,6 +23,8 @@ function createProvider(): EmailCodeProvider { return createHotmailProvider(); case "cloudflare": return createCloudflareProvider(); + case "mailnest": + return createMailNestProvider(); default: throw new Error(`不支持的邮箱 provider: ${MAILBOX_CONFIG.provider}`); }