tempmail-sdk 1.1.8 → 1.2.0

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.
@@ -0,0 +1,376 @@
1
+ import WebSocket from 'ws';
2
+ import { InternalEmailInfo, Email, Channel } from '../types';
3
+ import { normalizeEmail } from '../normalize';
4
+
5
+ const CHANNEL: Channel = 'tempmail-cn';
6
+ const DEFAULT_HOST = 'tempmail.cn';
7
+ const CONNECT_TIMEOUT_MS = 15000;
8
+ const HANDSHAKE_WAIT_MS = 1000;
9
+ const INITIAL_SYNC_WAIT_MS = 80;
10
+ const SOCKET_IO_VERSIONS = [4, 3];
11
+
12
+ const DEFAULT_HEADERS: Record<string, string> = {
13
+ 'User-Agent':
14
+ 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36 Edg/146.0.0.0',
15
+ 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6',
16
+ 'Cache-Control': 'no-cache',
17
+ DNT: '1',
18
+ Pragma: 'no-cache',
19
+ };
20
+
21
+ type BoxState = {
22
+ emails: Email[];
23
+ seenIds: Set<string>;
24
+ ws: WebSocket | null;
25
+ connectPromise?: Promise<void>;
26
+ };
27
+
28
+ const mailboxes = new Map<string, BoxState>();
29
+
30
+ function getState(email: string): BoxState {
31
+ const key = email.trim().toLowerCase();
32
+ let st = mailboxes.get(key);
33
+ if (!st) {
34
+ st = { emails: [], seenIds: new Set(), ws: null };
35
+ mailboxes.set(key, st);
36
+ }
37
+ return st;
38
+ }
39
+
40
+ function sleep(ms: number): Promise<void> {
41
+ return new Promise((resolve) => setTimeout(resolve, ms));
42
+ }
43
+
44
+ function normalizeHost(domain?: string | null): string {
45
+ const raw = String(domain || '').trim();
46
+ if (!raw) {
47
+ return DEFAULT_HOST;
48
+ }
49
+
50
+ let host = raw;
51
+ if (/^https?:\/\//i.test(host)) {
52
+ try {
53
+ host = new URL(host).host;
54
+ } catch {
55
+ host = raw;
56
+ }
57
+ } else if (host.includes('/')) {
58
+ try {
59
+ host = new URL(`https://${host}`).host;
60
+ } catch {
61
+ host = host.split('/')[0];
62
+ }
63
+ }
64
+
65
+ host = host.replace(/^\.+|\.+$/g, '');
66
+ const at = host.lastIndexOf('@');
67
+ if (at >= 0) {
68
+ host = host.slice(at + 1);
69
+ }
70
+ return host || DEFAULT_HOST;
71
+ }
72
+
73
+ function splitAddress(email: string): { local: string; host: string } {
74
+ const trimmed = email.trim();
75
+ const at = trimmed.indexOf('@');
76
+ if (at <= 0 || at === trimmed.length - 1) {
77
+ throw new Error('tempmail-cn: invalid email address');
78
+ }
79
+ return {
80
+ local: trimmed.slice(0, at),
81
+ host: normalizeHost(trimmed.slice(at + 1)),
82
+ };
83
+ }
84
+
85
+ function socketUrl(host: string, eio: number): string {
86
+ return `wss://${host}/socket.io/?EIO=${eio}&transport=websocket`;
87
+ }
88
+
89
+ function socketHeaders(host: string): Record<string, string> {
90
+ return {
91
+ ...DEFAULT_HEADERS,
92
+ Origin: `https://${host}`,
93
+ Referer: `https://${host}/`,
94
+ };
95
+ }
96
+
97
+ function sendEvent(ws: WebSocket, event: string, payload: unknown): void {
98
+ ws.send(`42${JSON.stringify([event, payload])}`);
99
+ }
100
+
101
+ function parseEventPacket(packet: string): { event: string; payload: any } | null {
102
+ if (!packet.startsWith('42')) {
103
+ return null;
104
+ }
105
+ try {
106
+ const decoded = JSON.parse(packet.slice(2));
107
+ if (!Array.isArray(decoded) || typeof decoded[0] !== 'string') {
108
+ return null;
109
+ }
110
+ return { event: decoded[0], payload: decoded[1] };
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ function stableMessageId(raw: any, recipientEmail: string): string {
117
+ const headers = raw?.headers || {};
118
+ return String(
119
+ raw?.id ??
120
+ raw?.messageId ??
121
+ headers['message-id'] ??
122
+ headers.messageId ??
123
+ `${headers.from || raw?.from || ''}\n${headers.subject || raw?.subject || ''}\n${headers.date || raw?.date || ''}\n${recipientEmail}`,
124
+ );
125
+ }
126
+
127
+ function flattenMail(raw: any, recipientEmail: string): any {
128
+ const headers = raw?.headers || {};
129
+ return {
130
+ id: stableMessageId(raw, recipientEmail),
131
+ from: headers.from || raw?.from || '',
132
+ to: recipientEmail,
133
+ subject: headers.subject || raw?.subject || '',
134
+ text: raw?.text || raw?.body || '',
135
+ html: raw?.html || '',
136
+ date: headers.date || raw?.date || '',
137
+ isRead: false,
138
+ attachments: Array.isArray(raw?.attachments) ? raw.attachments : [],
139
+ };
140
+ }
141
+
142
+ async function connectSocket(host: string): Promise<WebSocket> {
143
+ let lastError: Error | null = null;
144
+
145
+ for (const version of SOCKET_IO_VERSIONS) {
146
+ try {
147
+ return await new Promise<WebSocket>((resolve, reject) => {
148
+ const ws = new WebSocket(socketUrl(host, version), {
149
+ handshakeTimeout: CONNECT_TIMEOUT_MS,
150
+ headers: socketHeaders(host),
151
+ });
152
+
153
+ let timer: NodeJS.Timeout | undefined;
154
+ let sentConnect = false;
155
+ let settled = false;
156
+
157
+ const cleanup = () => {
158
+ if (timer) {
159
+ clearTimeout(timer);
160
+ timer = undefined;
161
+ }
162
+ ws.off('message', onMessage);
163
+ ws.off('error', onError);
164
+ ws.off('close', onClose);
165
+ };
166
+
167
+ const resolveOnce = () => {
168
+ if (settled) return;
169
+ settled = true;
170
+ cleanup();
171
+ resolve(ws);
172
+ };
173
+
174
+ const rejectOnce = (err: Error) => {
175
+ if (settled) return;
176
+ settled = true;
177
+ cleanup();
178
+ try {
179
+ ws.close();
180
+ } catch {
181
+ /* ignore close errors */
182
+ }
183
+ reject(err);
184
+ };
185
+
186
+ const armHandshakeFallback = () => {
187
+ if (timer) return;
188
+ timer = setTimeout(resolveOnce, HANDSHAKE_WAIT_MS);
189
+ };
190
+
191
+ const onMessage = (data: WebSocket.RawData) => {
192
+ const packet = data.toString();
193
+ if (packet === '2') {
194
+ try {
195
+ ws.send('3');
196
+ } catch {
197
+ /* ignore send errors during probing */
198
+ }
199
+ return;
200
+ }
201
+ if (!sentConnect) {
202
+ if (!packet.startsWith('0')) {
203
+ rejectOnce(new Error(`tempmail-cn: unexpected open packet for EIO=${version}`));
204
+ return;
205
+ }
206
+ sentConnect = true;
207
+ try {
208
+ ws.send('40');
209
+ } catch (error) {
210
+ rejectOnce(error instanceof Error ? error : new Error(String(error)));
211
+ return;
212
+ }
213
+ armHandshakeFallback();
214
+ return;
215
+ }
216
+ if (packet.startsWith('40')) {
217
+ resolveOnce();
218
+ }
219
+ };
220
+
221
+ const onError = (error: Error) => rejectOnce(error);
222
+ const onClose = () => rejectOnce(new Error(`tempmail-cn: websocket closed during EIO=${version} handshake`));
223
+
224
+ ws.on('message', onMessage);
225
+ ws.once('error', onError);
226
+ ws.once('close', onClose);
227
+ });
228
+ } catch (error) {
229
+ lastError = error instanceof Error ? error : new Error(String(error));
230
+ }
231
+ }
232
+
233
+ throw lastError || new Error('tempmail-cn: websocket handshake failed');
234
+ }
235
+
236
+ async function requestShortId(host: string): Promise<string> {
237
+ const ws = await connectSocket(host);
238
+
239
+ return await new Promise<string>((resolve, reject) => {
240
+ const timer = setTimeout(() => {
241
+ cleanup();
242
+ try {
243
+ ws.close();
244
+ } catch {
245
+ /* ignore close errors */
246
+ }
247
+ reject(new Error('tempmail-cn: timed out waiting for shortid'));
248
+ }, CONNECT_TIMEOUT_MS);
249
+
250
+ const cleanup = () => {
251
+ clearTimeout(timer);
252
+ ws.off('message', onMessage);
253
+ ws.off('error', onError);
254
+ ws.off('close', onClose);
255
+ };
256
+
257
+ const finish = (value: string) => {
258
+ cleanup();
259
+ try {
260
+ ws.close();
261
+ } catch {
262
+ /* ignore close errors */
263
+ }
264
+ resolve(value);
265
+ };
266
+
267
+ const fail = (error: Error) => {
268
+ cleanup();
269
+ try {
270
+ ws.close();
271
+ } catch {
272
+ /* ignore close errors */
273
+ }
274
+ reject(error);
275
+ };
276
+
277
+ const onMessage = (data: WebSocket.RawData) => {
278
+ const packet = data.toString();
279
+ if (packet === '2') {
280
+ ws.send('3');
281
+ return;
282
+ }
283
+ const decoded = parseEventPacket(packet);
284
+ if (!decoded || decoded.event !== 'shortid' || typeof decoded.payload !== 'string') {
285
+ return;
286
+ }
287
+ finish(decoded.payload);
288
+ };
289
+
290
+ const onError = (error: Error) => fail(error);
291
+ const onClose = () => fail(new Error('tempmail-cn: websocket closed before shortid arrived'));
292
+
293
+ ws.on('message', onMessage);
294
+ ws.once('error', onError);
295
+ ws.once('close', onClose);
296
+
297
+ sendEvent(ws, 'request shortid', true);
298
+ });
299
+ }
300
+
301
+ async function ensureMailbox(email: string): Promise<void> {
302
+ const st = getState(email);
303
+ if (st.ws?.readyState === WebSocket.OPEN) {
304
+ return;
305
+ }
306
+ if (st.connectPromise) {
307
+ return st.connectPromise;
308
+ }
309
+
310
+ const { local, host } = splitAddress(email);
311
+
312
+ st.connectPromise = (async () => {
313
+ try {
314
+ const ws = await connectSocket(host);
315
+ st.ws = ws;
316
+
317
+ const detach = () => {
318
+ if (st.ws === ws) {
319
+ st.ws = null;
320
+ }
321
+ st.connectPromise = undefined;
322
+ };
323
+
324
+ ws.on('message', (data: WebSocket.RawData) => {
325
+ const packet = data.toString();
326
+ if (packet === '2') {
327
+ try {
328
+ ws.send('3');
329
+ } catch {
330
+ /* ignore late pong failures */
331
+ }
332
+ return;
333
+ }
334
+
335
+ const decoded = parseEventPacket(packet);
336
+ if (!decoded || decoded.event !== 'mail' || !decoded.payload) {
337
+ return;
338
+ }
339
+
340
+ const normalized = normalizeEmail(flattenMail(decoded.payload, email), email);
341
+ if (!normalized.id || st.seenIds.has(normalized.id)) {
342
+ return;
343
+ }
344
+
345
+ st.seenIds.add(normalized.id);
346
+ st.emails.push(normalized);
347
+ });
348
+
349
+ ws.on('close', detach);
350
+ ws.on('error', detach);
351
+
352
+ sendEvent(ws, 'set shortid', local);
353
+ await sleep(INITIAL_SYNC_WAIT_MS);
354
+ } catch (error) {
355
+ st.connectPromise = undefined;
356
+ throw error;
357
+ }
358
+ })();
359
+
360
+ return st.connectPromise;
361
+ }
362
+
363
+ export async function generateEmail(domain?: string | null): Promise<InternalEmailInfo> {
364
+ const host = normalizeHost(domain);
365
+ const shortid = await requestShortId(host);
366
+ return {
367
+ channel: CHANNEL,
368
+ email: `${shortid}@${host}`,
369
+ };
370
+ }
371
+
372
+ export async function getEmails(email: string): Promise<Email[]> {
373
+ await ensureMailbox(email);
374
+ const st = getState(email);
375
+ return [...st.emails];
376
+ }
package/src/retry.ts CHANGED
@@ -89,17 +89,28 @@ function sleep(ms: number): Promise<void> {
89
89
  * @param fn 要执行的异步操作
90
90
  * @param options 重试配置
91
91
  */
92
- export async function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T> {
92
+ export type RetryWithAttemptsResult<T> =
93
+ | { ok: true; value: T; attempts: number }
94
+ | { ok: false; error: unknown; attempts: number };
95
+
96
+ /**
97
+ * 与 withRetry 相同,额外返回尝试次数(成功或最终失败时均有效)
98
+ */
99
+ export async function withRetryWithAttempts<T>(
100
+ fn: () => Promise<T>,
101
+ options?: RetryOptions,
102
+ ): Promise<RetryWithAttemptsResult<T>> {
93
103
  const opts = { ...DEFAULT_RETRY_OPTIONS, ...options };
94
104
  let lastError: any;
95
105
 
96
106
  for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
107
+ const attempts = attempt + 1;
97
108
  try {
98
109
  const result = await fn();
99
110
  if (attempt > 0) {
100
111
  logger.info(`第 ${attempt + 1} 次尝试成功`);
101
112
  }
102
- return result;
113
+ return { ok: true, value: result, attempts };
103
114
  } catch (error: any) {
104
115
  lastError = error;
105
116
  const errorMsg = error.message || String(error);
@@ -111,7 +122,7 @@ export async function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions)
111
122
  } else if (!opts.shouldRetry(error)) {
112
123
  logger.debug(`不可重试的错误: ${errorMsg}`);
113
124
  }
114
- throw error;
125
+ return { ok: false, error, attempts };
115
126
  }
116
127
 
117
128
  /* 指数退避等待 */
@@ -121,7 +132,13 @@ export async function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions)
121
132
  }
122
133
  }
