@groundmail/groundhog 0.0.0-stage → 0.1.1
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.
- package/LICENSE +21 -0
- package/README.md +323 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +130 -0
- package/dist/client.d.ts +55 -0
- package/dist/client.js +90 -0
- package/dist/extract.d.ts +22 -0
- package/dist/extract.js +116 -0
- package/dist/faults.d.ts +24 -0
- package/dist/faults.js +64 -0
- package/dist/groundmail-account.d.ts +50 -0
- package/dist/groundmail-account.js +233 -0
- package/dist/http.d.ts +58 -0
- package/dist/http.js +152 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +2 -0
- package/dist/mcp.d.ts +11 -0
- package/dist/mcp.js +156 -0
- package/dist/mime.d.ts +52 -0
- package/dist/mime.js +182 -0
- package/dist/providers/common.d.ts +14 -0
- package/dist/providers/common.js +68 -0
- package/dist/providers/groundmail.d.ts +31 -0
- package/dist/providers/groundmail.js +169 -0
- package/dist/providers/index.d.ts +12 -0
- package/dist/providers/index.js +10 -0
- package/dist/providers/mailgun.d.ts +3 -0
- package/dist/providers/mailgun.js +150 -0
- package/dist/providers/postmark.d.ts +3 -0
- package/dist/providers/postmark.js +147 -0
- package/dist/providers/resend.d.ts +8 -0
- package/dist/providers/resend.js +121 -0
- package/dist/providers/sendgrid.d.ts +3 -0
- package/dist/providers/sendgrid.js +128 -0
- package/dist/providers/ses.d.ts +3 -0
- package/dist/providers/ses.js +244 -0
- package/dist/providers/types.d.ts +35 -0
- package/dist/providers/types.js +1 -0
- package/dist/server.d.ts +38 -0
- package/dist/server.js +450 -0
- package/dist/signing.d.ts +25 -0
- package/dist/signing.js +47 -0
- package/dist/smtp.d.ts +13 -0
- package/dist/smtp.js +70 -0
- package/dist/store.d.ts +35 -0
- package/dist/store.js +171 -0
- package/dist/types.d.ts +48 -0
- package/dist/types.js +12 -0
- package/dist/ui/app.css +214 -0
- package/dist/ui/app.js +621 -0
- package/dist/ui/favicon.svg +1 -0
- package/dist/ui/index.html +108 -0
- package/dist/webhooks.d.ts +72 -0
- package/dist/webhooks.js +102 -0
- package/package.json +88 -4
package/dist/extract.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull the one-time code and the links out of an email, so a test (or an agent)
|
|
3
|
+
* can finish a sign-in without reading the email itself.
|
|
4
|
+
*/
|
|
5
|
+
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', '#39': "'" };
|
|
6
|
+
export function decodeEntities(value) {
|
|
7
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+\d*);/gi, (match, entity) => {
|
|
8
|
+
const lower = entity.toLowerCase();
|
|
9
|
+
if (lower.startsWith('#x'))
|
|
10
|
+
return String.fromCodePoint(parseInt(lower.slice(2), 16));
|
|
11
|
+
if (lower.startsWith('#'))
|
|
12
|
+
return String.fromCodePoint(parseInt(lower.slice(1), 10));
|
|
13
|
+
return ENTITIES[lower] ?? match;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/** A rough plain-text rendering of HTML, good enough for finding codes. */
|
|
17
|
+
export function htmlToText(html) {
|
|
18
|
+
return decodeEntities(html
|
|
19
|
+
.replace(/<(script|style|head)[\s\S]*?<\/\1>/gi, ' ')
|
|
20
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
21
|
+
.replace(/<\/(p|div|tr|h[1-6]|li|table)>/gi, '\n')
|
|
22
|
+
.replace(/<[^>]+>/g, ' '))
|
|
23
|
+
.replace(/[ \t\f\v]+/g, ' ')
|
|
24
|
+
.replace(/\n\s*\n+/g, '\n')
|
|
25
|
+
.trim();
|
|
26
|
+
}
|
|
27
|
+
function toParts(input) {
|
|
28
|
+
if (typeof input === 'string') {
|
|
29
|
+
const looksHtml = /<[a-z][\s\S]*>/i.test(input);
|
|
30
|
+
return { html: looksHtml ? input : '', text: looksHtml ? htmlToText(input) : input, subject: '' };
|
|
31
|
+
}
|
|
32
|
+
const html = input.html ?? '';
|
|
33
|
+
const text = input.text?.trim() ? input.text : html ? htmlToText(html) : '';
|
|
34
|
+
return { html, text, subject: input.subject ?? '' };
|
|
35
|
+
}
|
|
36
|
+
const URL_IN_TEXT = /https?:\/\/[^\s<>"'`)\]]+/gi;
|
|
37
|
+
function cleanUrl(url) {
|
|
38
|
+
return decodeEntities(url.trim()).replace(/[.,;:!?]+$/, '');
|
|
39
|
+
}
|
|
40
|
+
/** Every http(s) link in the email, in the order it appears, without duplicates. */
|
|
41
|
+
export function extractLinks(input) {
|
|
42
|
+
const { html, text } = toParts(input);
|
|
43
|
+
const seen = new Set();
|
|
44
|
+
const out = [];
|
|
45
|
+
const add = (url) => {
|
|
46
|
+
const clean = cleanUrl(url);
|
|
47
|
+
if (!/^https?:\/\//i.test(clean) || seen.has(clean))
|
|
48
|
+
return;
|
|
49
|
+
seen.add(clean);
|
|
50
|
+
out.push(clean);
|
|
51
|
+
};
|
|
52
|
+
if (html) {
|
|
53
|
+
for (const match of html.matchAll(/<a\b[^>]*?\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi)) {
|
|
54
|
+
add(match[1] ?? match[2] ?? match[3] ?? '');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
for (const match of (text || '').matchAll(URL_IN_TEXT))
|
|
58
|
+
add(match[0]);
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
const CODE_WORDS = /(code|otp|one[- ]time|passcode|pin|verification|verify|confirm|token|sign[- ]?in|log[- ]?in|2fa|mfa)/i;
|
|
62
|
+
/**
|
|
63
|
+
* The one-time code in the email: digits (4–8, also "123 456" / "123-456"), or a short
|
|
64
|
+
* letters-and-digits code next to a word like "code". `null` when nothing looks like one.
|
|
65
|
+
*/
|
|
66
|
+
export function extractCode(input) {
|
|
67
|
+
const { text, subject } = toParts(input);
|
|
68
|
+
const haystacks = [subject, text].filter(Boolean);
|
|
69
|
+
let best = null;
|
|
70
|
+
let order = 0;
|
|
71
|
+
for (const source of haystacks) {
|
|
72
|
+
const scrubbed = source.replace(URL_IN_TEXT, ' ').replace(/#[0-9a-f]{3,8}\b/gi, ' ');
|
|
73
|
+
const candidates = [];
|
|
74
|
+
for (const match of scrubbed.matchAll(/(?<![\w.$€£-])(\d{3}[ -]\d{3}|\d{4,8})(?![\w%-]|[.,]\d)/g)) {
|
|
75
|
+
candidates.push({ value: match[1], index: match.index ?? 0, alnum: false });
|
|
76
|
+
}
|
|
77
|
+
for (const match of scrubbed.matchAll(/(?<![\w-])([A-Z0-9]{5,8})(?![\w-])/g)) {
|
|
78
|
+
const value = match[1];
|
|
79
|
+
if (/\d/.test(value) && /[A-Z]/.test(value))
|
|
80
|
+
candidates.push({ value, index: match.index ?? 0, alnum: true });
|
|
81
|
+
}
|
|
82
|
+
for (const candidate of candidates) {
|
|
83
|
+
const value = candidate.value.replace(/[ -]/g, '');
|
|
84
|
+
const before = scrubbed.slice(Math.max(0, candidate.index - 60), candidate.index);
|
|
85
|
+
const near = CODE_WORDS.test(before);
|
|
86
|
+
let score = near ? 10 : 0;
|
|
87
|
+
if (candidate.alnum && !near)
|
|
88
|
+
continue;
|
|
89
|
+
if (!candidate.alnum) {
|
|
90
|
+
if (value.length === 6)
|
|
91
|
+
score += 4;
|
|
92
|
+
else if (value.length === 4 || value.length === 8)
|
|
93
|
+
score += 1;
|
|
94
|
+
if (value.length === 4 && /^(19|20)\d\d$/.test(value))
|
|
95
|
+
score -= 8;
|
|
96
|
+
if (/^0+$/.test(value))
|
|
97
|
+
score -= 8;
|
|
98
|
+
}
|
|
99
|
+
if (/[$€£]\s*$/.test(before) || /(#|no\.?|number|order|invoice|receipt)\s*$/i.test(before))
|
|
100
|
+
score -= 6;
|
|
101
|
+
order += 1;
|
|
102
|
+
if (score <= 0)
|
|
103
|
+
continue;
|
|
104
|
+
if (!best || score > best.score)
|
|
105
|
+
best = { value, score, order };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return best && best.score > 0 ? best.value : null;
|
|
109
|
+
}
|
|
110
|
+
/** The first link whose URL contains `contains` (or the first link that isn't an unsubscribe/asset link). */
|
|
111
|
+
export function pickLink(links, contains) {
|
|
112
|
+
if (contains)
|
|
113
|
+
return links.find((link) => link.includes(contains)) ?? null;
|
|
114
|
+
const useful = links.filter((link) => !/unsubscribe|\.(png|jpe?g|gif|svg|css)(\?|$)|list-manage|privacy|terms/i.test(link));
|
|
115
|
+
return useful[0] ?? links[0] ?? null;
|
|
116
|
+
}
|
package/dist/faults.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { type FaultKind } from './types.js';
|
|
3
|
+
export type FaultState = {
|
|
4
|
+
kind: FaultKind | null;
|
|
5
|
+
/** Sends left that will fail. 0 when failure mode is off. */
|
|
6
|
+
remaining: number;
|
|
7
|
+
/** How long a `timeout` fault holds the connection before dropping it. */
|
|
8
|
+
timeoutMs: number;
|
|
9
|
+
};
|
|
10
|
+
export declare const FAULT_LABELS: Record<FaultKind, {
|
|
11
|
+
short: string;
|
|
12
|
+
label: string;
|
|
13
|
+
}>;
|
|
14
|
+
/** `429`, `rate_limit`, `5xx`, `503`, `timeout`… → a fault kind. */
|
|
15
|
+
export declare function parseFaultKind(value: unknown): FaultKind | null;
|
|
16
|
+
export declare class Faults extends EventEmitter {
|
|
17
|
+
private state;
|
|
18
|
+
constructor(timeoutMs?: number);
|
|
19
|
+
get(): FaultState;
|
|
20
|
+
set(kind: FaultKind, next?: number, timeoutMs?: number): FaultState;
|
|
21
|
+
clear(): FaultState;
|
|
22
|
+
/** Take one fault for this send, if failure mode is on. */
|
|
23
|
+
take(): FaultKind | null;
|
|
24
|
+
}
|
package/dist/faults.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { FAULT_KINDS } from './types.js';
|
|
3
|
+
export const FAULT_LABELS = {
|
|
4
|
+
rate_limit: { short: '429', label: 'Rate limited' },
|
|
5
|
+
invalid_recipient: { short: '422', label: 'Invalid recipient' },
|
|
6
|
+
unauthorized: { short: '401', label: 'Bad API key' },
|
|
7
|
+
outage: { short: '5xx', label: 'Provider outage' },
|
|
8
|
+
timeout: { short: 'T/O', label: 'Timeout' },
|
|
9
|
+
};
|
|
10
|
+
/** `429`, `rate_limit`, `5xx`, `503`, `timeout`… → a fault kind. */
|
|
11
|
+
export function parseFaultKind(value) {
|
|
12
|
+
const v = String(value ?? '').trim().toLowerCase();
|
|
13
|
+
if (!v)
|
|
14
|
+
return null;
|
|
15
|
+
if (FAULT_KINDS.includes(v))
|
|
16
|
+
return v;
|
|
17
|
+
if (v === '429' || v === 'rate-limit' || v === 'ratelimit' || v === 'rate_limited')
|
|
18
|
+
return 'rate_limit';
|
|
19
|
+
if (v === '422' || v === '400' || v === 'invalid-recipient' || v === 'bad_recipient')
|
|
20
|
+
return 'invalid_recipient';
|
|
21
|
+
if (v === '401' || v === '403' || v === 'bad_key' || v === 'auth')
|
|
22
|
+
return 'unauthorized';
|
|
23
|
+
if (v === '5xx' || /^5\d\d$/.test(v) || v === 'down')
|
|
24
|
+
return 'outage';
|
|
25
|
+
if (v === 't/o' || v === 'timeout' || v === 'timed_out')
|
|
26
|
+
return 'timeout';
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export class Faults extends EventEmitter {
|
|
30
|
+
state;
|
|
31
|
+
constructor(timeoutMs = 30_000) {
|
|
32
|
+
super();
|
|
33
|
+
this.state = { kind: null, remaining: 0, timeoutMs };
|
|
34
|
+
}
|
|
35
|
+
get() {
|
|
36
|
+
return { ...this.state };
|
|
37
|
+
}
|
|
38
|
+
set(kind, next = 1, timeoutMs) {
|
|
39
|
+
this.state = {
|
|
40
|
+
kind: next > 0 ? kind : null,
|
|
41
|
+
remaining: Math.max(0, Math.floor(next)),
|
|
42
|
+
timeoutMs: timeoutMs ?? this.state.timeoutMs,
|
|
43
|
+
};
|
|
44
|
+
this.emit('change', this.get());
|
|
45
|
+
return this.get();
|
|
46
|
+
}
|
|
47
|
+
clear() {
|
|
48
|
+
this.state = { ...this.state, kind: null, remaining: 0 };
|
|
49
|
+
this.emit('change', this.get());
|
|
50
|
+
return this.get();
|
|
51
|
+
}
|
|
52
|
+
/** Take one fault for this send, if failure mode is on. */
|
|
53
|
+
take() {
|
|
54
|
+
if (!this.state.kind || this.state.remaining <= 0)
|
|
55
|
+
return null;
|
|
56
|
+
const kind = this.state.kind;
|
|
57
|
+
this.state.remaining -= 1;
|
|
58
|
+
if (this.state.remaining <= 0)
|
|
59
|
+
this.state.kind = null;
|
|
60
|
+
this.emit('change', this.get());
|
|
61
|
+
this.emit('fault', kind);
|
|
62
|
+
return kind;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Send to my real inbox": a one-time Groundmail sign-in (OAuth device flow, like
|
|
3
|
+
* `groundmail login`), then copies of caught emails go out through Groundmail's
|
|
4
|
+
* sandbox sender, which only delivers to the account's own address.
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import type { StoredEmail } from './types.js';
|
|
8
|
+
export declare const OAUTH_CLIENT_ID = "groundhog";
|
|
9
|
+
export declare function withUtm(url: string, campaign: string): string;
|
|
10
|
+
export type AccountStatus = {
|
|
11
|
+
connected: boolean;
|
|
12
|
+
email: string | null;
|
|
13
|
+
workspace: string | null;
|
|
14
|
+
pending: {
|
|
15
|
+
userCode: string;
|
|
16
|
+
url: string;
|
|
17
|
+
expiresAt: string;
|
|
18
|
+
} | null;
|
|
19
|
+
error: string | null;
|
|
20
|
+
};
|
|
21
|
+
export declare function configDir(env?: NodeJS.ProcessEnv): string;
|
|
22
|
+
export declare class GroundmailAccount extends EventEmitter {
|
|
23
|
+
private readonly options;
|
|
24
|
+
private pending;
|
|
25
|
+
private lastError;
|
|
26
|
+
private polling;
|
|
27
|
+
constructor(options?: {
|
|
28
|
+
apiUrl?: string;
|
|
29
|
+
from?: string;
|
|
30
|
+
env?: NodeJS.ProcessEnv;
|
|
31
|
+
openBrowser?: (url: string) => void;
|
|
32
|
+
});
|
|
33
|
+
get apiUrl(): string;
|
|
34
|
+
get from(): string;
|
|
35
|
+
private get file();
|
|
36
|
+
private load;
|
|
37
|
+
private save;
|
|
38
|
+
status(): AccountStatus;
|
|
39
|
+
/** Start the browser sign-in. Resolves with the link and code; finishes in the background. */
|
|
40
|
+
connect(): Promise<AccountStatus>;
|
|
41
|
+
private poll;
|
|
42
|
+
private storeTokens;
|
|
43
|
+
private accessToken;
|
|
44
|
+
disconnect(): Promise<AccountStatus>;
|
|
45
|
+
/** Send a copy of a caught email through Groundmail. */
|
|
46
|
+
sendCopy(email: StoredEmail, to?: string): Promise<{
|
|
47
|
+
id: string;
|
|
48
|
+
to: string;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Send to my real inbox": a one-time Groundmail sign-in (OAuth device flow, like
|
|
3
|
+
* `groundmail login`), then copies of caught emails go out through Groundmail's
|
|
4
|
+
* sandbox sender, which only delivers to the account's own address.
|
|
5
|
+
*/
|
|
6
|
+
import { EventEmitter } from 'node:events';
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { homedir, hostname } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
export const OAUTH_CLIENT_ID = 'groundhog';
|
|
11
|
+
const DEVICE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
12
|
+
const UTM = 'utm_source=groundhog&utm_medium=app';
|
|
13
|
+
export function withUtm(url, campaign) {
|
|
14
|
+
return `${url}${url.includes('?') ? '&' : '?'}${UTM}&utm_campaign=${campaign}`;
|
|
15
|
+
}
|
|
16
|
+
export function configDir(env = process.env) {
|
|
17
|
+
if (env.GROUNDHOG_CONFIG_DIR)
|
|
18
|
+
return env.GROUNDHOG_CONFIG_DIR;
|
|
19
|
+
const base = env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
20
|
+
return join(base, 'groundhog');
|
|
21
|
+
}
|
|
22
|
+
async function post(url, body, token) {
|
|
23
|
+
const response = await fetch(url, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': 'groundhog', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
26
|
+
body: JSON.stringify(body),
|
|
27
|
+
signal: AbortSignal.timeout(20_000),
|
|
28
|
+
});
|
|
29
|
+
const json = (await response.json().catch(() => ({})));
|
|
30
|
+
return { status: response.status, body: json };
|
|
31
|
+
}
|
|
32
|
+
function apiError(body, fallback) {
|
|
33
|
+
const error = body.error;
|
|
34
|
+
if (error && typeof error === 'object') {
|
|
35
|
+
const e = error;
|
|
36
|
+
return [e.message, e.hint].filter(Boolean).join(' ') || fallback;
|
|
37
|
+
}
|
|
38
|
+
if (typeof body.error_description === 'string')
|
|
39
|
+
return body.error_description;
|
|
40
|
+
if (typeof error === 'string')
|
|
41
|
+
return [error, body.hint].filter(Boolean).join(' ');
|
|
42
|
+
return fallback;
|
|
43
|
+
}
|
|
44
|
+
export class GroundmailAccount extends EventEmitter {
|
|
45
|
+
options;
|
|
46
|
+
pending = null;
|
|
47
|
+
lastError = null;
|
|
48
|
+
polling = false;
|
|
49
|
+
constructor(options = {}) {
|
|
50
|
+
super();
|
|
51
|
+
this.options = options;
|
|
52
|
+
}
|
|
53
|
+
get apiUrl() {
|
|
54
|
+
return (this.options.apiUrl || this.options.env?.GROUNDMAIL_API_URL || 'https://api.groundmail.dev').replace(/\/+$/, '');
|
|
55
|
+
}
|
|
56
|
+
get from() {
|
|
57
|
+
return this.options.from || 'Groundhog <noreply@mail.groundmail.dev>';
|
|
58
|
+
}
|
|
59
|
+
get file() {
|
|
60
|
+
return join(configDir(this.options.env), 'groundmail.json');
|
|
61
|
+
}
|
|
62
|
+
load() {
|
|
63
|
+
try {
|
|
64
|
+
if (!existsSync(this.file))
|
|
65
|
+
return null;
|
|
66
|
+
const saved = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
67
|
+
return saved.apiUrl === this.apiUrl ? saved : null;
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
save(saved) {
|
|
74
|
+
mkdirSync(configDir(this.options.env), { recursive: true });
|
|
75
|
+
writeFileSync(this.file, JSON.stringify(saved, null, 2), { mode: 0o600 });
|
|
76
|
+
try {
|
|
77
|
+
chmodSync(this.file, 0o600);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// Best effort on filesystems without modes.
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
status() {
|
|
84
|
+
const saved = this.load();
|
|
85
|
+
return {
|
|
86
|
+
connected: Boolean(saved),
|
|
87
|
+
email: saved?.email ?? null,
|
|
88
|
+
workspace: saved?.workspace ?? null,
|
|
89
|
+
pending: this.pending ? { userCode: this.pending.userCode, url: this.pending.url, expiresAt: new Date(this.pending.expiresAt).toISOString() } : null,
|
|
90
|
+
error: this.lastError,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/** Start the browser sign-in. Resolves with the link and code; finishes in the background. */
|
|
94
|
+
async connect() {
|
|
95
|
+
if (this.load())
|
|
96
|
+
return this.status();
|
|
97
|
+
if (this.pending && this.pending.expiresAt > Date.now() + 5000)
|
|
98
|
+
return this.status();
|
|
99
|
+
this.lastError = null;
|
|
100
|
+
const start = await post(`${this.apiUrl}/oauth/device_authorization`, {
|
|
101
|
+
client_id: OAUTH_CLIENT_ID,
|
|
102
|
+
scope: 'emails:send account:read',
|
|
103
|
+
device_name: `Groundhog on ${hostname().slice(0, 60) || 'this computer'}`,
|
|
104
|
+
});
|
|
105
|
+
const body = start.body;
|
|
106
|
+
const url = typeof body.verification_uri_complete === 'string' ? body.verification_uri_complete : body.verification_uri;
|
|
107
|
+
if (start.status >= 400 || typeof body.device_code !== 'string' || typeof url !== 'string') {
|
|
108
|
+
this.lastError = apiError(body, 'Could not start the Groundmail sign-in.');
|
|
109
|
+
throw new Error(this.lastError);
|
|
110
|
+
}
|
|
111
|
+
this.pending = {
|
|
112
|
+
deviceCode: body.device_code,
|
|
113
|
+
userCode: String(body.user_code ?? ''),
|
|
114
|
+
url: withUtm(url, 'real_inbox'),
|
|
115
|
+
interval: Math.max(1, Number(body.interval) || 5),
|
|
116
|
+
expiresAt: Date.now() + (Number(body.expires_in) || 600) * 1000,
|
|
117
|
+
};
|
|
118
|
+
this.options.openBrowser?.(this.pending.url);
|
|
119
|
+
void this.poll();
|
|
120
|
+
this.emit('change', this.status());
|
|
121
|
+
return this.status();
|
|
122
|
+
}
|
|
123
|
+
async poll() {
|
|
124
|
+
if (this.polling)
|
|
125
|
+
return;
|
|
126
|
+
this.polling = true;
|
|
127
|
+
try {
|
|
128
|
+
while (this.pending && this.pending.expiresAt > Date.now()) {
|
|
129
|
+
await new Promise((resolve) => setTimeout(resolve, this.pending.interval * 1000).unref?.());
|
|
130
|
+
const pending = this.pending;
|
|
131
|
+
if (!pending)
|
|
132
|
+
break;
|
|
133
|
+
let result;
|
|
134
|
+
try {
|
|
135
|
+
result = await post(`${this.apiUrl}/oauth/token`, { grant_type: DEVICE_GRANT, device_code: pending.deviceCode, client_id: OAUTH_CLIENT_ID });
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (result.status < 300 && typeof result.body.access_token === 'string') {
|
|
141
|
+
this.storeTokens(result.body);
|
|
142
|
+
this.pending = null;
|
|
143
|
+
this.emit('change', this.status());
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const code = String(result.body.error ?? '');
|
|
147
|
+
if (code === 'authorization_pending')
|
|
148
|
+
continue;
|
|
149
|
+
if (code === 'slow_down') {
|
|
150
|
+
pending.interval += 5;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
this.lastError = code === 'access_denied' ? 'The sign-in was canceled.' : apiError(result.body, 'The sign-in expired. Try again.');
|
|
154
|
+
this.pending = null;
|
|
155
|
+
this.emit('change', this.status());
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (this.pending) {
|
|
159
|
+
this.pending = null;
|
|
160
|
+
this.lastError = 'The sign-in expired. Try again.';
|
|
161
|
+
this.emit('change', this.status());
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
this.polling = false;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
storeTokens(body, previous) {
|
|
169
|
+
const user = (body.user ?? {});
|
|
170
|
+
const workspace = (body.workspace ?? {});
|
|
171
|
+
const saved = {
|
|
172
|
+
apiUrl: this.apiUrl,
|
|
173
|
+
accessToken: String(body.access_token),
|
|
174
|
+
accessTokenExpiresAt: Date.now() + (Number(body.expires_in) || 3600) * 1000,
|
|
175
|
+
refreshToken: typeof body.refresh_token === 'string' ? body.refresh_token : (previous?.refreshToken ?? ''),
|
|
176
|
+
email: user.email ?? previous?.email ?? null,
|
|
177
|
+
workspace: workspace.name ?? previous?.workspace ?? null,
|
|
178
|
+
savedAt: new Date().toISOString(),
|
|
179
|
+
};
|
|
180
|
+
this.save(saved);
|
|
181
|
+
return saved;
|
|
182
|
+
}
|
|
183
|
+
async accessToken() {
|
|
184
|
+
const saved = this.load();
|
|
185
|
+
if (!saved)
|
|
186
|
+
throw new Error('Connect Groundmail first.');
|
|
187
|
+
if (saved.accessTokenExpiresAt > Date.now() + 60_000)
|
|
188
|
+
return saved.accessToken;
|
|
189
|
+
const result = await post(`${this.apiUrl}/oauth/token`, { grant_type: 'refresh_token', refresh_token: saved.refreshToken, client_id: OAUTH_CLIENT_ID });
|
|
190
|
+
if (result.status >= 300 || typeof result.body.access_token !== 'string') {
|
|
191
|
+
if (result.status === 400 || result.status === 401)
|
|
192
|
+
rmSync(this.file, { force: true });
|
|
193
|
+
throw new Error(apiError(result.body, 'The Groundmail sign-in expired. Connect again.'));
|
|
194
|
+
}
|
|
195
|
+
return this.storeTokens(result.body, saved).accessToken;
|
|
196
|
+
}
|
|
197
|
+
async disconnect() {
|
|
198
|
+
const saved = this.load();
|
|
199
|
+
this.pending = null;
|
|
200
|
+
if (saved?.refreshToken) {
|
|
201
|
+
await post(`${this.apiUrl}/oauth/revoke`, { token: saved.refreshToken, token_type_hint: 'refresh_token', client_id: OAUTH_CLIENT_ID }).catch(() => null);
|
|
202
|
+
}
|
|
203
|
+
rmSync(this.file, { force: true });
|
|
204
|
+
this.emit('change', this.status());
|
|
205
|
+
return this.status();
|
|
206
|
+
}
|
|
207
|
+
/** Send a copy of a caught email through Groundmail. */
|
|
208
|
+
async sendCopy(email, to) {
|
|
209
|
+
const saved = this.load();
|
|
210
|
+
const recipient = (to || saved?.email || '').trim();
|
|
211
|
+
if (!recipient)
|
|
212
|
+
throw new Error('Which address should get the copy?');
|
|
213
|
+
const token = await this.accessToken();
|
|
214
|
+
const attachments = email.attachments.map((attachment) => ({
|
|
215
|
+
filename: attachment.filename,
|
|
216
|
+
content: email.attachmentContent.get(attachment.id)?.toString('base64') ?? '',
|
|
217
|
+
content_type: attachment.contentType,
|
|
218
|
+
}));
|
|
219
|
+
const result = await post(`${this.apiUrl}/emails`, {
|
|
220
|
+
from: this.from,
|
|
221
|
+
to: [recipient],
|
|
222
|
+
subject: email.subject || '(no subject)',
|
|
223
|
+
...(email.html ? { html: email.html } : {}),
|
|
224
|
+
...(email.text ? { text: email.text } : {}),
|
|
225
|
+
...(!email.html && !email.text ? { text: '(This email had no body.)' } : {}),
|
|
226
|
+
...(attachments.length ? { attachments } : {}),
|
|
227
|
+
headers: { 'X-Groundhog-Copy-Of': email.id },
|
|
228
|
+
}, token);
|
|
229
|
+
if (result.status >= 300)
|
|
230
|
+
throw new Error(apiError(result.body, `Groundmail answered ${result.status}.`));
|
|
231
|
+
return { id: String(result.body.id ?? ''), to: recipient };
|
|
232
|
+
}
|
|
233
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
export declare const MAX_BODY_BYTES: number;
|
|
3
|
+
export declare class HttpError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
constructor(status: number, message: string);
|
|
6
|
+
}
|
|
7
|
+
export type HttpResult = {
|
|
8
|
+
status: number;
|
|
9
|
+
headers?: Record<string, string>;
|
|
10
|
+
body?: unknown;
|
|
11
|
+
};
|
|
12
|
+
export type MultipartFile = {
|
|
13
|
+
field: string;
|
|
14
|
+
filename: string;
|
|
15
|
+
contentType: string;
|
|
16
|
+
content: Buffer;
|
|
17
|
+
};
|
|
18
|
+
export type MultipartForm = {
|
|
19
|
+
fields: Array<[string, string]>;
|
|
20
|
+
files: MultipartFile[];
|
|
21
|
+
};
|
|
22
|
+
export declare class RequestContext {
|
|
23
|
+
readonly req: IncomingMessage;
|
|
24
|
+
readonly res: ServerResponse;
|
|
25
|
+
readonly url: URL;
|
|
26
|
+
params: Record<string, string>;
|
|
27
|
+
private bodyPromise;
|
|
28
|
+
constructor(req: IncomingMessage, res: ServerResponse);
|
|
29
|
+
get method(): string;
|
|
30
|
+
header(name: string): string;
|
|
31
|
+
query(name: string): string | undefined;
|
|
32
|
+
body(): Promise<Buffer>;
|
|
33
|
+
json<T = Record<string, unknown>>(): Promise<T>;
|
|
34
|
+
/** application/x-www-form-urlencoded, as ordered pairs (keys repeat). */
|
|
35
|
+
formPairs(): Promise<Array<[string, string]>>;
|
|
36
|
+
multipart(): Promise<MultipartForm>;
|
|
37
|
+
send(result: HttpResult): void;
|
|
38
|
+
}
|
|
39
|
+
export type Handler = (ctx: RequestContext) => Promise<HttpResult | void> | HttpResult | void;
|
|
40
|
+
export type Route = {
|
|
41
|
+
method: string;
|
|
42
|
+
/** Path with `:params`, e.g. `/emails/:id`. A trailing `*` matches the rest. */
|
|
43
|
+
path: string;
|
|
44
|
+
handler: Handler;
|
|
45
|
+
};
|
|
46
|
+
export declare function compileRoute(path: string): RegExp;
|
|
47
|
+
export declare class Router {
|
|
48
|
+
private routes;
|
|
49
|
+
add(method: string, path: string, handler: Handler): this;
|
|
50
|
+
get(path: string, handler: Handler): this;
|
|
51
|
+
post(path: string, handler: Handler): this;
|
|
52
|
+
put(path: string, handler: Handler): this;
|
|
53
|
+
delete(path: string, handler: Handler): this;
|
|
54
|
+
match(method: string, pathname: string): {
|
|
55
|
+
route: Route;
|
|
56
|
+
params: Record<string, string>;
|
|
57
|
+
} | 'method' | null;
|
|
58
|
+
}
|