nothumanallowed 1.1.0 → 2.1.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,242 @@
1
+ /**
2
+ * Gmail API wrapper — zero dependencies.
3
+ * All calls via native fetch to Gmail REST API.
4
+ * Auto-refreshes tokens on 401.
5
+ */
6
+
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import { getAccessToken } from './token-store.mjs';
10
+ import { NHA_DIR } from '../constants.mjs';
11
+
12
+ const GMAIL_BASE = 'https://gmail.googleapis.com/gmail/v1/users/me';
13
+ const MAIL_DIR = path.join(NHA_DIR, 'ops', 'mail');
14
+ const INBOX_DIR = path.join(MAIL_DIR, 'inbox');
15
+
16
+ /** Authenticated fetch with auto-retry on 401 */
17
+ async function gmailFetch(config, urlPath, options = {}) {
18
+ const token = await getAccessToken(config);
19
+ const url = urlPath.startsWith('http') ? urlPath : `${GMAIL_BASE}${urlPath}`;
20
+
21
+ let res = await fetch(url, {
22
+ ...options,
23
+ headers: {
24
+ ...options.headers,
25
+ 'Authorization': `Bearer ${token}`,
26
+ },
27
+ });
28
+
29
+ // Retry once on 401 (token may have expired between check and request)
30
+ if (res.status === 401) {
31
+ const newToken = await getAccessToken(config);
32
+ res = await fetch(url, {
33
+ ...options,
34
+ headers: {
35
+ ...options.headers,
36
+ 'Authorization': `Bearer ${newToken}`,
37
+ },
38
+ });
39
+ }
40
+
41
+ if (!res.ok) {
42
+ const err = await res.text();
43
+ throw new Error(`Gmail API ${res.status}: ${err}`);
44
+ }
45
+
46
+ return res.json();
47
+ }
48
+
49
+ /**
50
+ * List messages matching a query.
51
+ * @param {object} config
52
+ * @param {string} query — Gmail search query (e.g., "is:unread", "from:boss@company.com")
53
+ * @param {number} maxResults — max messages (default 20)
54
+ * @returns {Promise<Array<{id: string, threadId: string}>>}
55
+ */
56
+ export async function listMessages(config, query = 'is:unread', maxResults = 20) {
57
+ const params = new URLSearchParams({ q: query, maxResults: String(maxResults) });
58
+ const data = await gmailFetch(config, `/messages?${params}`);
59
+ return data.messages || [];
60
+ }
61
+
62
+ /**
63
+ * Get a full message by ID.
64
+ * @param {object} config
65
+ * @param {string} messageId
66
+ * @returns {Promise<object>} parsed message with headers, body, snippet
67
+ */
68
+ export async function getMessage(config, messageId) {
69
+ const data = await gmailFetch(config, `/messages/${messageId}?format=full`);
70
+ return parseMessage(data);
71
+ }
72
+
73
+ /**
74
+ * Get unread important emails (for daily planner).
75
+ * @param {object} config
76
+ * @param {number} maxResults
77
+ * @returns {Promise<Array>} parsed messages
78
+ */
79
+ export async function getUnreadImportant(config, maxResults = 30) {
80
+ const messageRefs = await listMessages(config, 'is:unread -category:promotions -category:social', maxResults);
81
+ const messages = [];
82
+
83
+ for (const ref of messageRefs.slice(0, maxResults)) {
84
+ try {
85
+ const msg = await getMessage(config, ref.id);
86
+ messages.push(msg);
87
+ } catch { /* skip failed messages */ }
88
+ }
89
+
90
+ // Cache messages locally
91
+ cacheMessages(messages);
92
+ return messages;
93
+ }
94
+
95
+ /**
96
+ * Get emails from today (read + unread).
97
+ */
98
+ export async function getTodayEmails(config, maxResults = 50) {
99
+ const today = new Date().toISOString().split('T')[0].replace(/-/g, '/');
100
+ const messageRefs = await listMessages(config, `after:${today}`, maxResults);
101
+ const messages = [];
102
+
103
+ for (const ref of messageRefs.slice(0, maxResults)) {
104
+ try {
105
+ const msg = await getMessage(config, ref.id);
106
+ messages.push(msg);
107
+ } catch {}
108
+ }
109
+
110
+ cacheMessages(messages);
111
+ return messages;
112
+ }
113
+
114
+ /**
115
+ * Send an email.
116
+ * @param {object} config
117
+ * @param {string} to
118
+ * @param {string} subject
119
+ * @param {string} body — plain text
120
+ * @param {object} opts — { cc, bcc, replyToMessageId, threadId }
121
+ */
122
+ export async function sendEmail(config, to, subject, body, opts = {}) {
123
+ const lines = [
124
+ `To: ${to}`,
125
+ `Subject: ${subject}`,
126
+ 'Content-Type: text/plain; charset=utf-8',
127
+ 'MIME-Version: 1.0',
128
+ ];
129
+ if (opts.cc) lines.push(`Cc: ${opts.cc}`);
130
+ if (opts.bcc) lines.push(`Bcc: ${opts.bcc}`);
131
+ if (opts.replyToMessageId) lines.push(`In-Reply-To: ${opts.replyToMessageId}`);
132
+ lines.push('', body);
133
+
134
+ const raw = Buffer.from(lines.join('\r\n')).toString('base64url');
135
+ const reqBody = { raw };
136
+ if (opts.threadId) reqBody.threadId = opts.threadId;
137
+
138
+ return gmailFetch(config, '/messages/send', {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify(reqBody),
142
+ });
143
+ }
144
+
145
+ /**
146
+ * Create a draft.
147
+ */
148
+ export async function createDraft(config, to, subject, body) {
149
+ const lines = [
150
+ `To: ${to}`,
151
+ `Subject: ${subject}`,
152
+ 'Content-Type: text/plain; charset=utf-8',
153
+ 'MIME-Version: 1.0',
154
+ '',
155
+ body,
156
+ ];
157
+ const raw = Buffer.from(lines.join('\r\n')).toString('base64url');
158
+
159
+ return gmailFetch(config, '/drafts', {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify({ message: { raw } }),
163
+ });
164
+ }
165
+
166
+ /**
167
+ * Get user's email profile.
168
+ */
169
+ export async function getProfile(config) {
170
+ return gmailFetch(config, '/profile');
171
+ }
172
+
173
+ /**
174
+ * Get labels.
175
+ */
176
+ export async function listLabels(config) {
177
+ const data = await gmailFetch(config, '/labels');
178
+ return data.labels || [];
179
+ }
180
+
181
+ // ── Message Parser ─────────────────────────────────────────────────────────
182
+
183
+ function parseMessage(raw) {
184
+ const headers = {};
185
+ for (const h of raw.payload?.headers || []) {
186
+ headers[h.name.toLowerCase()] = h.value;
187
+ }
188
+
189
+ let body = '';
190
+ if (raw.payload?.body?.data) {
191
+ body = Buffer.from(raw.payload.body.data, 'base64url').toString('utf-8');
192
+ } else if (raw.payload?.parts) {
193
+ const textPart = raw.payload.parts.find(p => p.mimeType === 'text/plain');
194
+ if (textPart?.body?.data) {
195
+ body = Buffer.from(textPart.body.data, 'base64url').toString('utf-8');
196
+ }
197
+ }
198
+
199
+ // Extract URLs from body
200
+ const urls = (body.match(/https?:\/\/[^\s<>"']+/g) || []).slice(0, 10);
201
+
202
+ return {
203
+ id: raw.id,
204
+ threadId: raw.threadId,
205
+ from: headers.from || '',
206
+ to: headers.to || '',
207
+ subject: headers.subject || '(no subject)',
208
+ date: headers.date || '',
209
+ snippet: raw.snippet || '',
210
+ body: body.slice(0, 5000), // cap for LLM context
211
+ urls,
212
+ labels: raw.labelIds || [],
213
+ isUnread: (raw.labelIds || []).includes('UNREAD'),
214
+ isImportant: (raw.labelIds || []).includes('IMPORTANT'),
215
+ sizeEstimate: raw.sizeEstimate || 0,
216
+ };
217
+ }
218
+
219
+ // ── Local Cache ────────────────────────────────────────────────────────────
220
+
221
+ function cacheMessages(messages) {
222
+ fs.mkdirSync(INBOX_DIR, { recursive: true });
223
+ for (const msg of messages) {
224
+ const file = path.join(INBOX_DIR, `${msg.id}.json`);
225
+ fs.writeFileSync(file, JSON.stringify(msg, null, 2), { mode: 0o600 });
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Load cached messages from disk.
231
+ * @returns {Array} cached messages
232
+ */
233
+ export function loadCachedMessages() {
234
+ if (!fs.existsSync(INBOX_DIR)) return [];
235
+ return fs.readdirSync(INBOX_DIR)
236
+ .filter(f => f.endsWith('.json'))
237
+ .map(f => {
238
+ try { return JSON.parse(fs.readFileSync(path.join(INBOX_DIR, f), 'utf-8')); }
239
+ catch { return null; }
240
+ })
241
+ .filter(Boolean);
242
+ }
@@ -0,0 +1,272 @@
1
+ /**
2
+ * Google OAuth 2.0 with PKCE — browser-based consent flow.
3
+ * Runs ephemeral local HTTP server for callback.
4
+ * Zero dependencies — uses Node.js native http + crypto.
5
+ */
6
+
7
+ import http from 'http';
8
+ import crypto from 'crypto';
9
+ import { execSync } from 'child_process';
10
+ import os from 'os';
11
+ import { saveTokens, loadTokens, deleteTokens } from './token-store.mjs';
12
+ import { info, ok, fail, warn } from '../ui.mjs';
13
+
14
+ // NHA published OAuth client (Desktop app type — client_id is not a secret)
15
+ const DEFAULT_CLIENT_ID = ''; // Will be set when Google Cloud project is verified
16
+ const SCOPES = [
17
+ 'https://www.googleapis.com/auth/gmail.readonly',
18
+ 'https://www.googleapis.com/auth/gmail.send',
19
+ 'https://www.googleapis.com/auth/gmail.compose',
20
+ 'https://www.googleapis.com/auth/calendar.readonly',
21
+ 'https://www.googleapis.com/auth/calendar.events',
22
+ 'https://www.googleapis.com/auth/userinfo.email',
23
+ ].join(' ');
24
+
25
+ const CALLBACK_PORTS = [19847, 19848, 19849, 19850, 19851];
26
+
27
+ /**
28
+ * Generate PKCE code_verifier and code_challenge.
29
+ */
30
+ function generatePKCE() {
31
+ const verifier = crypto.randomBytes(32).toString('base64url');
32
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
33
+ return { verifier, challenge };
34
+ }
35
+
36
+ /**
37
+ * Open URL in user's default browser.
38
+ */
39
+ function openBrowser(url) {
40
+ const platform = os.platform();
41
+ try {
42
+ if (platform === 'darwin') execSync(`open "${url}"`);
43
+ else if (platform === 'win32') execSync(`start "" "${url}"`);
44
+ else execSync(`xdg-open "${url}"`);
45
+ } catch {
46
+ warn('Could not open browser automatically.');
47
+ info(`Open this URL manually:\n\n ${url}\n`);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Start ephemeral HTTP server and wait for OAuth callback.
53
+ * @returns {Promise<{code: string, port: number}>}
54
+ */
55
+ function waitForCallback(state, port) {
56
+ return new Promise((resolve, reject) => {
57
+ const server = http.createServer((req, res) => {
58
+ const url = new URL(req.url, `http://127.0.0.1:${port}`);
59
+ if (url.pathname !== '/callback') {
60
+ res.writeHead(404);
61
+ res.end('Not found');
62
+ return;
63
+ }
64
+
65
+ const code = url.searchParams.get('code');
66
+ const returnedState = url.searchParams.get('state');
67
+ const error = url.searchParams.get('error');
68
+
69
+ if (error) {
70
+ res.writeHead(200, { 'Content-Type': 'text/html' });
71
+ res.end(`<html><body><h2>Authorization failed</h2><p>${error}</p><p>You can close this tab.</p></body></html>`);
72
+ server.close();
73
+ reject(new Error(`OAuth error: ${error}`));
74
+ return;
75
+ }
76
+
77
+ if (!code || returnedState !== state) {
78
+ res.writeHead(400, { 'Content-Type': 'text/html' });
79
+ res.end('<html><body><h2>Invalid callback</h2><p>Missing code or state mismatch.</p></body></html>');
80
+ server.close();
81
+ reject(new Error('Invalid OAuth callback'));
82
+ return;
83
+ }
84
+
85
+ res.writeHead(200, { 'Content-Type': 'text/html' });
86
+ res.end(`
87
+ <html>
88
+ <head><style>body{font-family:monospace;background:#0a0a0a;color:#00ff41;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
89
+ .box{text-align:center;border:1px solid #00ff41;padding:40px;border-radius:8px}</style></head>
90
+ <body><div class="box">
91
+ <h2>NHA Connected</h2>
92
+ <p>Google account linked successfully.</p>
93
+ <p style="color:#666">You can close this tab and return to the terminal.</p>
94
+ </div></body></html>
95
+ `);
96
+
97
+ server.close();
98
+ resolve({ code, port });
99
+ });
100
+
101
+ server.listen(port, '127.0.0.1');
102
+ server.on('error', () => reject(new Error(`Port ${port} in use`)));
103
+
104
+ // 5 minute timeout
105
+ setTimeout(() => {
106
+ server.close();
107
+ reject(new Error('OAuth timeout — no callback received within 5 minutes'));
108
+ }, 300_000);
109
+ });
110
+ }
111
+
112
+ /**
113
+ * Exchange authorization code for tokens.
114
+ */
115
+ async function exchangeCode(code, codeVerifier, clientId, clientSecret, redirectUri) {
116
+ const params = new URLSearchParams({
117
+ code,
118
+ client_id: clientId,
119
+ client_secret: clientSecret,
120
+ redirect_uri: redirectUri,
121
+ grant_type: 'authorization_code',
122
+ code_verifier: codeVerifier,
123
+ });
124
+
125
+ const res = await fetch('https://oauth2.googleapis.com/token', {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
128
+ body: params.toString(),
129
+ });
130
+
131
+ if (!res.ok) {
132
+ const err = await res.text();
133
+ throw new Error(`Token exchange failed: ${err}`);
134
+ }
135
+
136
+ return res.json();
137
+ }
138
+
139
+ /**
140
+ * Fetch authenticated user's email address.
141
+ */
142
+ async function getUserEmail(accessToken) {
143
+ const res = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
144
+ headers: { Authorization: `Bearer ${accessToken}` },
145
+ });
146
+ if (!res.ok) return null;
147
+ const data = await res.json();
148
+ return data.email || null;
149
+ }
150
+
151
+ /**
152
+ * Run the full OAuth consent flow.
153
+ * @param {object} config — NHA config
154
+ */
155
+ export async function runAuthFlow(config) {
156
+ const clientId = config.google?.clientId || DEFAULT_CLIENT_ID;
157
+ const clientSecret = config.google?.clientSecret || '';
158
+
159
+ if (!clientId) {
160
+ fail('Google OAuth client ID not configured.');
161
+ info('Get credentials from Google Cloud Console:');
162
+ info(' 1. Go to https://console.cloud.google.com/apis/credentials');
163
+ info(' 2. Create an OAuth 2.0 Client ID (Desktop app type)');
164
+ info(' 3. Enable Gmail API and Calendar API');
165
+ info(' 4. Run:');
166
+ info(' nha config set google-client-id YOUR_CLIENT_ID');
167
+ info(' nha config set google-client-secret YOUR_CLIENT_SECRET');
168
+ info(' 5. Run: nha google auth');
169
+ return false;
170
+ }
171
+
172
+ // Find available port
173
+ let port = 0;
174
+ for (const p of CALLBACK_PORTS) {
175
+ try {
176
+ const srv = http.createServer();
177
+ await new Promise((resolve, reject) => {
178
+ srv.listen(p, '127.0.0.1', () => { srv.close(); resolve(true); });
179
+ srv.on('error', () => reject());
180
+ });
181
+ port = p;
182
+ break;
183
+ } catch { continue; }
184
+ }
185
+ if (!port) {
186
+ fail('No available port for OAuth callback (tried 19847-19851)');
187
+ return false;
188
+ }
189
+
190
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
191
+ const { verifier, challenge } = generatePKCE();
192
+ const state = crypto.randomBytes(32).toString('hex');
193
+
194
+ const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
195
+ authUrl.searchParams.set('client_id', clientId);
196
+ authUrl.searchParams.set('redirect_uri', redirectUri);
197
+ authUrl.searchParams.set('response_type', 'code');
198
+ authUrl.searchParams.set('scope', SCOPES);
199
+ authUrl.searchParams.set('state', state);
200
+ authUrl.searchParams.set('code_challenge', challenge);
201
+ authUrl.searchParams.set('code_challenge_method', 'S256');
202
+ authUrl.searchParams.set('access_type', 'offline');
203
+ authUrl.searchParams.set('prompt', 'consent');
204
+
205
+ info('Opening browser for Google authorization...');
206
+ openBrowser(authUrl.toString());
207
+ info('Waiting for authorization (5 min timeout)...\n');
208
+
209
+ try {
210
+ const { code } = await waitForCallback(state, port);
211
+ info('Authorization code received. Exchanging for tokens...');
212
+
213
+ const tokenData = await exchangeCode(code, verifier, clientId, clientSecret, redirectUri);
214
+ const email = await getUserEmail(tokenData.access_token);
215
+
216
+ const tokens = {
217
+ access_token: tokenData.access_token,
218
+ refresh_token: tokenData.refresh_token,
219
+ expires_at: Date.now() + (tokenData.expires_in * 1000),
220
+ scope: tokenData.scope,
221
+ email: email || 'unknown',
222
+ };
223
+
224
+ saveTokens(tokens);
225
+ ok(`Google account connected: ${email || 'unknown'}`);
226
+ ok('Gmail + Calendar access granted.');
227
+ info('Run "nha plan" to generate your first daily plan.');
228
+ return true;
229
+ } catch (err) {
230
+ fail(err.message);
231
+ return false;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Show connection status.
237
+ */
238
+ export function showStatus() {
239
+ const tokens = loadTokens();
240
+ if (!tokens) {
241
+ info('Not connected to Google. Run: nha google auth');
242
+ return;
243
+ }
244
+
245
+ const expired = Date.now() >= tokens.expires_at;
246
+ console.log(`\n Google Account: ${tokens.email || 'unknown'}`);
247
+ console.log(` Token Status: ${expired ? '\x1b[0;31mexpired\x1b[0m' : '\x1b[0;32mactive\x1b[0m'}`);
248
+ console.log(` Expires: ${new Date(tokens.expires_at).toLocaleString()}`);
249
+ console.log(` Scopes: ${tokens.scope || 'unknown'}\n`);
250
+ }
251
+
252
+ /**
253
+ * Revoke tokens and delete local storage.
254
+ */
255
+ export async function revokeAuth() {
256
+ const tokens = loadTokens();
257
+ if (!tokens) {
258
+ info('No Google tokens found.');
259
+ return;
260
+ }
261
+
262
+ // Revoke at Google
263
+ try {
264
+ await fetch(`https://oauth2.googleapis.com/revoke?token=${tokens.access_token}`, {
265
+ method: 'POST',
266
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
267
+ });
268
+ } catch { /* best effort */ }
269
+
270
+ deleteTokens();
271
+ ok('Google account disconnected. Tokens revoked.');
272
+ }