123
134
 
124
- throw lastError;
135
+ return { ok: false, error: lastError, attempts: opts.maxRetries + 1 };
136
+ }
137
+
138
+ export async function withRetry<T>(fn: () => Promise<T>, options?: RetryOptions): Promise<T> {
139
+ const r = await withRetryWithAttempts(fn, options);
140
+ if (r.ok) return r.value;
141
+ throw r.error;
125
142
  }
126
143
 
127
144
  /**
@@ -0,0 +1,138 @@
1
+ import { getConfig } from './config';
2
+ import { getSdkVersion } from './version';
3
+
4
+ const DEFAULT_URL = 'https://sdk-1.openel.top/v1/event';
5
+ const MAX_BATCH = 32;
6
+ const FLUSH_MS = 2000;
7
+
8
+ const EMAIL_LIKE = /[^\s@]{1,64}@[^\s@]{1,255}/g;
9
+
10
+ interface TelemetryEvent {
11
+ operation: string;
12
+ channel: string;
13
+ success: boolean;
14
+ attempt_count: number;
15
+ channels_tried?: number;
16
+ error?: string;
17
+ ts_ms: number;
18
+ }
19
+
20
+ interface TelemetryBatch {
21
+ schema_version: number;
22
+ sdk_language: string;
23
+ sdk_version: string;
24
+ os: string;
25
+ arch: string;
26
+ events: TelemetryEvent[];
27
+ }
28
+
29
+ const queue: TelemetryEvent[] = [];
30
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
31
+ let periodicStarted = false;
32
+
33
+ function sanitizeError(msg: string): string {
34
+ if (!msg) return '';
35
+ return msg.replace(EMAIL_LIKE, '[redacted]').slice(0, 400);
36
+ }
37
+
38
+ function resolveUrl(): string {
39
+ const c = getConfig();
40
+ const u = (c.telemetryUrl || '').trim();
41
+ if (u) return u;
42
+ return DEFAULT_URL;
43
+ }
44
+
45
+ function telemetryOn(): boolean {
46
+ const v = getConfig().telemetryEnabled;
47
+ if (v === false) return false;
48
+ return true;
49
+ }
50
+
51
+ function getFetchForTelemetry(): { fetchFn: typeof fetch } {
52
+ const c = getConfig();
53
+ return { fetchFn: c.customFetch || fetch };
54
+ }
55
+
56
+ function startPeriodicFlush(): void {
57
+ if (periodicStarted) return;
58
+ periodicStarted = true;
59
+ setInterval(() => {
60
+ void flushQueue();
61
+ }, FLUSH_MS);
62
+ }
63
+
64
+ function scheduleDebouncedFlush(): void {
65
+ if (flushTimer) clearTimeout(flushTimer);
66
+ flushTimer = setTimeout(() => {
67
+ flushTimer = null;
68
+ void flushQueue();
69
+ }, FLUSH_MS);
70
+ }
71
+
72
+ async function flushQueue(): Promise<void> {
73
+ if (!telemetryOn()) {
74
+ queue.length = 0;
75
+ return;
76
+ }
77
+ if (queue.length === 0) return;
78
+
79
+ const events = queue.splice(0, queue.length);
80
+ const url = resolveUrl();
81
+ if (!url) return;
82
+
83
+ const sdkVersion = getSdkVersion();
84
+ const batch: TelemetryBatch = {
85
+ schema_version: 2,
86
+ sdk_language: 'node',
87
+ sdk_version: sdkVersion,
88
+ os: typeof process !== 'undefined' ? process.platform : 'unknown',
89
+ arch: typeof process !== 'undefined' ? process.arch : 'unknown',
90
+ events,
91
+ };
92
+
93
+ const { fetchFn } = getFetchForTelemetry();
94
+ try {
95
+ await fetchFn(url, {
96
+ method: 'POST',
97
+ headers: {
98
+ 'Content-Type': 'application/json',
99
+ 'User-Agent': `tempmail-sdk-node/${sdkVersion}`,
100
+ },
101
+ body: JSON.stringify(batch),
102
+ });
103
+ } catch {
104
+ /* ignore */
105
+ }
106
+ }
107
+
108
+ export function reportTelemetry(
109
+ operation: string,
110
+ channel: string,
111
+ success: boolean,
112
+ attemptCount: number,
113
+ channelsTried: number,
114
+ error: string,
115
+ ): void {
116
+ if (!telemetryOn()) return;
117
+
118
+ startPeriodicFlush();
119
+
120
+ const ev: TelemetryEvent = {
121
+ operation,
122
+ channel,
123
+ success,
124
+ attempt_count: attemptCount,
125
+ ts_ms: Date.now(),
126
+ };
127
+ if (channelsTried > 0) ev.channels_tried = channelsTried;
128
+ const err = sanitizeError(error);
129
+ if (err) ev.error = err;
130
+
131
+ queue.push(ev);
132
+
133
+ if (queue.length >= MAX_BATCH) {
134
+ void flushQueue();
135
+ } else {
136
+ scheduleDebouncedFlush();
137
+ }
138
+ }
package/src/types.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * 支持的临时邮箱渠道标识
3
3
  * 每个渠道对应一个第三方临时邮箱服务商
4
4
  */
5
- export type Channel = 'tempmail' | 'linshi-email' | 'linshiyou' | 'mffac' | 'tempmail-lol' | 'chatgpt-org-uk' | 'temp-mail-io' | 'awamail' | 'temporary-email-org' | 'mail-tm' | 'mail-cx' | 'dropmail' | 'guerrillamail' | 'maildrop' | 'smail-pw' | 'boomlify' | 'minmail' | 'vip-215' | 'anonbox' | 'fake-legal';
5
+ export type Channel = 'tempmail' | 'tempmail-cn' | 'linshi-email' | 'linshiyou' | 'mffac' | 'tempmail-lol' | 'chatgpt-org-uk' | 'temp-mail-io' | 'awamail' | 'temporary-email-org' | 'mail-tm' | 'mail-cx' | 'dropmail' | 'guerrillamail' | 'maildrop' | 'smail-pw' | 'boomlify' | 'minmail' | 'vip-215' | 'anonbox' | 'fake-legal';
6
6
 
7
7
  /**
8
8
  * 创建临时邮箱后返回的邮箱信息
@@ -130,7 +130,7 @@ export interface GenerateEmailOptions {
130
130
  channelFallback?: boolean;
131
131
  /** 邮箱有效时长 */
132
132
  duration?: number;
133
- /** 指定邮箱域名 */
133
+ /** 指定邮箱域名或接入域名(如 `tempmail-cn` 自定义域名) */
134
134
  domain?: string | null;
135
135
  /** 重试配置,不传则使用默认值(最多重试 2 次) */
136
136
  retry?: RetryConfig;
package/src/version.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join } from 'path';
3
+
4
+ let cached: string | null = null;
5
+
6
+ export function getSdkVersion(): string {
7
+ if (cached !== null) {
8
+ return cached;
9
+ }
10
+ try {
11
+ const pkgPath = join(__dirname, '..', 'package.json');
12
+ const raw = readFileSync(pkgPath, 'utf8');
13
+ const j = JSON.parse(raw) as { version?: string };
14
+ cached = j.version && String(j.version).trim() ? String(j.version).trim() : '0.0.0';
15
+ } catch {
16
+ cached = '0.0.0';
17
+ }
18
+ return cached;
19
+ }